Skip to content

Commit 1ca0a23

Browse files
committed
[ty] Preserve descriptor validation after frozen setter delegation
1 parent b464874 commit 1ca0a23

3 files changed

Lines changed: 143 additions & 37 deletions

File tree

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

Lines changed: 79 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -833,14 +833,12 @@ class ChildWithRejectingAssignmentBase(Frozen, RejectsAssignment): ...
833833
ChildWithRejectingAssignmentBase().y = 2
834834
```
835835

836-
A later base class can instead allow assignment to an otherwise read-only property. Its setter still
837-
determines which values are accepted:
836+
A later base class can customize assignment to an ordinary attribute. The value must satisfy both
837+
the later `__setattr__` and the attribute declaration:
838838

839839
```py
840840
class AllowsAssignment:
841-
@property
842-
def y(self) -> int:
843-
return 1
841+
y: object = 1
844842

845843
def __setattr__(self, name: str, value: int) -> None: ...
846844

@@ -853,6 +851,82 @@ allowed.y = 2
853851
allowed.y = "invalid"
854852
```
855853

854+
A later `__setattr__` can forward to `object.__setattr__`, which still invokes data descriptors:
855+
856+
```py
857+
class ForwardsAssignment:
858+
def __setattr__(self, name: str, value: object) -> None:
859+
super().__setattr__(name, value)
860+
```
861+
862+
A read-only property therefore remains read-only:
863+
864+
```py
865+
class ReadOnlyPropertyBase(ForwardsAssignment):
866+
@property
867+
def y(self) -> int:
868+
return 1
869+
870+
class ChildWithReadOnlyProperty(Frozen, ReadOnlyPropertyBase): ...
871+
872+
# error: [invalid-assignment] "Cannot assign to read-only property `y` on object of type `ChildWithReadOnlyProperty`"
873+
ChildWithReadOnlyProperty().y = 2
874+
```
875+
876+
The property's setter still determines which values it accepts:
877+
878+
```py
879+
class TypedPropertyBase(ForwardsAssignment):
880+
@property
881+
def y(self) -> int:
882+
return 1
883+
884+
@y.setter
885+
def y(self, value: int) -> None: ...
886+
887+
class ChildWithTypedProperty(Frozen, TypedPropertyBase): ...
888+
889+
# error: [invalid-assignment] "Expected `int`, found `Literal["invalid"]`"
890+
ChildWithTypedProperty().y = "invalid"
891+
```
892+
893+
A property setter that never returns prevents assignment:
894+
895+
```py
896+
class TerminalPropertyBase(ForwardsAssignment):
897+
@property
898+
def y(self) -> int:
899+
return 1
900+
901+
@y.setter
902+
def y(self, value: int) -> NoReturn:
903+
raise AttributeError
904+
905+
class ChildWithTerminalProperty(Frozen, TerminalPropertyBase): ...
906+
907+
# error: [invalid-assignment] "Cannot assign to attribute `y` on type `ChildWithTerminalProperty` whose `__set__` method returns `Never`/`NoReturn`"
908+
ChildWithTerminalProperty().y = 2
909+
```
910+
911+
The same rule applies to a custom descriptor whose setter never returns:
912+
913+
```py
914+
class TerminalDescriptor:
915+
def __get__(self, instance: object, owner: type | None = None) -> int:
916+
return 1
917+
918+
def __set__(self, instance: object, value: int) -> NoReturn:
919+
raise AttributeError
920+
921+
class TerminalDescriptorBase(ForwardsAssignment):
922+
y: TerminalDescriptor = TerminalDescriptor()
923+
924+
class ChildWithTerminalDescriptor(Frozen, TerminalDescriptorBase): ...
925+
926+
# error: [invalid-assignment] "Cannot assign to attribute `y` on type `ChildWithTerminalDescriptor` whose `__set__` method returns `Never`/`NoReturn`"
927+
ChildWithTerminalDescriptor().y = 2
928+
```
929+
856930
A later `__setattr__` does not make the declared type of an ordinary attribute disappear:
857931

858932
```py

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

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -120,17 +120,26 @@ pub struct StaticClassLiteral<'db> {
120120
// The Salsa heap is tracked separately.
121121
impl get_size2::GetSize for StaticClassLiteral<'_> {}
122122

123-
/// The outcome of dispatching a frozen-dataclass method on a subclass instance.
123+
/// The result of [`StaticClassLiteral::inherited_frozen_dataclass_dispatch`].
124+
///
125+
/// See that method for details on how generated frozen-dataclass methods handle fields and
126+
/// non-fields on subclass instances.
124127
#[derive(Clone, Copy)]
125128
pub(crate) enum FrozenDataclassDispatch<'db> {
126129
/// A reachable frozen dataclass rejects modification of one of its fields.
127130
FrozenField,
128-
/// Every reachable frozen method delegates past this base.
131+
/// Every reachable frozen method delegates, with lookup resuming after this base.
129132
Delegate(StaticClassLiteral<'db>),
130133
}
131134

132135
impl<'db> FrozenDataclassDispatch<'db> {
133-
/// Returns `object` for a frozen field or `super(frozen_base, object)` for a non-field.
136+
/// Returns the receiver for the next step of assignment or deletion validation.
137+
///
138+
/// Validation stays on `object_ty` for a frozen field because the generated method rejects the
139+
/// mutation. For a non-field, the generated method calls `super(frozen_base, object_ty)`, so
140+
/// lookup must resume after the last frozen base. For example, assigning `Child().y` for
141+
/// `class Child(Frozen, Later)` uses `super(Frozen, child)` when `y` is not a field of `Frozen`;
142+
/// this preserves a later `__setattr__` or a descriptor for `y`.
134143
pub(crate) fn receiver(self, db: &'db dyn Db, object_ty: Type<'db>) -> Type<'db> {
135144
match self {
136145
Self::FrozenField => object_ty,
@@ -147,6 +156,9 @@ impl<'db> FrozenDataclassDispatch<'db> {
147156
/// Fields protected by reachable frozen-dataclass methods.
148157
struct InheritedFrozenDataclassFields<'db> {
149158
names: Box<[Name]>,
159+
/// The final frozen dataclass whose generated method participates in dispatch.
160+
///
161+
/// For a non-field, mutation validation resumes after this class in the MRO.
150162
last_frozen_base: StaticClassLiteral<'db>,
151163
}
152164

@@ -1915,11 +1927,33 @@ impl<'db> StaticClassLiteral<'db> {
19151927
)))
19161928
}
19171929

1918-
/// Returns the outcome of an inherited frozen-dataclass method for `name`.
1930+
/// Determines how an inherited generated frozen-dataclass `method` handles `name`.
1931+
///
1932+
/// CPython's generated `__setattr__` and `__delattr__` reject every mutation when called on an
1933+
/// instance of the exact frozen class. On an ordinary subclass instance, they reject only
1934+
/// dataclass fields and delegate other names with `super(frozen_class, instance)`.
1935+
///
1936+
/// For example:
1937+
///
1938+
/// ```python
1939+
/// @dataclass(frozen=True)
1940+
/// class Frozen:
1941+
/// x: int
1942+
///
1943+
/// class Later:
1944+
/// y: int
1945+
///
1946+
/// class Child(Frozen, Later): ...
1947+
/// ```
1948+
///
1949+
/// Assigning to `Child().x` is rejected because `x` is a field of `Frozen`. Assigning to
1950+
/// `Child().y` instead delegates to `super(Frozen, child).__setattr__`, where a later
1951+
/// `__setattr__` or the descriptor for `y` can still reject the assignment.
19191952
///
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.
1953+
/// If multiple frozen dataclasses are reachable before an explicit implementation of
1954+
/// `method`, a non-field delegates past each generated method. [`FrozenDataclassDispatch::Delegate`]
1955+
/// stores the last frozen base so the caller can perform the equivalent lookup once, after all
1956+
/// of them.
19231957
pub(crate) fn inherited_frozen_dataclass_dispatch(
19241958
self,
19251959
db: &'db dyn Db,

crates/ty_python_semantic/src/types/infer/builder/attribute_assignment.rs

Lines changed: 23 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -433,27 +433,15 @@ impl<'db> AssignmentAttributeWriteEvaluator<'_, 'db, '_, '_> {
433433
if matches!(
434434
frozen_dataclass_dispatch,
435435
Some(FrozenDataclassDispatch::Delegate(_))
436-
) {
437-
match setattr_result {
438-
Ok(_) | Err(CallDunderError::PossiblyUnbound { .. })
439-
if matches!(
440-
member,
441-
ExplicitAttributeWriteRequirement::Descriptor { .. }
442-
) =>
443-
{
444-
return true;
445-
}
446-
Err(CallDunderError::CallError(kind, bindings, _)) => {
447-
if emit_diagnostics {
448-
self.report(AssignmentAttributeWriteDiagnostic::BadSetAttr {
449-
value_ty,
450-
failure: CallError(kind, bindings),
451-
});
452-
}
453-
return false;
454-
}
455-
_ => {}
436+
) && let Err(CallDunderError::CallError(kind, bindings, _)) = setattr_result
437+
{
438+
if emit_diagnostics {
439+
self.report(AssignmentAttributeWriteDiagnostic::BadSetAttr {
440+
value_ty,
441+
failure: CallError(kind, bindings),
442+
});
456443
}
444+
return false;
457445
}
458446
let member_valid =
459447
self.evaluate_explicit_member(object_ty, member, value_ty, emit_diagnostics);
@@ -679,17 +667,27 @@ impl<'db> AssignmentAttributeWriteEvaluator<'_, 'db, '_, '_> {
679667
emit_diagnostics: bool,
680668
) -> bool {
681669
let db = self.builder.db();
682-
if property_setter_returns_never(db, descriptor_ty, object_ty, value_ty) {
670+
let setter_result = setter_ty.try_call(
671+
db,
672+
&CallArguments::positional([descriptor_ty, object_ty, value_ty]),
673+
);
674+
// `Never` supports arbitrary operations only because there can be no runtime value to
675+
// mutate; it is not a concrete descriptor with a terminal setter.
676+
let setter_returns_never = !descriptor_ty.is_never()
677+
&& match &setter_result {
678+
Ok(bindings) => bindings.return_type(db).is_never(),
679+
Err(error) => error.return_type(db).is_never(),
680+
};
681+
if setter_returns_never
682+
|| property_setter_returns_never(db, descriptor_ty, object_ty, value_ty)
683+
{
683684
if emit_diagnostics {
684685
self.report(AssignmentAttributeWriteDiagnostic::TerminalDescriptor);
685686
}
686687
return false;
687688
}
688689

689-
match setter_ty.try_call(
690-
db,
691-
&CallArguments::positional([descriptor_ty, object_ty, value_ty]),
692-
) {
690+
match setter_result {
693691
Ok(_) => true,
694692
Err(error) => {
695693
if emit_diagnostics {

0 commit comments

Comments
 (0)