Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,41 @@ OrderTrueOverwritten(1) < OrderTrueOverwritten(2)

### `kw_only_default`

To do
When provided, sets the default value for the `kw_only` parameter of `field()`.

```py
from typing import dataclass_transform
from dataclasses import field

@dataclass_transform(kw_only_default=True)
def create_model(*, init=True): ...
@create_model()
class A:
name: str

a = A(name="Harry")
# error: [missing-argument]
# error: [too-many-positional-arguments]
a = A("Harry")
```

TODO: This can be overridden by the call to the decorator function.

```py
from typing import dataclass_transform

@dataclass_transform(kw_only_default=True)
def create_model(*, kw_only: bool = True): ...
@create_model(kw_only=False)
class CustomerModel:
id: int
name: str

# TODO: Should not emit errors
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i believe this should cover the dataclasses_transform_func false positive

# error: [missing-argument]
# error: [too-many-positional-arguments]
c = CustomerModel(1, "Harry")
```

### `field_specifiers`

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -465,7 +465,84 @@ To do

### `kw_only`

To do
An error is emitted if a dataclass is defined with `kw_only=True` and positional arguments are
passed to the constructor.

```toml
[environment]
python-version = "3.10"
```

```py
from dataclasses import dataclass

@dataclass(kw_only=True)
class A:
x: int
y: int

# error: [missing-argument] "No arguments provided for required parameters `x`, `y`"
# error: [too-many-positional-arguments] "Too many positional arguments: expected 0, got 2"
a = A(1, 2)
a = A(x=1, y=2)
```

The class-level parameter can be overridden per-field.

```py
from dataclasses import dataclass, field

@dataclass(kw_only=True)
class A:
a: str = field(kw_only=False)
b: int = 0

A("hi")
```

If some fields are `kw_only`, they should appear after all positional fields in the `__init__`
signature.

```py
@dataclass
class A:
b: int = field(kw_only=True, default=3)
a: str

A("hi")
```

The field-level `kw_only` value takes precedence over the `KW_ONLY` pseudo-type.

```py
from dataclasses import field, dataclass, KW_ONLY

@dataclass
class C:
_: KW_ONLY
x: int = field(kw_only=False)

C(x=1)
C(1)
```

### `kw_only` - Python < 3.10

For Python < 3.10, `kw_only` is not supported.

```toml
[environment]
python-version = "3.9"
```

```py
from dataclasses import dataclass

@dataclass(kw_only=True) # TODO: Emit a diagnostic here
class A:
x: int
y: int
```

### `slots`

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,13 +63,12 @@ class Person:
age: int | None = field(default=None, kw_only=True)
role: str = field(default="user", kw_only=True)

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

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

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

Expand Down
4 changes: 4 additions & 0 deletions crates/ty_python_semantic/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6789,6 +6789,9 @@ pub struct FieldInstance<'db> {

/// Whether this field is part of the `__init__` signature, or not.
pub init: bool,

/// Whether or not this field can only be passed as a keyword argument to `__init__`.
pub kw_only: Option<bool>,
}

// The Salsa heap is tracked separately.
Expand All @@ -6800,6 +6803,7 @@ impl<'db> FieldInstance<'db> {
db,
self.default_type(db).normalized_impl(db, visitor),
self.init(db),
self.kw_only(db),
)
}
}
Expand Down
23 changes: 19 additions & 4 deletions crates/ty_python_semantic/src/types/call/bind.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ use ruff_db::parsed::parsed_module;
use smallvec::{SmallVec, smallvec, smallvec_inline};

use super::{Argument, CallArguments, CallError, CallErrorKind, InferContext, Signature, Type};
use crate::Program;
use crate::db::Db;
use crate::dunder_all::dunder_all_names;
use crate::place::{Boundness, Place};
Expand All @@ -33,7 +34,7 @@ use crate::types::{
WrapperDescriptorKind, enums, ide_support, todo_type,
};
use ruff_db::diagnostic::{Annotation, Diagnostic, SubDiagnostic, SubDiagnosticSeverity};
use ruff_python_ast as ast;
use ruff_python_ast::{self as ast, PythonVersion};

/// Binding information for a possible union of callables. At a call site, the arguments must be
/// compatible with _all_ of the types in the union for the call to be valid.
Expand Down Expand Up @@ -860,7 +861,11 @@ impl<'db> Bindings<'db> {
params |= DataclassParams::MATCH_ARGS;
}
if to_bool(kw_only, false) {
params |= DataclassParams::KW_ONLY;
if Program::get(db).python_version(db) >= PythonVersion::PY310 {
params |= DataclassParams::KW_ONLY;
} else {
// TODO: emit diagnostic
}
}
if to_bool(slots, false) {
params |= DataclassParams::SLOTS;
Expand Down Expand Up @@ -919,7 +924,9 @@ impl<'db> Bindings<'db> {
}

Some(KnownFunction::Field) => {
if let [default, default_factory, init, ..] = overload.parameter_types()
// TODO this will break on Python 3.14 -- we should match by parameter name instead
if let [default, default_factory, init, .., kw_only] =
overload.parameter_types()
Comment on lines +928 to +929
Copy link
Contributor

@carljm carljm Aug 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not awesome how we're hardcoding a correspondence with the ordering of keyword-only parameters in typeshed here, rather than actually matching by parameter name. But that's orthogonal to this PR.

But this makes it even less robust. Python 3.14 added a new doc parameter which will break this as it's currently written, since it means kw_only is not necessarily last.

We can't just list all the parameters from the front, because then this if let will fail to match at all on older Python versions that don't have the kw_only parameter.

I think we should modify this code to do something a bit more intelligent here. Ideally that more intelligent thing would be to actually match by parameter name. That should be very doable, but it is a bit more involved; it will require adding new API to Binding, I think. So perhaps it would make more sense as a separate PR.

So for now perhaps we just add a TODO comment here:

Suggested change
if let [default, default_factory, init, .., kw_only] =
overload.parameter_types()
// TODO this will break on Python 3.14 -- we should match by parameter name instead
if let [default, default_factory, init, .., kw_only] =
overload.parameter_types()

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO added

yeah - i remember thinking this felt a little brittle while i was working on it. if you want to expand a bit on the vision for the Binding API or write up an issue or something i'd be interested in taking a look!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Basically a Binding includes a vector of types for each parameter (matching up with parameters in source order), and it includes a Signature which tells us all about those parameters (including their names), but it doesn't currently have any way to say "give me the type for the parameter with this name." Unless I'm missing something (which I might be), there's no particular difficulty here, it just requires a bit of wiring things together.

{
let default_ty = match (default, default_factory) {
(Some(default_ty), _) => *default_ty,
Expand All @@ -933,6 +940,14 @@ impl<'db> Bindings<'db> {
.map(|init| !init.bool(db).is_always_false())
.unwrap_or(true);

let kw_only = if Program::get(db).python_version(db)
>= PythonVersion::PY310
{
kw_only.map(|kw_only| !kw_only.bool(db).is_always_false())
} else {
None
};

// `typeshed` pretends that `dataclasses.field()` returns the type of the
// default value directly. At runtime, however, this function returns an
// instance of `dataclasses.Field`. We also model it this way and return
Expand All @@ -942,7 +957,7 @@ impl<'db> Bindings<'db> {
// to `T`. Otherwise, we would error on `name: str = field(default="")`.
overload.set_return_type(Type::KnownInstance(
KnownInstanceType::Field(FieldInstance::new(
db, default_ty, init,
db, default_ty, init, kw_only,
)),
));
}
Expand Down
18 changes: 17 additions & 1 deletion crates/ty_python_semantic/src/types/class.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1144,6 +1144,9 @@ pub(crate) struct Field<'db> {

/// Whether or not this field should appear in the signature of `__init__`.
pub(crate) init: bool,

/// Whether or not this field can only be passed as a keyword argument to `__init__`.
pub(crate) kw_only: Option<bool>,
}

/// Representation of a class definition statement in the AST: either a non-generic class, or a
Expand Down Expand Up @@ -1895,6 +1898,7 @@ impl<'db> ClassLiteral<'db> {
mut default_ty,
init_only: _,
init,
kw_only,
},
) in self.fields(db, specialization, field_policy)
{
Expand Down Expand Up @@ -1959,7 +1963,12 @@ impl<'db> ClassLiteral<'db> {
}
}

let mut parameter = if kw_only_field_seen || name == "__replace__" {
let is_kw_only = name == "__replace__"
|| kw_only.unwrap_or(
has_dataclass_param(DataclassParams::KW_ONLY) || kw_only_field_seen,
);

let mut parameter = if is_kw_only {
Parameter::keyword_only(field_name)
} else {
Parameter::positional_or_keyword(field_name)
Expand All @@ -1978,6 +1987,10 @@ impl<'db> ClassLiteral<'db> {
parameters.push(parameter);
}

// In the event that we have a mix of keyword-only and positional parameters, we need to sort them
// so that the keyword-only parameters appear after positional parameters.
parameters.sort_by_key(Parameter::is_keyword_only);

let mut signature = Signature::new(Parameters::new(parameters), return_ty);
signature.inherited_generic_context = self.generic_context(db);
Some(CallableType::function_like(db, signature))
Expand Down Expand Up @@ -2289,9 +2302,11 @@ impl<'db> ClassLiteral<'db> {
default_ty.map(|ty| ty.apply_optional_specialization(db, specialization));

let mut init = true;
let mut kw_only = None;
if let Some(Type::KnownInstance(KnownInstanceType::Field(field))) = default_ty {
default_ty = Some(field.default_type(db));
init = field.init(db);
kw_only = field.kw_only(db);
}

attributes.insert(
Expand All @@ -2301,6 +2316,7 @@ impl<'db> ClassLiteral<'db> {
default_ty,
init_only: attr.is_init_var(),
init,
kw_only,
},
);
}
Expand Down
Loading