From 4373974dd942100495daf4489a58677a754996a9 Mon Sep 17 00:00:00 2001 From: Mahmoud Saada Date: Tue, 11 Nov 2025 15:54:05 -0500 Subject: [PATCH] [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 --- .../resources/mdtest/type_qualifiers/final.md | 138 +++++++++++++++++- .../src/types/infer/builder.rs | 78 ++++++++-- 2 files changed, 196 insertions(+), 20 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/type_qualifiers/final.md b/crates/ty_python_semantic/resources/mdtest/type_qualifiers/final.md index 29e3d72ec3..1c42ded723 100644 --- a/crates/ty_python_semantic/resources/mdtest/type_qualifiers/final.md +++ b/crates/ty_python_semantic/resources/mdtest/type_qualifiers/final.md @@ -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 ``" @@ -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 diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index e775ca1993..dea82603f4 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -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 {