Skip to content

Commit b464874

Browse files
committed
[ty] Preserve frozen-dataclass setter delegation
1 parent 62768b9 commit b464874

4 files changed

Lines changed: 313 additions & 19 deletions

File tree

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

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -810,6 +810,141 @@ grandchild.z = 2
810810
grandchild.unknown = 2
811811
```
812812

813+
When another base class rejects assignment, a frozen dataclass must not hide its `__setattr__`
814+
method:
815+
816+
```py
817+
from dataclasses import dataclass
818+
from typing import NoReturn
819+
820+
@dataclass(frozen=True)
821+
class Frozen:
822+
x: int = 1
823+
824+
class RejectsAssignment:
825+
y: int = 1
826+
827+
def __setattr__(self, name: str, value: object) -> NoReturn:
828+
raise AttributeError(name)
829+
830+
class ChildWithRejectingAssignmentBase(Frozen, RejectsAssignment): ...
831+
832+
# error: [invalid-assignment] "Cannot assign to attribute `y` on type `ChildWithRejectingAssignmentBase` whose `__setattr__` method returns `Never`/`NoReturn`"
833+
ChildWithRejectingAssignmentBase().y = 2
834+
```
835+
836+
A later base class can instead allow assignment to an otherwise read-only property. Its setter still
837+
determines which values are accepted:
838+
839+
```py
840+
class AllowsAssignment:
841+
@property
842+
def y(self) -> int:
843+
return 1
844+
845+
def __setattr__(self, name: str, value: int) -> None: ...
846+
847+
class ChildWithAllowingAssignmentBase(Frozen, AllowsAssignment): ...
848+
849+
allowed = ChildWithAllowingAssignmentBase()
850+
allowed.y = 2
851+
852+
# error: [invalid-assignment] "Cannot assign object of type"
853+
allowed.y = "invalid"
854+
```
855+
856+
A later `__setattr__` does not make the declared type of an ordinary attribute disappear:
857+
858+
```py
859+
class AllowsUntypedAssignment:
860+
y: int = 1
861+
862+
def __setattr__(self, name: str, value: object) -> None: ...
863+
864+
class ChildWithUntypedAssignmentBase(Frozen, AllowsUntypedAssignment): ...
865+
866+
# error: [invalid-assignment]
867+
ChildWithUntypedAssignmentBase().y = "invalid"
868+
```
869+
870+
A specialized `Generic[T]` frozen base must also preserve the next `__setattr__` in the MRO:
871+
872+
```py
873+
from typing import Generic, TypeVar
874+
875+
T = TypeVar("T")
876+
877+
@dataclass(frozen=True)
878+
class GenericFrozen(Generic[T]):
879+
value: T
880+
881+
class RejectingGenericAssignmentChild(GenericFrozen[int], RejectsAssignment): ...
882+
883+
# error: [invalid-assignment] "Cannot assign to attribute `y` on type `RejectingGenericAssignmentChild` whose `__setattr__` method returns `Never`/`NoReturn`"
884+
RejectingGenericAssignmentChild(1).y = 2
885+
```
886+
887+
The same behavior applies to Python 3.12 type-parameter syntax:
888+
889+
```py
890+
@dataclass(frozen=True)
891+
class TypeParameterFrozen[T]:
892+
value: T
893+
894+
class RejectingTypeParameterAssignmentChild(TypeParameterFrozen[int], RejectsAssignment): ...
895+
896+
# error: [invalid-assignment] "Cannot assign to attribute `y` on type `RejectingTypeParameterAssignmentChild` whose `__setattr__` method returns `Never`/`NoReturn`"
897+
RejectingTypeParameterAssignmentChild(1).y = 2
898+
```
899+
900+
When a subclass inherits from two frozen dataclasses, assignments to fields from both bases remain
901+
frozen:
902+
903+
```py
904+
@dataclass(frozen=True)
905+
class FirstFrozen:
906+
first: int = 1
907+
908+
@dataclass(frozen=True)
909+
class SecondFrozen:
910+
second: int = 1
911+
912+
class ChildWithTwoFrozenBases(FirstFrozen, SecondFrozen): ...
913+
914+
multiple = ChildWithTwoFrozenBases()
915+
# revealed: Overload[(name: Literal["first"], value) -> Never, (name: Literal["second"], value) -> Never, (name: str, value) -> None]
916+
reveal_type(multiple.__setattr__)
917+
918+
multiple.second = 2 # error: [invalid-assignment]
919+
```
920+
921+
An `InitVar` is a constructor argument, not a frozen field. A subclass can assign to an attribute
922+
with the same name:
923+
924+
```py
925+
from dataclasses import InitVar
926+
927+
@dataclass(frozen=True)
928+
class FrozenWithInitVar:
929+
temporary: InitVar[int] = 0
930+
931+
class ChildWithInitVar(FrozenWithInitVar):
932+
temporary: int = 1
933+
934+
init_var_child = ChildWithInitVar()
935+
init_var_child.temporary = 4
936+
```
937+
938+
The same rule applies when the `InitVar` belongs to a second frozen base:
939+
940+
```py
941+
class ChildWithSecondBaseInitVar(Frozen, FrozenWithInitVar):
942+
temporary: int = 1
943+
944+
second_init_var_child = ChildWithSecondBaseInitVar()
945+
second_init_var_child.temporary = 4
946+
```
947+
813948
Non-field attributes on subclasses of slotted frozen dataclasses are still rejected. This correctly
814949
models the runtime behavior, but is somewhat surprising and may be a CPython bug, as subclasses of
815950
slotted classes usually allow arbitrary attributes to be set on them unless the subclass also

crates/ty_python_semantic/src/types/class.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ pub(super) use self::named_tuple::{
1010
DynamicNamedTupleAnchor, DynamicNamedTupleLiteral, NamedTupleField, NamedTupleSpec,
1111
};
1212
pub(crate) use self::static_literal::{
13-
ExpandedClassBaseEntry, StaticClassLiteral, expanded_class_base_entries,
13+
ExpandedClassBaseEntry, FrozenDataclassDispatch, StaticClassLiteral,
14+
expanded_class_base_entries,
1415
};
1516
pub(super) use self::typed_dict::{DynamicTypedDictAnchor, DynamicTypedDictLiteral};
1617
use super::dedicated::pydantic;

crates/ty_python_semantic/src/types/class/static_literal.rs

Lines changed: 107 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ use crate::{
2424
Parameter, Parameters, PropertyInstanceType, Signature, SpecialFormType, StaticMroError,
2525
SubclassOfType, Truthiness, Type, TypeContext, TypeMapping, TypeVarVariance,
2626
TypedDictModule, UnionBuilder, UnionType,
27+
bound_super::BoundSuperType,
2728
call::{CallError, CallErrorKind},
2829
callable::{CallableFunctionProvenance, CallableTypeKind},
2930
class::{
@@ -119,6 +120,36 @@ pub struct StaticClassLiteral<'db> {
119120
// The Salsa heap is tracked separately.
120121
impl get_size2::GetSize for StaticClassLiteral<'_> {}
121122

123+
/// The outcome of dispatching a frozen-dataclass method on a subclass instance.
124+
#[derive(Clone, Copy)]
125+
pub(crate) enum FrozenDataclassDispatch<'db> {
126+
/// A reachable frozen dataclass rejects modification of one of its fields.
127+
FrozenField,
128+
/// Every reachable frozen method delegates past this base.
129+
Delegate(StaticClassLiteral<'db>),
130+
}
131+
132+
impl<'db> FrozenDataclassDispatch<'db> {
133+
/// Returns `object` for a frozen field or `super(frozen_base, object)` for a non-field.
134+
pub(crate) fn receiver(self, db: &'db dyn Db, object_ty: Type<'db>) -> Type<'db> {
135+
match self {
136+
Self::FrozenField => object_ty,
137+
Self::Delegate(frozen_base) => BoundSuperType::build(
138+
db,
139+
Type::ClassLiteral(ClassLiteral::Static(frozen_base)),
140+
object_ty,
141+
)
142+
.unwrap_or(object_ty),
143+
}
144+
}
145+
}
146+
147+
/// Fields protected by reachable frozen-dataclass methods.
148+
struct InheritedFrozenDataclassFields<'db> {
149+
names: Box<[Name]>,
150+
last_frozen_base: StaticClassLiteral<'db>,
151+
}
152+
122153
#[salsa::tracked]
123154
impl<'db> StaticClassLiteral<'db> {
124155
/// Return `true` if this class represents `known_class`
@@ -1850,7 +1881,7 @@ impl<'db> StaticClassLiteral<'db> {
18501881
}
18511882

18521883
let frozen_base_fields =
1853-
self.inherited_non_slotted_frozen_dataclass_fields(db, specialization)?;
1884+
self.inherited_non_slotted_frozen_dataclass_fields(db, specialization, "__setattr__")?;
18541885

18551886
let instance_ty =
18561887
Type::instance(db, self.apply_optional_specialization(db, specialization));
@@ -1868,7 +1899,8 @@ impl<'db> StaticClassLiteral<'db> {
18681899
};
18691900

18701901
let overloads = frozen_base_fields
1871-
.keys()
1902+
.names
1903+
.iter()
18721904
.map(|field| setattr_signature(Type::string_literal(db, field), Type::Never))
18731905
.chain([setattr_signature(
18741906
KnownClass::Str.to_instance(db),
@@ -1883,15 +1915,60 @@ impl<'db> StaticClassLiteral<'db> {
18831915
)))
18841916
}
18851917

1886-
/// Return the inherited frozen dataclass fields whose generated `__setattr__` still controls
1887-
/// assignments on this class.
1918+
/// Returns the outcome of an inherited frozen-dataclass method for `name`.
1919+
///
1920+
/// For a non-field, CPython delegates past each generated frozen method. Preserving the final
1921+
/// frozen base lets assignment validation perform the same `super()` lookup without hiding a
1922+
/// later method or an attribute's descriptor.
1923+
pub(crate) fn inherited_frozen_dataclass_dispatch(
1924+
self,
1925+
db: &'db dyn Db,
1926+
specialization: Option<Specialization<'db>>,
1927+
method: &str,
1928+
name: &str,
1929+
) -> Option<FrozenDataclassDispatch<'db>> {
1930+
if CodeGeneratorKind::from_static_class(db, self).is_some()
1931+
|| class_member(db, self.body_scope(db), method)
1932+
.ignore_possibly_undefined()
1933+
.is_some()
1934+
{
1935+
return None;
1936+
}
1937+
1938+
let frozen_base_fields =
1939+
self.inherited_non_slotted_frozen_dataclass_fields(db, specialization, method)?;
1940+
1941+
if frozen_base_fields
1942+
.names
1943+
.iter()
1944+
.any(|field| field.as_str() == name)
1945+
{
1946+
Some(FrozenDataclassDispatch::FrozenField)
1947+
} else {
1948+
Some(FrozenDataclassDispatch::Delegate(
1949+
frozen_base_fields.last_frozen_base,
1950+
))
1951+
}
1952+
}
1953+
1954+
/// Returns the inherited fields protected by a generated frozen-dataclass method.
18881955
fn inherited_non_slotted_frozen_dataclass_fields(
18891956
self,
18901957
db: &'db dyn Db,
18911958
specialization: Option<Specialization<'db>>,
1892-
) -> Option<&'db FxIndexMap<Name, Field<'db>>> {
1959+
method: &str,
1960+
) -> Option<InheritedFrozenDataclassFields<'db>> {
1961+
let mut names = FxIndexSet::default();
1962+
let mut last_frozen_base = None;
1963+
18931964
for base in self.iter_mro(db, specialization).skip(1) {
1894-
let (base_class, base_specialization) = base.into_class()?.static_class_literal(db)?;
1965+
let Some(base_class_type) = base.into_class() else {
1966+
break;
1967+
};
1968+
let Some((base_class, base_specialization)) = base_class_type.static_class_literal(db)
1969+
else {
1970+
break;
1971+
};
18951972

18961973
// Stop if another class in the MRO replaces the generated frozen setter:
18971974
//
@@ -1905,29 +1982,47 @@ impl<'db> StaticClassLiteral<'db> {
19051982
//
19061983
// Writes to `Child().x` dispatch to `Mutable.__setattr__`, not to the synthesized
19071984
// `Frozen.__setattr__`.
1908-
if class_member(db, base_class.body_scope(db), "__setattr__")
1985+
if class_member(db, base_class.body_scope(db), method)
19091986
.ignore_possibly_undefined()
19101987
.is_some()
19111988
{
1912-
return None;
1989+
break;
19131990
}
19141991

19151992
if base_class.is_frozen_dataclass(db) == Some(true) {
19161993
let field_policy @ CodeGeneratorKind::DataclassLike(_) =
19171994
CodeGeneratorKind::from_static_class(db, base_class)?
19181995
else {
1919-
return None;
1996+
break;
19201997
};
19211998

19221999
if base_class.has_dataclass_param(db, field_policy, DataclassFlags::SLOTS) {
1923-
return None;
2000+
break;
19242001
}
19252002

1926-
return Some(base_class.fields(db, base_specialization, field_policy));
2003+
names.extend(
2004+
base_class
2005+
.fields(db, base_specialization, field_policy)
2006+
.iter()
2007+
.filter(|(_, field)| {
2008+
!matches!(
2009+
field.kind,
2010+
FieldKind::Dataclass {
2011+
init_only: true,
2012+
..
2013+
}
2014+
)
2015+
})
2016+
.map(|(name, _)| name.clone()),
2017+
);
2018+
last_frozen_base = Some(base_class);
19272019
}
19282020
}
19292021

1930-
None
2022+
Some(InheritedFrozenDataclassFields {
2023+
names: names.into_iter().collect(),
2024+
last_frozen_base: last_frozen_base?,
2025+
})
19312026
}
19322027

19332028
/// Member lookup for classes that inherit from `typing.TypedDict`.

0 commit comments

Comments
 (0)