[ty] Disallow assignment to Final class attributes (#19457)

## Summary

Emit errors for the following assignments:
```py
class C:
    CLASS_LEVEL_CONSTANT: Final[int] = 1

C.CLASS_LEVEL_CONSTANT = 2
C().CLASS_LEVEL_CONSTANT = 2
```

## Test Plan

Updated and new MD tests
This commit is contained in:
David Peter 2025-07-21 14:27:56 +02:00 committed by GitHub
parent f063c0e874
commit b6579eaf04
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 63 additions and 20 deletions

View file

@ -498,7 +498,7 @@ class C:
reveal_type(C.__init__) # revealed: (self: C, instance_variable_no_default: int, instance_variable: int = Literal[1]) -> None reveal_type(C.__init__) # revealed: (self: C, instance_variable_no_default: int, instance_variable: int = Literal[1]) -> None
c = C(1) c = C(1)
# TODO: this should be an error # error: [invalid-assignment] "Cannot assign to final attribute `instance_variable` on type `C`"
c.instance_variable = 2 c.instance_variable = 2
``` ```

View file

@ -170,26 +170,37 @@ Assignments to attributes qualified with `Final` are also not allowed:
```py ```py
from typing import Final from typing import Final
class C: class Meta(type):
FINAL_A: Final[int] = 1 META_FINAL_A: Final[int] = 1
FINAL_B: Final = 1 META_FINAL_B: Final = 1
class C(metaclass=Meta):
CLASS_FINAL_A: Final[int] = 1
CLASS_FINAL_B: Final = 1
def __init__(self): def __init__(self):
self.FINAL_C: Final[int] = 1 self.INSTANCE_FINAL_A: Final[int] = 1
self.FINAL_D: Final = 1 self.INSTANCE_FINAL_B: Final = 1
# TODO: these should be errors (that mention `Final`) # error: [invalid-assignment] "Cannot assign to final attribute `META_FINAL_A` on type `<class 'C'>`"
C.FINAL_A = 2 C.META_FINAL_A = 2
# error: [invalid-assignment] "Object of type `Literal[2]` is not assignable to attribute `FINAL_B` of type `Literal[1]`" # error: [invalid-assignment] "Cannot assign to final attribute `META_FINAL_B` on type `<class 'C'>`"
C.FINAL_B = 2 C.META_FINAL_B = 2
# error: [invalid-assignment] "Cannot assign to final attribute `CLASS_FINAL_A` on type `<class 'C'>`"
C.CLASS_FINAL_A = 2
# error: [invalid-assignment] "Cannot assign to final attribute `CLASS_FINAL_B` on type `<class 'C'>`"
C.CLASS_FINAL_B = 2
# TODO: these should be errors (that mention `Final`)
c = C() c = C()
c.FINAL_A = 2 # error: [invalid-assignment] "Cannot assign to final attribute `CLASS_FINAL_A` on type `C`"
# error: [invalid-assignment] "Object of type `Literal[2]` is not assignable to attribute `FINAL_B` of type `Literal[1]`" c.CLASS_FINAL_A = 2
c.FINAL_B = 2 # error: [invalid-assignment] "Cannot assign to final attribute `CLASS_FINAL_B` on type `C`"
c.FINAL_C = 2 c.CLASS_FINAL_B = 2
c.FINAL_D = 2 # TODO: this should be an error
c.INSTANCE_FINAL_A = 2
# TODO: this should be an error
c.INSTANCE_FINAL_B = 2
``` ```
## Mutability ## Mutability

View file

@ -3363,6 +3363,24 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
assignable assignable
}; };
// Return true (and emit a diagnostic) if this is an invalid assignment to a `Final` attribute.
let invalid_assignment_to_final = |qualifiers: TypeQualifiers| -> bool {
if qualifiers.contains(TypeQualifiers::FINAL) {
if emit_diagnostics {
if let Some(builder) = self.context.report_lint(&INVALID_ASSIGNMENT, target) {
builder.into_diagnostic(format_args!(
"Cannot assign to final attribute `{attribute}` \
on type `{}`",
object_ty.display(db)
));
}
}
true
} else {
false
}
};
match object_ty { match object_ty {
Type::Union(union) => { Type::Union(union) => {
if union.elements(self.db()).iter().all(|elem| { if union.elements(self.db()).iter().all(|elem| {
@ -3558,8 +3576,12 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
} }
PlaceAndQualifiers { PlaceAndQualifiers {
place: Place::Type(meta_attr_ty, meta_attr_boundness), place: Place::Type(meta_attr_ty, meta_attr_boundness),
qualifiers: _, qualifiers,
} => { } => {
if invalid_assignment_to_final(qualifiers) {
return false;
}
let assignable_to_meta_attr = let assignable_to_meta_attr =
if let Place::Type(meta_dunder_set, _) = if let Place::Type(meta_dunder_set, _) =
meta_attr_ty.class_member(db, "__set__".into()).place meta_attr_ty.class_member(db, "__set__".into()).place
@ -3669,8 +3691,12 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
match object_ty.class_member(db, attribute.into()) { match object_ty.class_member(db, attribute.into()) {
PlaceAndQualifiers { PlaceAndQualifiers {
place: Place::Type(meta_attr_ty, meta_attr_boundness), place: Place::Type(meta_attr_ty, meta_attr_boundness),
qualifiers: _, qualifiers,
} => { } => {
if invalid_assignment_to_final(qualifiers) {
return false;
}
let assignable_to_meta_attr = if let Place::Type(meta_dunder_set, _) = let assignable_to_meta_attr = if let Place::Type(meta_dunder_set, _) =
meta_attr_ty.class_member(db, "__set__".into()).place meta_attr_ty.class_member(db, "__set__".into()).place
{ {
@ -3733,11 +3759,17 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
place: Place::Unbound, place: Place::Unbound,
.. ..
} => { } => {
if let Place::Type(class_attr_ty, class_attr_boundness) = object_ty if let PlaceAndQualifiers {
place: Place::Type(class_attr_ty, class_attr_boundness),
qualifiers,
} = object_ty
.find_name_in_mro(db, attribute) .find_name_in_mro(db, attribute)
.expect("called on Type::ClassLiteral or Type::SubclassOf") .expect("called on Type::ClassLiteral or Type::SubclassOf")
.place
{ {
if invalid_assignment_to_final(qualifiers) {
return false;
}
if class_attr_boundness == Boundness::PossiblyUnbound { if class_attr_boundness == Boundness::PossiblyUnbound {
report_possibly_unbound_attribute( report_possibly_unbound_attribute(
&self.context, &self.context,