From 01eeb2f0d6894f413048ff8fc8980453bf17acab Mon Sep 17 00:00:00 2001 From: justin Date: Thu, 22 May 2025 00:20:34 -0400 Subject: [PATCH] [ty] Support frozen dataclasses (#17974) ## Summary https://github.com/astral-sh/ty/issues/111 This PR adds support for `frozen` dataclasses. It will emit a diagnostic with a similar message to mypy Note: This does not include emitting a diagnostic if `__setattr__` or `__delattr__` are defined on the object as per the [spec](https://docs.python.org/3/library/dataclasses.html#module-contents) ## Test Plan mdtest --------- Co-authored-by: Alex Waygood Co-authored-by: Carl Meyer --- .../resources/mdtest/dataclasses.md | 60 +++++++- crates/ty_python_semantic/src/types/infer.rs | 145 ++++++++++++------ 2 files changed, 153 insertions(+), 52 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/dataclasses.md b/crates/ty_python_semantic/resources/mdtest/dataclasses.md index 5db68ca81b..fcbe3f6c1b 100644 --- a/crates/ty_python_semantic/resources/mdtest/dataclasses.md +++ b/crates/ty_python_semantic/resources/mdtest/dataclasses.md @@ -369,7 +369,65 @@ To do ### `frozen` -To do +If true (the default is False), assigning to fields will generate a diagnostic. + +```py +from dataclasses import dataclass + +@dataclass(frozen=True) +class MyFrozenClass: + x: int + +frozen_instance = MyFrozenClass(1) +frozen_instance.x = 2 # error: [invalid-assignment] +``` + +If `__setattr__()` or `__delattr__()` is defined in the class, we should emit a diagnostic. + +```py +from dataclasses import dataclass + +@dataclass(frozen=True) +class MyFrozenClass: + x: int + + # TODO: Emit a diagnostic here + def __setattr__(self, name: str, value: object) -> None: ... + + # TODO: Emit a diagnostic here + def __delattr__(self, name: str) -> None: ... +``` + +This also works for generic dataclasses: + +```toml +[environment] +python-version = "3.12" +``` + +```py +from dataclasses import dataclass + +@dataclass(frozen=True) +class MyFrozenGeneric[T]: + x: T + +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: + +```py +from dataclasses import dataclass + +@dataclass(frozen=True) +class MyFrozenClass: ... + +frozen = MyFrozenClass() +frozen.x = 2 # error: [unresolved-attribute] +``` ### `match_args` diff --git a/crates/ty_python_semantic/src/types/infer.rs b/crates/ty_python_semantic/src/types/infer.rs index 9ba6220198..46c383eaa7 100644 --- a/crates/ty_python_semantic/src/types/infer.rs +++ b/crates/ty_python_semantic/src/types/infer.rs @@ -3077,6 +3077,20 @@ impl<'db> TypeInferenceBuilder<'db> { | Type::TypeVar(..) | Type::AlwaysTruthy | Type::AlwaysFalsy => { + 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)) + }; + match object_ty.class_member(db, attribute.into()) { meta_attr @ SymbolAndQualifiers { .. } if meta_attr.is_class_var() => { if emit_diagnostics { @@ -3096,68 +3110,83 @@ impl<'db> TypeInferenceBuilder<'db> { symbol: Symbol::Type(meta_attr_ty, meta_attr_boundness), qualifiers: _, } => { - let assignable_to_meta_attr = if let Symbol::Type(meta_dunder_set, _) = - meta_attr_ty.class_member(db, "__set__".into()).symbol - { - let successful_call = meta_dunder_set - .try_call( - db, - &CallArgumentTypes::positional([ - meta_attr_ty, - object_ty, - value_ty, - ]), - ) - .is_ok(); - - if !successful_call && emit_diagnostics { + if is_read_only() { + if 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) + "Property `{attribute}` defined in `{ty}` is read-only", + ty = object_ty.display(self.db()), )); } } - - successful_call + false } else { - ensure_assignable_to(meta_attr_ty) - }; - - let assignable_to_instance_attribute = if meta_attr_boundness - == Boundness::PossiblyUnbound - { - let (assignable, boundness) = - if let Symbol::Type(instance_attr_ty, instance_attr_boundness) = - object_ty.instance_member(db, attribute).symbol - { - ( - ensure_assignable_to(instance_attr_ty), - instance_attr_boundness, + let assignable_to_meta_attr = if let Symbol::Type(meta_dunder_set, _) = + meta_attr_ty.class_member(db, "__set__".into()).symbol + { + let successful_call = meta_dunder_set + .try_call( + db, + &CallArgumentTypes::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!( + "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) + }; + + let assignable_to_instance_attribute = + if meta_attr_boundness == Boundness::PossiblyUnbound { + let (assignable, boundness) = if let Symbol::Type( + instance_attr_ty, + instance_attr_boundness, + ) = + object_ty.instance_member(db, attribute).symbol + { + ( + 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, Boundness::PossiblyUnbound) + true }; - 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 + assignable_to_meta_attr && assignable_to_instance_attribute + } } SymbolAndQualifiers { @@ -3176,7 +3205,21 @@ impl<'db> TypeInferenceBuilder<'db> { ); } - ensure_assignable_to(instance_attr_ty) + 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) + } } else { let result = object_ty.try_call_dunder_with_policy( db,