Skip to content

Commit dc2e8ab

Browse files
authored
[ty] support kw_only=True for dataclass() and field() (#19677)
## Summary astral-sh/ty#111 adds support for `@dataclass(kw_only=True)` (https://docs.python.org/3/library/dataclasses.html) ## Test Plan - new mdtests - triaged conformance diffs (notes here: https://diffswarm.dev/d-01k2gknwyq82f6x17zqf3apjxc) - `mypy_primer` no-op
1 parent 9aaa82d commit dc2e8ab

File tree

6 files changed

+155
-10
lines changed

6 files changed

+155
-10
lines changed

crates/ty_python_semantic/resources/mdtest/dataclasses/dataclass_transform.md

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,41 @@ OrderTrueOverwritten(1) < OrderTrueOverwritten(2)
195195

196196
### `kw_only_default`
197197

198-
To do
198+
When provided, sets the default value for the `kw_only` parameter of `field()`.
199+
200+
```py
201+
from typing import dataclass_transform
202+
from dataclasses import field
203+
204+
@dataclass_transform(kw_only_default=True)
205+
def create_model(*, init=True): ...
206+
@create_model()
207+
class A:
208+
name: str
209+
210+
a = A(name="Harry")
211+
# error: [missing-argument]
212+
# error: [too-many-positional-arguments]
213+
a = A("Harry")
214+
```
215+
216+
TODO: This can be overridden by the call to the decorator function.
217+
218+
```py
219+
from typing import dataclass_transform
220+
221+
@dataclass_transform(kw_only_default=True)
222+
def create_model(*, kw_only: bool = True): ...
223+
@create_model(kw_only=False)
224+
class CustomerModel:
225+
id: int
226+
name: str
227+
228+
# TODO: Should not emit errors
229+
# error: [missing-argument]
230+
# error: [too-many-positional-arguments]
231+
c = CustomerModel(1, "Harry")
232+
```
199233

200234
### `field_specifiers`
201235

crates/ty_python_semantic/resources/mdtest/dataclasses/dataclasses.md

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -465,7 +465,84 @@ To do
465465

466466
### `kw_only`
467467

468-
To do
468+
An error is emitted if a dataclass is defined with `kw_only=True` and positional arguments are
469+
passed to the constructor.
470+
471+
```toml
472+
[environment]
473+
python-version = "3.10"
474+
```
475+
476+
```py
477+
from dataclasses import dataclass
478+
479+
@dataclass(kw_only=True)
480+
class A:
481+
x: int
482+
y: int
483+
484+
# error: [missing-argument] "No arguments provided for required parameters `x`, `y`"
485+
# error: [too-many-positional-arguments] "Too many positional arguments: expected 0, got 2"
486+
a = A(1, 2)
487+
a = A(x=1, y=2)
488+
```
489+
490+
The class-level parameter can be overridden per-field.
491+
492+
```py
493+
from dataclasses import dataclass, field
494+
495+
@dataclass(kw_only=True)
496+
class A:
497+
a: str = field(kw_only=False)
498+
b: int = 0
499+
500+
A("hi")
501+
```
502+
503+
If some fields are `kw_only`, they should appear after all positional fields in the `__init__`
504+
signature.
505+
506+
```py
507+
@dataclass
508+
class A:
509+
b: int = field(kw_only=True, default=3)
510+
a: str
511+
512+
A("hi")
513+
```
514+
515+
The field-level `kw_only` value takes precedence over the `KW_ONLY` pseudo-type.
516+
517+
```py
518+
from dataclasses import field, dataclass, KW_ONLY
519+
520+
@dataclass
521+
class C:
522+
_: KW_ONLY
523+
x: int = field(kw_only=False)
524+
525+
C(x=1)
526+
C(1)
527+
```
528+
529+
### `kw_only` - Python < 3.10
530+
531+
For Python < 3.10, `kw_only` is not supported.
532+
533+
```toml
534+
[environment]
535+
python-version = "3.9"
536+
```
537+
538+
```py
539+
from dataclasses import dataclass
540+
541+
@dataclass(kw_only=True) # TODO: Emit a diagnostic here
542+
class A:
543+
x: int
544+
y: int
545+
```
469546

470547
### `slots`
471548

crates/ty_python_semantic/resources/mdtest/dataclasses/fields.md

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,13 +63,12 @@ class Person:
6363
age: int | None = field(default=None, kw_only=True)
6464
role: str = field(default="user", kw_only=True)
6565

66-
# TODO: the `age` and `role` fields should be keyword-only
67-
# revealed: (self: Person, name: str, age: int | None = None, role: str = Literal["user"]) -> None
66+
# revealed: (self: Person, name: str, *, age: int | None = None, role: str = Literal["user"]) -> None
6867
reveal_type(Person.__init__)
6968

7069
alice = Person(role="admin", name="Alice")
7170

72-
# TODO: this should be an error
71+
# error: [too-many-positional-arguments] "Too many positional arguments: expected 1, got 2"
7372
bob = Person("Bob", 30)
7473
```
7574

crates/ty_python_semantic/src/types.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6787,6 +6787,9 @@ pub struct FieldInstance<'db> {
67876787

67886788
/// Whether this field is part of the `__init__` signature, or not.
67896789
pub init: bool,
6790+
6791+
/// Whether or not this field can only be passed as a keyword argument to `__init__`.
6792+
pub kw_only: Option<bool>,
67906793
}
67916794

67926795
// The Salsa heap is tracked separately.
@@ -6798,6 +6801,7 @@ impl<'db> FieldInstance<'db> {
67986801
db,
67996802
self.default_type(db).normalized_impl(db, visitor),
68006803
self.init(db),
6804+
self.kw_only(db),
68016805
)
68026806
}
68036807
}

crates/ty_python_semantic/src/types/call/bind.rs

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ use ruff_db::parsed::parsed_module;
1212
use smallvec::{SmallVec, smallvec, smallvec_inline};
1313

1414
use super::{Argument, CallArguments, CallError, CallErrorKind, InferContext, Signature, Type};
15+
use crate::Program;
1516
use crate::db::Db;
1617
use crate::dunder_all::dunder_all_names;
1718
use crate::place::{Boundness, Place};
@@ -33,7 +34,7 @@ use crate::types::{
3334
WrapperDescriptorKind, enums, ide_support, todo_type,
3435
};
3536
use ruff_db::diagnostic::{Annotation, Diagnostic, SubDiagnostic, SubDiagnosticSeverity};
36-
use ruff_python_ast as ast;
37+
use ruff_python_ast::{self as ast, PythonVersion};
3738

3839
/// Binding information for a possible union of callables. At a call site, the arguments must be
3940
/// compatible with _all_ of the types in the union for the call to be valid.
@@ -860,7 +861,11 @@ impl<'db> Bindings<'db> {
860861
params |= DataclassParams::MATCH_ARGS;
861862
}
862863
if to_bool(kw_only, false) {
863-
params |= DataclassParams::KW_ONLY;
864+
if Program::get(db).python_version(db) >= PythonVersion::PY310 {
865+
params |= DataclassParams::KW_ONLY;
866+
} else {
867+
// TODO: emit diagnostic
868+
}
864869
}
865870
if to_bool(slots, false) {
866871
params |= DataclassParams::SLOTS;
@@ -919,7 +924,9 @@ impl<'db> Bindings<'db> {
919924
}
920925

921926
Some(KnownFunction::Field) => {
922-
if let [default, default_factory, init, ..] = overload.parameter_types()
927+
// TODO this will break on Python 3.14 -- we should match by parameter name instead
928+
if let [default, default_factory, init, .., kw_only] =
929+
overload.parameter_types()
923930
{
924931
let default_ty = match (default, default_factory) {
925932
(Some(default_ty), _) => *default_ty,
@@ -933,6 +940,14 @@ impl<'db> Bindings<'db> {
933940
.map(|init| !init.bool(db).is_always_false())
934941
.unwrap_or(true);
935942

943+
let kw_only = if Program::get(db).python_version(db)
944+
>= PythonVersion::PY310
945+
{
946+
kw_only.map(|kw_only| !kw_only.bool(db).is_always_false())
947+
} else {
948+
None
949+
};
950+
936951
// `typeshed` pretends that `dataclasses.field()` returns the type of the
937952
// default value directly. At runtime, however, this function returns an
938953
// instance of `dataclasses.Field`. We also model it this way and return
@@ -942,7 +957,7 @@ impl<'db> Bindings<'db> {
942957
// to `T`. Otherwise, we would error on `name: str = field(default="")`.
943958
overload.set_return_type(Type::KnownInstance(
944959
KnownInstanceType::Field(FieldInstance::new(
945-
db, default_ty, init,
960+
db, default_ty, init, kw_only,
946961
)),
947962
));
948963
}

crates/ty_python_semantic/src/types/class.rs

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1171,6 +1171,9 @@ pub(crate) struct Field<'db> {
11711171

11721172
/// Whether or not this field should appear in the signature of `__init__`.
11731173
pub(crate) init: bool,
1174+
1175+
/// Whether or not this field can only be passed as a keyword argument to `__init__`.
1176+
pub(crate) kw_only: Option<bool>,
11741177
}
11751178

11761179
/// Representation of a class definition statement in the AST: either a non-generic class, or a
@@ -1922,6 +1925,7 @@ impl<'db> ClassLiteral<'db> {
19221925
mut default_ty,
19231926
init_only: _,
19241927
init,
1928+
kw_only,
19251929
},
19261930
) in self.fields(db, specialization, field_policy)
19271931
{
@@ -1986,7 +1990,12 @@ impl<'db> ClassLiteral<'db> {
19861990
}
19871991
}
19881992

1989-
let mut parameter = if kw_only_field_seen || name == "__replace__" {
1993+
let is_kw_only = name == "__replace__"
1994+
|| kw_only.unwrap_or(
1995+
has_dataclass_param(DataclassParams::KW_ONLY) || kw_only_field_seen,
1996+
);
1997+
1998+
let mut parameter = if is_kw_only {
19901999
Parameter::keyword_only(field_name)
19912000
} else {
19922001
Parameter::positional_or_keyword(field_name)
@@ -2005,6 +2014,10 @@ impl<'db> ClassLiteral<'db> {
20052014
parameters.push(parameter);
20062015
}
20072016

2017+
// In the event that we have a mix of keyword-only and positional parameters, we need to sort them
2018+
// so that the keyword-only parameters appear after positional parameters.
2019+
parameters.sort_by_key(Parameter::is_keyword_only);
2020+
20082021
let mut signature = Signature::new(Parameters::new(parameters), return_ty);
20092022
signature.inherited_generic_context = self.generic_context(db);
20102023
Some(CallableType::function_like(db, signature))
@@ -2316,9 +2329,11 @@ impl<'db> ClassLiteral<'db> {
23162329
default_ty.map(|ty| ty.apply_optional_specialization(db, specialization));
23172330

23182331
let mut init = true;
2332+
let mut kw_only = None;
23192333
if let Some(Type::KnownInstance(KnownInstanceType::Field(field))) = default_ty {
23202334
default_ty = Some(field.default_type(db));
23212335
init = field.init(db);
2336+
kw_only = field.kw_only(db);
23222337
}
23232338

23242339
attributes.insert(
@@ -2328,6 +2343,7 @@ impl<'db> ClassLiteral<'db> {
23282343
default_ty,
23292344
init_only: attr.is_init_var(),
23302345
init,
2346+
kw_only,
23312347
},
23322348
);
23332349
}

0 commit comments

Comments
 (0)