[ty] Respect `kw_only` from parent class (#21820)

## Summary

Closes https://github.com/astral-sh/ty/issues/1769.

---------

Co-authored-by: Carl Meyer <carl@astral.sh>
This commit is contained in:
Charlie Marsh 2025-12-10 04:12:18 -05:00 committed by GitHub
parent 8293afe2ae
commit d2aabeaaa2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 134 additions and 3 deletions

View File

@ -701,6 +701,111 @@ Employee("Alice", e_id=1)
Employee("Alice", 1) # error: [too-many-positional-arguments]
```
### Inherited fields with class-level `kw_only`
When a child dataclass uses `@dataclass(kw_only=True)`, the `kw_only` setting should only apply to
fields defined in the child class, not to inherited fields from parent classes.
This is a regression test for <https://github.com/astral-sh/ty/issues/1769>.
```toml
[environment]
python-version = "3.10"
```
```py
from dataclasses import dataclass
@dataclass
class Inner:
inner: int
@dataclass(kw_only=True)
class Outer(Inner):
outer: int
# Inherited field `inner` is positional, new field `outer` is keyword-only
reveal_type(Outer.__init__) # revealed: (self: Outer, inner: int, *, outer: int) -> None
Outer(0, outer=5) # OK
Outer(inner=0, outer=5) # Also OK
# error: [missing-argument]
# error: [too-many-positional-arguments]
Outer(0, 5)
```
This also works when the parent class uses the `KW_ONLY` sentinel:
```py
from dataclasses import dataclass, KW_ONLY
@dataclass
class Parent:
a: int
_: KW_ONLY
b: str
@dataclass(kw_only=True)
class Child(Parent):
c: bytes
# `a` is positional (from parent), `b` is keyword-only (from parent's KW_ONLY),
# `c` is keyword-only (from child's kw_only=True)
reveal_type(Child.__init__) # revealed: (self: Child, a: int, *, b: str, c: bytes) -> None
Child(1, b="hello", c=b"world") # OK
# error: [missing-argument] "No arguments provided for required parameters `b`, `c`"
# error: [too-many-positional-arguments]
Child(1, "hello", b"world")
```
And when the child class uses the `KW_ONLY` sentinel while inheriting from a parent:
```py
from dataclasses import dataclass, KW_ONLY
@dataclass
class Base:
x: int
@dataclass
class Derived(Base):
y: str
_: KW_ONLY
z: bytes
# `x` and `y` are positional, `z` is keyword-only (from Derived's KW_ONLY)
reveal_type(Derived.__init__) # revealed: (self: Derived, x: int, y: str, *, z: bytes) -> None
Derived(1, "hello", z=b"world") # OK
# error: [missing-argument]
# error: [too-many-positional-arguments]
Derived(1, "hello", b"world")
```
The reverse case also works: when a parent has `kw_only=True` but the child doesn't, the parent's
fields stay keyword-only while the child's fields are positional:
```py
from dataclasses import dataclass
@dataclass(kw_only=True)
class KwOnlyParent:
parent_field: int
@dataclass
class PositionalChild(KwOnlyParent):
child_field: str
# `child_field` is positional (child's default), `parent_field` stays keyword-only
reveal_type(PositionalChild.__init__) # revealed: (self: PositionalChild, child_field: str, *, parent_field: int) -> None
PositionalChild("hello", parent_field=1) # OK
# error: [missing-argument]
# error: [too-many-positional-arguments]
PositionalChild("hello", 1)
```
### `slots`
If a dataclass is defined with `slots=True`, the `__slots__` attribute is generated as a tuple. It

View File

@ -21,7 +21,9 @@ use crate::types::constraints::{ConstraintSet, IteratorConstraintsExtension};
use crate::types::context::InferContext;
use crate::types::diagnostic::{INVALID_TYPE_ALIAS_TYPE, SUPER_CALL_IN_NAMED_TUPLE_METHOD};
use crate::types::enums::enum_metadata;
use crate::types::function::{DataclassTransformerParams, KnownFunction};
use crate::types::function::{
DataclassTransformerFlags, DataclassTransformerParams, KnownFunction,
};
use crate::types::generics::{
GenericContext, InferableTypeVars, Specialization, walk_generic_context, walk_specialization,
};
@ -2347,6 +2349,8 @@ impl<'db> ClassLiteral<'db> {
};
// Dataclass transformer flags can be overwritten using class arguments.
// TODO this should be done more generally, not just in `own_synthesized_member`, so that
// `dataclass_params` always reflects the transformer params.
if let Some(transformer_params) = transformer_params.as_mut() {
if let Some(class_def) = self.definition(db).kind(db).as_class() {
let module = parsed_module(db, self.file(db)).load(db);
@ -2376,6 +2380,8 @@ impl<'db> ClassLiteral<'db> {
let has_dataclass_param = |param| {
dataclass_params.is_some_and(|params| params.flags(db).contains(param))
// TODO if we were correctly initializing `dataclass_params` from the
// transformer params, this fallback shouldn't be needed here.
|| transformer_params.is_some_and(|params| params.flags(db).contains(param))
};
@ -2455,8 +2461,7 @@ impl<'db> ClassLiteral<'db> {
}
}
let is_kw_only = name == "__replace__"
|| kw_only.unwrap_or(has_dataclass_param(DataclassFlags::KW_ONLY));
let is_kw_only = name == "__replace__" || kw_only.unwrap_or(false);
// Use the alias name if provided, otherwise use the field name
let parameter_name =
@ -3175,6 +3180,27 @@ impl<'db> ClassLiteral<'db> {
}
}
// Resolve the kw_only to the class-level default. This ensures that when fields
// are inherited by child classes, they use their defining class's kw_only default.
if let FieldKind::Dataclass {
kw_only: ref mut kw @ None,
..
} = field.kind
{
let class_kw_only_default = self
.dataclass_params(db)
.is_some_and(|params| params.flags(db).contains(DataclassFlags::KW_ONLY))
// TODO this next part should not be necessary, if we were properly
// initializing `dataclass_params` from the dataclass-transform params, for
// metaclass and base-class-based dataclass-transformers.
|| matches!(
field_policy,
CodeGeneratorKind::DataclassLike(Some(transformer_params))
if transformer_params.flags(db).contains(DataclassTransformerFlags::KW_ONLY_DEFAULT)
);
*kw = Some(class_kw_only_default);
}
attributes.insert(symbol.name().clone(), field);
}
}