mirror of
https://github.com/astral-sh/ruff.git
synced 2025-11-19 03:48:29 +00:00
[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:
parent
e4374f14ed
commit
4373974dd9
2 changed files with 196 additions and 20 deletions
|
|
@ -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 -->
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue