From 39b41838f3957fbe2e73d2c2e38a29aa2358079c Mon Sep 17 00:00:00 2001 From: justin Date: Fri, 18 Jul 2025 05:35:05 -0400 Subject: [PATCH] [ty] synthesize __setattr__ for frozen dataclasses (#19307) ## 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 --- .../resources/mdtest/attributes.md | 2 +- .../mdtest/dataclasses/dataclasses.md | 37 +++- crates/ty_python_semantic/src/types/class.rs | 19 ++ crates/ty_python_semantic/src/types/infer.rs | 191 ++++++++---------- 4 files changed, 143 insertions(+), 106 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/attributes.md b/crates/ty_python_semantic/resources/mdtest/attributes.md index 260cee2f91..488b82886b 100644 --- a/crates/ty_python_semantic/resources/mdtest/attributes.md +++ b/crates/ty_python_semantic/resources/mdtest/attributes.md @@ -1806,7 +1806,7 @@ class Frozen: raise AttributeError("Attributes can not be modified") 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`" ``` diff --git a/crates/ty_python_semantic/resources/mdtest/dataclasses/dataclasses.md b/crates/ty_python_semantic/resources/mdtest/dataclasses/dataclasses.md index b685b37fe0..af54a7cd33 100644 --- a/crates/ty_python_semantic/resources/mdtest/dataclasses/dataclasses.md +++ b/crates/ty_python_semantic/resources/mdtest/dataclasses/dataclasses.md @@ -415,8 +415,7 @@ frozen_instance = MyFrozenGeneric[int](1) frozen_instance.x = 2 # error: [invalid-assignment] ``` -When attempting to mutate an unresolved attribute on a frozen dataclass, only `unresolved-attribute` -is emitted: +Attempting to mutate an unresolved attribute on a frozen dataclass: ```py from dataclasses import dataclass @@ -425,7 +424,39 @@ from dataclasses import dataclass class 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` diff --git a/crates/ty_python_semantic/src/types/class.rs b/crates/ty_python_semantic/src/types/class.rs index 0e15a8081f..e8c1b07d2a 100644 --- a/crates/ty_python_semantic/src/types/class.rs +++ b/crates/ty_python_semantic/src/types/class.rs @@ -1600,6 +1600,25 @@ impl<'db> ClassLiteral<'db> { .place .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, } } diff --git a/crates/ty_python_semantic/src/types/infer.rs b/crates/ty_python_semantic/src/types/infer.rs index 8d6211d771..da3f7ae8f4 100644 --- a/crates/ty_python_semantic/src/types/infer.rs +++ b/crates/ty_python_semantic/src/types/infer.rs @@ -3446,20 +3446,6 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { | Type::AlwaysTruthy | Type::AlwaysFalsy | 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 // assigning the attributed by the normal mechanism. 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) = self.context.report_lint(&INVALID_ASSIGNMENT, target) { - builder.into_diagnostic(format_args!( - "Cannot assign to attribute `{attribute}` on type `{}` \ - whose `__setattr__` method returns `Never`/`NoReturn`", - object_ty.display(db) - )); + let is_setattr_synthesized = match object_ty + .class_member_with_policy( + 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 @@ -3530,85 +3546,71 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { place: Place::Type(meta_attr_ty, meta_attr_boundness), qualifiers: _, } => { - if is_read_only() { - 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 { - let assignable_to_meta_attr = - 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(); + let assignable_to_meta_attr = + 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 let Some(builder) = self - .context - .report_lint(&INVALID_ASSIGNMENT, target) - { - // TODO: Here, it would be nice to emit an additional diagnostic that explains why the call failed - builder.into_diagnostic(format_args!( + if !successful_call && emit_diagnostics { + if let Some(builder) = self + .context + .report_lint(&INVALID_ASSIGNMENT, target) + { + // TODO: Here, it would be nice to emit an additional diagnostic that explains why the call failed + builder.into_diagnostic(format_args!( "Invalid assignment to data descriptor attribute \ `{attribute}` on type `{}` with custom `__set__` method", object_ty.display(db) )); - } } + } - successful_call - } else { - ensure_assignable_to(meta_attr_ty) - }; + successful_call + } else { + ensure_assignable_to(meta_attr_ty) + }; - let assignable_to_instance_attribute = - if meta_attr_boundness == Boundness::PossiblyUnbound { - let (assignable, boundness) = if let Place::Type( - instance_attr_ty, + let assignable_to_instance_attribute = + if meta_attr_boundness == Boundness::PossiblyUnbound { + let (assignable, boundness) = if let Place::Type( + instance_attr_ty, + instance_attr_boundness, + ) = + object_ty.instance_member(db, attribute).place + { + ( + ensure_assignable_to(instance_attr_ty), 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 { - 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 { @@ -3627,22 +3629,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { ); } - if is_read_only() { - 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) - } + ensure_assignable_to(instance_attr_ty) } else { if emit_diagnostics { if let Some(builder) =