Skip to content

Commit be2cfb5

Browse files
committed
[ty] support kw_only=True for dataclasses
astral-sh/ty#111
1 parent 6a2d358 commit be2cfb5

File tree

5 files changed

+87
-9
lines changed

5 files changed

+87
-9
lines changed

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

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -465,7 +465,58 @@ 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+
### `kw_only` - Python < 3.10
504+
505+
For Python < 3.10, `kw_only` is not supported.
506+
507+
```toml
508+
[environment]
509+
python-version = "3.9"
510+
```
511+
512+
```py
513+
from dataclasses import dataclass
514+
515+
@dataclass(kw_only=True) # TODO: Emit a diagnostic here
516+
class A:
517+
x: int
518+
y: int
519+
```
469520

470521
### `slots`
471522

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
@@ -6535,6 +6535,9 @@ pub struct FieldInstance<'db> {
65356535

65366536
/// Whether this field is part of the `__init__` signature, or not.
65376537
pub init: bool,
6538+
6539+
/// Whether or not this field can only be passed as a keyword argument to `__init__`.
6540+
pub kw_only: Option<bool>,
65386541
}
65396542

65406543
// The Salsa heap is tracked separately.
@@ -6550,6 +6553,7 @@ impl<'db> FieldInstance<'db> {
65506553
db,
65516554
self.default_type(db).normalized_impl(db, visitor),
65526555
self.init(db),
6556+
self.kw_only(db),
65536557
)
65546558
}
65556559
}

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

Lines changed: 18 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,8 @@ impl<'db> Bindings<'db> {
919924
}
920925

921926
Some(KnownFunction::Field) => {
922-
if let [default, default_factory, init, ..] = overload.parameter_types()
927+
if let [default, default_factory, init, .., kw_only] =
928+
overload.parameter_types()
923929
{
924930
let default_ty = match (default, default_factory) {
925931
(Some(default_ty), _) => *default_ty,
@@ -933,6 +939,14 @@ impl<'db> Bindings<'db> {
933939
.map(|init| !init.bool(db).is_always_false())
934940
.unwrap_or(true);
935941

942+
let kw_only = if Program::get(db).python_version(db)
943+
>= PythonVersion::PY310
944+
{
945+
kw_only.map(|kw_only| !kw_only.bool(db).is_always_false())
946+
} else {
947+
None
948+
};
949+
936950
// `typeshed` pretends that `dataclasses.field()` returns the type of the
937951
// default value directly. At runtime, however, this function returns an
938952
// instance of `dataclasses.Field`. We also model it this way and return
@@ -942,7 +956,7 @@ impl<'db> Bindings<'db> {
942956
// to `T`. Otherwise, we would error on `name: str = field(default="")`.
943957
overload.set_return_type(Type::KnownInstance(
944958
KnownInstanceType::Field(FieldInstance::new(
945-
db, default_ty, init,
959+
db, default_ty, init, kw_only,
946960
)),
947961
));
948962
}

crates/ty_python_semantic/src/types/class.rs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1068,6 +1068,9 @@ pub(crate) struct DataclassField<'db> {
10681068

10691069
/// Whether or not this field should appear in the signature of `__init__`.
10701070
pub(crate) init: bool,
1071+
1072+
/// Whether or not this field can only be passed as a keyword argument to `__init__`.
1073+
pub(crate) kw_only: Option<bool>,
10711074
}
10721075

10731076
/// Representation of a class definition statement in the AST: either a non-generic class, or a
@@ -1779,6 +1782,7 @@ impl<'db> ClassLiteral<'db> {
17791782
mut default_ty,
17801783
init_only: _,
17811784
init,
1785+
kw_only,
17821786
},
17831787
) in self.fields(db, specialization, field_policy)
17841788
{
@@ -1843,7 +1847,10 @@ impl<'db> ClassLiteral<'db> {
18431847
}
18441848
}
18451849

1846-
let mut parameter = if kw_only_field_seen || name == "__replace__" {
1850+
let mut parameter = if kw_only_field_seen
1851+
|| name == "__replace__"
1852+
|| kw_only.unwrap_or(has_dataclass_param(DataclassParams::KW_ONLY))
1853+
{
18471854
Parameter::keyword_only(field_name)
18481855
} else {
18491856
Parameter::positional_or_keyword(field_name)
@@ -2043,9 +2050,11 @@ impl<'db> ClassLiteral<'db> {
20432050
default_ty.map(|ty| ty.apply_optional_specialization(db, specialization));
20442051

20452052
let mut init = true;
2053+
let mut kw_only = None;
20462054
if let Some(Type::KnownInstance(KnownInstanceType::Field(field))) = default_ty {
20472055
default_ty = Some(field.default_type(db));
20482056
init = field.init(db);
2057+
kw_only = field.kw_only(db);
20492058
}
20502059

20512060
attributes.insert(
@@ -2055,6 +2064,7 @@ impl<'db> ClassLiteral<'db> {
20552064
default_ty,
20562065
init_only: attr.is_init_var(),
20572066
init,
2067+
kw_only,
20582068
},
20592069
);
20602070
}

0 commit comments

Comments
 (0)