[ty] Fix false positive for Final attribute assignment in __init__ (#21158)

## Summary

Fixes https://github.com/astral-sh/ty/issues/1409

This PR allows `Final` instance attributes to be initialized in
`__init__` methods, as mandated by the Python typing specification (PEP
591). Previously, ty incorrectly prevented this initialization, causing
false positive errors.

The fix checks if we're inside an `__init__` method before rejecting
Final attribute assignments, allowing assignments during
instance initialization while still preventing reassignment elsewhere.

## Test Plan

- Added new test coverage in `final.md` for the reported issue with
`Self` annotations
- Updated existing tests that were incorrectly expecting errors 
- All 278 mdtest tests pass
- Manually tested with real-world code examples

---------

Co-authored-by: Carl Meyer <carl@astral.sh>
This commit is contained in:
Mahmoud Saada 2025-11-11 15:54:05 -05:00 committed by GitHub
parent e4374f14ed
commit 4373974dd9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 196 additions and 20 deletions

View file

@ -88,8 +88,6 @@ class C:
self.FINAL_C: Final[int] = 1
self.FINAL_D: Final = 1
self.FINAL_E: Final
# TODO: Should not be an error
# error: [invalid-assignment] "Cannot assign to final attribute `FINAL_E` on type `Self@__init__`"
self.FINAL_E = 1
reveal_type(C.FINAL_A) # revealed: int
@ -186,7 +184,6 @@ class C(metaclass=Meta):
self.INSTANCE_FINAL_A: Final[int] = 1
self.INSTANCE_FINAL_B: Final = 1
self.INSTANCE_FINAL_C: Final[int]
# error: [invalid-assignment] "Cannot assign to final attribute `INSTANCE_FINAL_C` on type `Self@__init__`"
self.INSTANCE_FINAL_C = 1
# error: [invalid-assignment] "Cannot assign to final attribute `META_FINAL_A` on type `<class 'C'>`"
@ -282,8 +279,6 @@ class C:
def __init__(self):
self.LEGAL_H: Final[int] = 1
self.LEGAL_I: Final[int]
# TODO: Should not be an error
# error: [invalid-assignment]
self.LEGAL_I = 1
# error: [invalid-type-form] "`Final` is not allowed in function parameter annotations"
@ -392,15 +387,142 @@ class C:
# TODO: This should be an error
NO_ASSIGNMENT_B: Final[int]
# This is okay. `DEFINED_IN_INIT` is defined in `__init__`.
DEFINED_IN_INIT: Final[int]
def __init__(self):
# TODO: should not be an error
# error: [invalid-assignment]
self.DEFINED_IN_INIT = 1
```
## Final attributes with Self annotation in `__init__`
Issue #1409: Final instance attributes should be assignable in `__init__` even when using `Self`
type annotation.
```toml
[environment]
python-version = "3.11"
```
```py
from typing import Final, Self
class ClassA:
ID4: Final[int] # OK because initialized in __init__
def __init__(self: Self):
self.ID4 = 1 # Should be OK
def other_method(self: Self):
# error: [invalid-assignment] "Cannot assign to final attribute `ID4` on type `Self@other_method`"
self.ID4 = 2 # Should still error outside __init__
class ClassB:
ID5: Final[int]
def __init__(self): # Without Self annotation
self.ID5 = 1 # Should also be OK
reveal_type(ClassA().ID4) # revealed: int
reveal_type(ClassB().ID5) # revealed: int
```
## Reassignment to Final in `__init__`
Per PEP 591 and the typing conformance suite, Final attributes can be assigned in `__init__`.
Multiple assignments within `__init__` are allowed (matching mypy and pyright behavior). However,
assignment in `__init__` is not allowed if the attribute already has a value at class level.
```py
from typing import Final
# Case 1: Declared in class, assigned once in __init__ - ALLOWED
class DeclaredAssignedInInit:
attr1: Final[int]
def __init__(self):
self.attr1 = 1 # OK: First and only assignment
# Case 2: Declared and assigned in class body - ALLOWED (no __init__ assignment)
class DeclaredAndAssignedInClass:
attr2: Final[int] = 10
# Case 3: Reassignment when already assigned in class body
class ReassignmentFromClass:
attr3: Final[int] = 10
def __init__(self):
# error: [invalid-assignment]
self.attr3 = 20 # Error: already assigned in class body
# Case 4: Multiple assignments within __init__ itself
# Per conformance suite and PEP 591, all assignments in __init__ are allowed
class MultipleAssignmentsInInit:
attr4: Final[int]
def __init__(self):
self.attr4 = 1 # OK: Assignment in __init__
self.attr4 = 2 # OK: Multiple assignments in __init__ are allowed
class ConditionalAssignment:
X: Final[int]
def __init__(self, cond: bool):
if cond:
self.X = 42 # OK: Assignment in __init__
else:
self.X = 56 # OK: Multiple assignments in __init__ are allowed
# Case 5: Declaration and assignment in __init__ - ALLOWED
class DeclareAndAssignInInit:
def __init__(self):
self.attr5: Final[int] = 1 # OK: Declare and assign in __init__
# Case 6: Assignment outside __init__ should still fail
class AssignmentOutsideInit:
attr6: Final[int]
def other_method(self):
# error: [invalid-assignment] "Cannot assign to final attribute `attr6`"
self.attr6 = 1 # Error: Not in __init__
```
## Final assignment restrictions in `__init__`
`__init__` can only assign Final attributes on the class it's defining, and only to the first
parameter (`self`).
```py
from typing import Final
class C:
x: Final[int] = 100
# Assignment from standalone function (even named __init__)
def _(c: C):
# error: [invalid-assignment] "Cannot assign to final attribute `x`"
c.x = 1 # Error: Not in C.__init__
def __init__(c: C):
# error: [invalid-assignment] "Cannot assign to final attribute `x`"
c.x = 1 # Error: Not a method
# Assignment from another class's __init__
class A:
def __init__(self, c: C):
# error: [invalid-assignment] "Cannot assign to final attribute `x`"
c.x = 1 # Error: Not C's __init__
# Assignment to non-self parameter in __init__
class D:
y: Final[int]
def __init__(self, other: "D"):
self.y = 1 # OK: Assigning to self
# TODO: Should error - assigning to non-self parameter
# Requires tracking which parameter the base expression refers to
other.y = 2
```
## Full diagnostics
<!-- snapshot-diagnostics -->

View file

@ -3741,23 +3741,77 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
assignable
};
let emit_invalid_final = |builder: &Self| {
if emit_diagnostics {
if let Some(builder) = builder.context.report_lint(&INVALID_ASSIGNMENT, target) {
builder.into_diagnostic(format_args!(
"Cannot assign to final attribute `{attribute}` on type `{}`",
object_ty.display(db)
));
}
}
};
// Return true (and emit a diagnostic) if this is an invalid assignment to a `Final` attribute.
// Per PEP 591 and the typing conformance suite, Final instance attributes can be assigned
// in __init__ methods. Multiple assignments within __init__ are allowed (matching mypy
// and pyright behavior), as long as the attribute doesn't have a class-level value.
let invalid_assignment_to_final = |builder: &Self, qualifiers: TypeQualifiers| -> bool {
if qualifiers.contains(TypeQualifiers::FINAL) {
if emit_diagnostics {
if let Some(builder) = builder.context.report_lint(&INVALID_ASSIGNMENT, target)
{
builder.into_diagnostic(format_args!(
"Cannot assign to final attribute `{attribute}` \
on type `{}`",
object_ty.display(db)
));
// Check if it's a Final attribute
if !qualifiers.contains(TypeQualifiers::FINAL) {
return false;
}
// Check if we're in an __init__ method (where Final attributes can be initialized).
let is_in_init = builder
.current_function_definition()
.is_some_and(|func| func.name.id == "__init__");
// Not in __init__ - always disallow
if !is_in_init {
emit_invalid_final(builder);
return true;
}
// We're in __init__ - verify we're in a method of the class being mutated
let Some(class_ty) = builder.class_context_of_current_method() else {
// Not a method (standalone function named __init__)
emit_invalid_final(builder);
return true;
};
// Check that object_ty is an instance of the class we're in
if !object_ty.is_subtype_of(builder.db(), Type::instance(builder.db(), class_ty)) {
// Assigning to a different class's Final attribute
emit_invalid_final(builder);
return true;
}
// Check if class-level attribute already has a value
{
let class_definition = class_ty.class_literal(db).0;
let class_scope_id = class_definition.body_scope(db).file_scope_id(db);
let place_table = builder.index.place_table(class_scope_id);
if let Some(symbol) = place_table.symbol_by_name(attribute) {
if symbol.is_bound() {
if emit_diagnostics {
if let Some(diag_builder) =
builder.context.report_lint(&INVALID_ASSIGNMENT, target)
{
diag_builder.into_diagnostic(format_args!(
"Cannot assign to final attribute `{attribute}` in `__init__` \
because it already has a value at class level"
));
}
}
return true;
}
}
true
} else {
false
}
// In __init__ and no class-level value - allow
false
};
match object_ty {