diff --git a/crates/ty_python_semantic/resources/mdtest/attributes.md b/crates/ty_python_semantic/resources/mdtest/attributes.md index dd6278db62..6bab616edf 100644 --- a/crates/ty_python_semantic/resources/mdtest/attributes.md +++ b/crates/ty_python_semantic/resources/mdtest/attributes.md @@ -2035,6 +2035,31 @@ def use_module(m: MyModule, param: int) -> None: m.undefined_param = param ``` +### `__setattr__` returning `Never` blocks all assignments + +When `__setattr__` returns `Never` (indicating an immutable class), all attribute assignments are +blocked, even if the value type doesn't match `__setattr__`'s parameter type. + +```py +from typing import NoReturn + +class Immutable: + x: float + + def __setattr__(self, name: str, value: int) -> NoReturn: + raise AttributeError("Immutable") + +def _(obj: Immutable) -> None: + # Even though `"foo"` doesn't match `__setattr__`'s `value: int` parameter, + # we still detect that `__setattr__` returns `Never` and block the assignment. + # error: [invalid-assignment] "Cannot assign to attribute `x` on type `Immutable` whose `__setattr__` method returns `Never`/`NoReturn`" + obj.x = "foo" + + # Same for assignments that would match `__setattr__`'s parameter type. + # error: [invalid-assignment] "Cannot assign to attribute `x` on type `Immutable` whose `__setattr__` method returns `Never`/`NoReturn`" + obj.x = 42 +``` + ## Objects of all types have a `__class__` method The type of `x.__class__` is the same as `x`'s meta-type. `x.__class__` is always the same value as diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index c28d50e3b5..98f969aec3 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -4578,29 +4578,22 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { // However, we would still have to perform the first inference without type context. let value_ty = infer_value_ty(self, TypeContext::default()); + // Infer `__setattr__` once upfront. We use this result for: + // 1. Checking if it returns `Never` (indicating an immutable class) + // 2. As a fallback when no explicit attribute is found + let setattr_dunder_call_result = object_ty.try_call_dunder_with_policy( + db, + "__setattr__", + &mut CallArguments::positional([Type::string_literal(db, attribute), value_ty]), + TypeContext::default(), + MemberLookupPolicy::MRO_NO_OBJECT_FALLBACK, + ); + // Check if `__setattr__` returns `Never` (indicating an immutable class). // If so, block all attribute assignments regardless of explicit attributes. - let setattr_returns_never = { - let setattr_result = object_ty.try_call_dunder_with_policy( - db, - "__setattr__", - &mut CallArguments::positional([ - Type::string_literal(db, attribute), - value_ty, - ]), - TypeContext::default(), - MemberLookupPolicy::MRO_NO_OBJECT_FALLBACK, - ); - match setattr_result { - Ok(result) => result.return_type(db).is_never(), - Err(CallDunderError::PossiblyUnbound(result)) => { - result.return_type(db).is_never() - } - // If __setattr__ rejects the type or doesn't exist, it doesn't return Never - Err( - CallDunderError::CallError(..) | CallDunderError::MethodNotAvailable, - ) => false, - } + let setattr_returns_never = match &setattr_dunder_call_result { + Ok(result) => result.return_type(db).is_never(), + Err(err) => err.return_type(db).is_some_and(|ty| ty.is_never()), }; if setattr_returns_never { @@ -4768,44 +4761,11 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { ensure_assignable_to(self, value_ty, instance_attr_ty) } else { - // No explicit attribute found. Try `__setattr__` as a fallback - // for dynamic attribute assignment. - let setattr_dunder_call_result = object_ty.try_call_dunder_with_policy( - db, - "__setattr__", - &mut CallArguments::positional([ - Type::string_literal(db, attribute), - value_ty, - ]), - TypeContext::default(), - MemberLookupPolicy::MRO_NO_OBJECT_FALLBACK, - ); - - let check_setattr_return_type = |result: &Bindings<'db>| -> bool { - 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!( - "Cannot assign to unresolved attribute `{attribute}` on type `{}`", - object_ty.display(db) - )); - } - } - false - } - _ => true, - } - }; - + // No explicit attribute found. Use `__setattr__` (already inferred + // above) as a fallback for dynamic attribute assignment. match setattr_dunder_call_result { - Ok(result) => check_setattr_return_type(&result), - Err(CallDunderError::PossiblyUnbound(result)) => { - check_setattr_return_type(&result) - } + // If __setattr__ succeeded, allow the assignment. + Ok(_) | Err(CallDunderError::PossiblyUnbound(_)) => true, Err(CallDunderError::CallError(..)) => { if emit_diagnostics { if let Some(builder) =