[ty] synthesize __setattr__ for frozen dataclasses (#19307)
Some checks are pending
CI / Determine changes (push) Waiting to run
CI / cargo fmt (push) Waiting to run
CI / cargo clippy (push) Blocked by required conditions
CI / cargo test (linux) (push) Blocked by required conditions
CI / cargo test (linux, release) (push) Blocked by required conditions
CI / cargo test (windows) (push) Blocked by required conditions
CI / cargo test (wasm) (push) Blocked by required conditions
CI / cargo build (release) (push) Waiting to run
CI / cargo build (msrv) (push) Blocked by required conditions
CI / cargo fuzz build (push) Blocked by required conditions
CI / fuzz parser (push) Blocked by required conditions
CI / test scripts (push) Blocked by required conditions
CI / ecosystem (push) Blocked by required conditions
CI / Fuzz for new ty panics (push) Blocked by required conditions
CI / cargo shear (push) Blocked by required conditions
CI / python package (push) Waiting to run
CI / pre-commit (push) Waiting to run
CI / mkdocs (push) Waiting to run
CI / formatter instabilities and black similarity (push) Blocked by required conditions
CI / test ruff-lsp (push) Blocked by required conditions
CI / check playground (push) Blocked by required conditions
CI / benchmarks-instrumented (push) Blocked by required conditions
CI / benchmarks-walltime (push) Blocked by required conditions
[ty Playground] Release / publish (push) Waiting to run

## Summary

Synthesize a `__setattr__` method with a return type of `Never` for
frozen dataclasses.

https://docs.python.org/3/library/dataclasses.html#frozen-instances

https://docs.python.org/3/library/dataclasses.html#dataclasses.FrozenInstanceError

### Related
https://github.com/astral-sh/ty/issues/111
https://github.com/astral-sh/ruff/pull/17974#discussion_r2108527106
https://github.com/astral-sh/ruff/pull/18347#discussion_r2128174665

## Test Plan

New Markdown tests

---------

Co-authored-by: David Peter <mail@david-peter.de>
This commit is contained in:
justin 2025-07-18 05:35:05 -04:00 committed by GitHub
parent c7640a433e
commit 39b41838f3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 143 additions and 106 deletions

View file

@ -1806,7 +1806,7 @@ class Frozen:
raise AttributeError("Attributes can not be modified") raise AttributeError("Attributes can not be modified")
instance = Frozen() instance = Frozen()
instance.non_existing = 2 # error: [invalid-assignment] "Cannot assign to attribute `non_existing` on type `Frozen` whose `__setattr__` method returns `Never`/`NoReturn`" instance.non_existing = 2 # error: [invalid-assignment] "Can not assign to unresolved attribute `non_existing` on type `Frozen`"
instance.existing = 2 # error: [invalid-assignment] "Cannot assign to attribute `existing` on type `Frozen` whose `__setattr__` method returns `Never`/`NoReturn`" instance.existing = 2 # error: [invalid-assignment] "Cannot assign to attribute `existing` on type `Frozen` whose `__setattr__` method returns `Never`/`NoReturn`"
``` ```

View file

@ -415,8 +415,7 @@ frozen_instance = MyFrozenGeneric[int](1)
frozen_instance.x = 2 # error: [invalid-assignment] frozen_instance.x = 2 # error: [invalid-assignment]
``` ```
When attempting to mutate an unresolved attribute on a frozen dataclass, only `unresolved-attribute` Attempting to mutate an unresolved attribute on a frozen dataclass:
is emitted:
```py ```py
from dataclasses import dataclass from dataclasses import dataclass
@ -425,7 +424,39 @@ from dataclasses import dataclass
class MyFrozenClass: ... class MyFrozenClass: ...
frozen = MyFrozenClass() frozen = MyFrozenClass()
frozen.x = 2 # error: [unresolved-attribute] frozen.x = 2 # error: [invalid-assignment] "Can not assign to unresolved attribute `x` on type `MyFrozenClass`"
```
A diagnostic is also emitted if a frozen dataclass is inherited, and an attempt is made to mutate an
attribute in the child class:
```py
from dataclasses import dataclass
@dataclass(frozen=True)
class MyFrozenClass:
x: int = 1
class MyFrozenChildClass(MyFrozenClass): ...
frozen = MyFrozenChildClass()
frozen.x = 2 # error: [invalid-assignment]
```
The same diagnostic is emitted if a frozen dataclass is inherited, and an attempt is made to delete
an attribute:
```py
from dataclasses import dataclass
@dataclass(frozen=True)
class MyFrozenClass:
x: int = 1
class MyFrozenChildClass(MyFrozenClass): ...
frozen = MyFrozenChildClass()
del frozen.x # TODO this should emit an [invalid-assignment]
``` ```
### `match_args` ### `match_args`

View file

@ -1600,6 +1600,25 @@ impl<'db> ClassLiteral<'db> {
.place .place
.ignore_possibly_unbound() .ignore_possibly_unbound()
} }
(CodeGeneratorKind::DataclassLike, "__setattr__") => {
if has_dataclass_param(DataclassParams::FROZEN) {
let signature = Signature::new(
Parameters::new([
Parameter::positional_or_keyword(Name::new_static("self"))
.with_annotated_type(Type::instance(
db,
self.apply_optional_specialization(db, specialization),
)),
Parameter::positional_or_keyword(Name::new_static("name")),
Parameter::positional_or_keyword(Name::new_static("value")),
]),
Some(Type::Never),
);
return Some(CallableType::function_like(db, signature));
}
None
}
_ => None, _ => None,
} }
} }

View file

@ -3446,20 +3446,6 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
| Type::AlwaysTruthy | Type::AlwaysTruthy
| Type::AlwaysFalsy | Type::AlwaysFalsy
| Type::TypeIs(_) => { | Type::TypeIs(_) => {
let is_read_only = || {
let dataclass_params = match object_ty {
Type::NominalInstance(instance) => match instance.class {
ClassType::NonGeneric(cls) => cls.dataclass_params(self.db()),
ClassType::Generic(cls) => {
cls.origin(self.db()).dataclass_params(self.db())
}
},
_ => None,
};
dataclass_params.is_some_and(|params| params.contains(DataclassParams::FROZEN))
};
// First, try to call the `__setattr__` dunder method. If this is present/defined, overrides // First, try to call the `__setattr__` dunder method. If this is present/defined, overrides
// assigning the attributed by the normal mechanism. // assigning the attributed by the normal mechanism.
let setattr_dunder_call_result = object_ty.try_call_dunder_with_policy( let setattr_dunder_call_result = object_ty.try_call_dunder_with_policy(
@ -3476,11 +3462,41 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
if let Some(builder) = if let Some(builder) =
self.context.report_lint(&INVALID_ASSIGNMENT, target) self.context.report_lint(&INVALID_ASSIGNMENT, target)
{ {
builder.into_diagnostic(format_args!( let is_setattr_synthesized = match object_ty
"Cannot assign to attribute `{attribute}` on type `{}` \ .class_member_with_policy(
whose `__setattr__` method returns `Never`/`NoReturn`", db,
object_ty.display(db) "__setattr__".into(),
)); MemberLookupPolicy::MRO_NO_OBJECT_FALLBACK,
) {
PlaceAndQualifiers {
place: Place::Type(attr_ty, _),
qualifiers: _,
} => attr_ty.is_callable_type(),
_ => false,
};
let member_exists =
!object_ty.member(db, attribute).place.is_unbound();
let msg = if !member_exists {
format!(
"Can not assign to unresolved attribute `{attribute}` on type `{}`",
object_ty.display(db)
)
} else if is_setattr_synthesized {
format!(
"Property `{attribute}` defined in `{}` is read-only",
object_ty.display(db)
)
} else {
format!(
"Cannot assign to attribute `{attribute}` on type `{}` \
whose `__setattr__` method returns `Never`/`NoReturn`",
object_ty.display(db)
)
};
builder.into_diagnostic(msg);
} }
} }
false false
@ -3530,85 +3546,71 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
place: Place::Type(meta_attr_ty, meta_attr_boundness), place: Place::Type(meta_attr_ty, meta_attr_boundness),
qualifiers: _, qualifiers: _,
} => { } => {
if is_read_only() { let assignable_to_meta_attr =
if emit_diagnostics { if let Place::Type(meta_dunder_set, _) =
if let Some(builder) = meta_attr_ty.class_member(db, "__set__".into()).place
self.context.report_lint(&INVALID_ASSIGNMENT, target) {
{ let successful_call = meta_dunder_set
builder.into_diagnostic(format_args!( .try_call(
"Property `{attribute}` defined in `{ty}` is read-only", db,
ty = object_ty.display(self.db()), &CallArguments::positional([
)); meta_attr_ty,
} object_ty,
} value_ty,
false ]),
} else { )
let assignable_to_meta_attr = .is_ok();
if let Place::Type(meta_dunder_set, _) =
meta_attr_ty.class_member(db, "__set__".into()).place
{
let successful_call = meta_dunder_set
.try_call(
db,
&CallArguments::positional([
meta_attr_ty,
object_ty,
value_ty,
]),
)
.is_ok();
if !successful_call && emit_diagnostics { if !successful_call && emit_diagnostics {
if let Some(builder) = self if let Some(builder) = self
.context .context
.report_lint(&INVALID_ASSIGNMENT, target) .report_lint(&INVALID_ASSIGNMENT, target)
{ {
// TODO: Here, it would be nice to emit an additional diagnostic that explains why the call failed // TODO: Here, it would be nice to emit an additional diagnostic that explains why the call failed
builder.into_diagnostic(format_args!( builder.into_diagnostic(format_args!(
"Invalid assignment to data descriptor attribute \ "Invalid assignment to data descriptor attribute \
`{attribute}` on type `{}` with custom `__set__` method", `{attribute}` on type `{}` with custom `__set__` method",
object_ty.display(db) object_ty.display(db)
)); ));
}
} }
}
successful_call successful_call
} else { } else {
ensure_assignable_to(meta_attr_ty) ensure_assignable_to(meta_attr_ty)
}; };
let assignable_to_instance_attribute = let assignable_to_instance_attribute =
if meta_attr_boundness == Boundness::PossiblyUnbound { if meta_attr_boundness == Boundness::PossiblyUnbound {
let (assignable, boundness) = if let Place::Type( let (assignable, boundness) = if let Place::Type(
instance_attr_ty, instance_attr_ty,
instance_attr_boundness,
) =
object_ty.instance_member(db, attribute).place
{
(
ensure_assignable_to(instance_attr_ty),
instance_attr_boundness, instance_attr_boundness,
) = )
object_ty.instance_member(db, attribute).place
{
(
ensure_assignable_to(instance_attr_ty),
instance_attr_boundness,
)
} else {
(true, Boundness::PossiblyUnbound)
};
if boundness == Boundness::PossiblyUnbound {
report_possibly_unbound_attribute(
&self.context,
target,
attribute,
object_ty,
);
}
assignable
} else { } else {
true (true, Boundness::PossiblyUnbound)
}; };
assignable_to_meta_attr && assignable_to_instance_attribute if boundness == Boundness::PossiblyUnbound {
} report_possibly_unbound_attribute(
&self.context,
target,
attribute,
object_ty,
);
}
assignable
} else {
true
};
assignable_to_meta_attr && assignable_to_instance_attribute
} }
PlaceAndQualifiers { PlaceAndQualifiers {
@ -3627,22 +3629,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
); );
} }
if is_read_only() { ensure_assignable_to(instance_attr_ty)
if emit_diagnostics {
if let Some(builder) = self
.context
.report_lint(&INVALID_ASSIGNMENT, target)
{
builder.into_diagnostic(format_args!(
"Property `{attribute}` defined in `{ty}` is read-only",
ty = object_ty.display(self.db()),
));
}
}
false
} else {
ensure_assignable_to(instance_attr_ty)
}
} else { } else {
if emit_diagnostics { if emit_diagnostics {
if let Some(builder) = if let Some(builder) =