diff --git a/crates/ty_python_semantic/resources/mdtest/dataclasses.md b/crates/ty_python_semantic/resources/mdtest/dataclasses.md index a74c125b5dbe65..72ae5bf18431b5 100644 --- a/crates/ty_python_semantic/resources/mdtest/dataclasses.md +++ b/crates/ty_python_semantic/resources/mdtest/dataclasses.md @@ -380,6 +380,19 @@ frozen_instance = MyFrozenClass(1) frozen_instance.x = 2 # error: [invalid-assignment] ``` +Deleting fields will also generate a diagnostic. + +```py +from dataclasses import dataclass + +@dataclass(frozen=True) +class MyFrozenClass: + x: int = 1 + +frozen_instance = MyFrozenClass() +del frozen_instance.x # TODO: error: [invalid-assignment] +``` + If `__setattr__()` or `__delattr__()` is defined in the class, we should emit a diagnostic. ```py @@ -427,6 +440,22 @@ frozen = MyFrozenClass() frozen.x = 2 # error: [unresolved-attribute] ``` +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] +``` + ### `match_args` To do diff --git a/crates/ty_python_semantic/src/types/class.rs b/crates/ty_python_semantic/src/types/class.rs index 50b9e8b6820e09..627135f2685377 100644 --- a/crates/ty_python_semantic/src/types/class.rs +++ b/crates/ty_python_semantic/src/types/class.rs @@ -1400,6 +1400,26 @@ impl<'db> ClassLiteral<'db> { .with_annotated_type(KnownClass::Type.to_instance(db)); signature_from_fields(vec![cls_parameter]) } + (CodeGeneratorKind::DataclassLike, "__setattr__" | "__delattr__") => { + if !has_dataclass_param(DataclassParams::FROZEN) { + return None; + } + + 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), + ); + + Some(CallableType::function_like(db, signature)) + } (CodeGeneratorKind::DataclassLike, "__lt__" | "__le__" | "__gt__" | "__ge__") => { if !has_dataclass_param(DataclassParams::ORDER) { return None; diff --git a/crates/ty_python_semantic/src/types/infer.rs b/crates/ty_python_semantic/src/types/infer.rs index acbf27b8815ab8..5d80d773641c3f 100644 --- a/crates/ty_python_semantic/src/types/infer.rs +++ b/crates/ty_python_semantic/src/types/infer.rs @@ -3099,101 +3099,147 @@ 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)) - }; + let setattr_dunder_call_result = object_ty.try_call_dunder_with_policy( + db, + "__setattr__", + &mut CallArgumentTypes::positional([ + Type::StringLiteral(StringLiteralType::new(db, Box::from(attribute))), + value_ty, + ]), + MemberLookupPolicy::MRO_NO_OBJECT_FALLBACK, + ); - match object_ty.class_member(db, attribute.into()) { - meta_attr @ SymbolAndQualifiers { .. } if meta_attr.is_class_var() => { - if emit_diagnostics { - if let Some(builder) = - self.context.report_lint(&INVALID_ATTRIBUTE_ACCESS, target) - { - builder.into_diagnostic(format_args!( - "Cannot assign to ClassVar `{attribute}` \ - from an instance of type `{ty}`", - ty = object_ty.display(self.db()), - )); - } - } - false - } - SymbolAndQualifiers { - symbol: Symbol::Type(meta_attr_ty, meta_attr_boundness), - qualifiers: _, - } => { - if is_read_only() { + // First, try to call the `__setattr__` dunder method. If this is present/defined, overrides + // assigning the attributed by the normal mechanism. + match setattr_dunder_call_result { + Ok(result) => match result.return_type(db) { + Type::Never => { 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()), + "Attribute `{attribute}` on type `{}` is read-only", + object_ty.display(db) )); } } false - } else { - let assignable_to_meta_attr = if let Symbol::Type(meta_dunder_set, _) = - meta_attr_ty.class_member(db, "__set__".into()).symbol + } + _ => true, + }, + Err(CallDunderError::CallError(..)) => { + if emit_diagnostics { + if let Some(builder) = + self.context.report_lint(&UNRESOLVED_ATTRIBUTE, target) { - 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 { + builder.into_diagnostic(format_args!( + "Can not assign object of `{}` to attribute \ + `{attribute}` on type `{}` with \ + custom `__setattr__` method.", + value_ty.display(db), + object_ty.display(db) + )); + } + } + false + } + Err(CallDunderError::PossiblyUnbound(_)) => true, + Err(CallDunderError::MethodNotAvailable) => { + match object_ty.class_member(db, attribute.into()) { + meta_attr @ SymbolAndQualifiers { .. } if meta_attr.is_class_var() => { + if emit_diagnostics { if let Some(builder) = - self.context.report_lint(&INVALID_ASSIGNMENT, target) + self.context.report_lint(&INVALID_ATTRIBUTE_ACCESS, target) { - // TODO: Here, it would be nice to emit an additional diagnostic that explains why the call failed builder.into_diagnostic(format_args!( + "Cannot assign to ClassVar `{attribute}` \ + from an instance of type `{ty}`", + ty = object_ty.display(self.db()), + )); + } + } + false + } + SymbolAndQualifiers { + 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 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 Symbol::Type( - instance_attr_ty, - instance_attr_boundness, - ) = - object_ty.instance_member(db, attribute).symbol - { - ( - ensure_assignable_to(instance_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 { + assignable_to_meta_attr && assignable_to_instance_attribute + } + + SymbolAndQualifiers { + symbol: Symbol::Unbound, + .. + } => { + if let Symbol::Type(instance_attr_ty, instance_attr_boundness) = + object_ty.instance_member(db, attribute).symbol + { + if instance_attr_boundness == Boundness::PossiblyUnbound { report_possibly_unbound_attribute( &self.context, target, @@ -3202,79 +3248,8 @@ impl<'db> TypeInferenceBuilder<'db> { ); } - assignable + ensure_assignable_to(instance_attr_ty) } else { - true - }; - - assignable_to_meta_attr && assignable_to_instance_attribute - } - } - - SymbolAndQualifiers { - symbol: Symbol::Unbound, - .. - } => { - if let Symbol::Type(instance_attr_ty, instance_attr_boundness) = - object_ty.instance_member(db, attribute).symbol - { - if instance_attr_boundness == Boundness::PossiblyUnbound { - report_possibly_unbound_attribute( - &self.context, - target, - attribute, - object_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, - "__setattr__", - &mut CallArgumentTypes::positional([ - Type::StringLiteral(StringLiteralType::new( - db, - Box::from(attribute), - )), - value_ty, - ]), - MemberLookupPolicy::MRO_NO_OBJECT_FALLBACK, - ); - - match result { - Ok(_) | Err(CallDunderError::PossiblyUnbound(_)) => true, - Err(CallDunderError::CallError(..)) => { - if emit_diagnostics { - if let Some(builder) = - self.context.report_lint(&UNRESOLVED_ATTRIBUTE, target) - { - builder.into_diagnostic(format_args!( - "Can not assign object of `{}` to attribute \ - `{attribute}` on type `{}` with \ - custom `__setattr__` method.", - value_ty.display(db), - object_ty.display(db) - )); - } - } - false - } - Err(CallDunderError::MethodNotAvailable) => { if emit_diagnostics { if let Some(builder) = self.context.report_lint(&UNRESOLVED_ATTRIBUTE, target)