[ty] Fix false positive for Final attribute assignment in __init__#21158
[ty] Fix false positive for Final attribute assignment in __init__#21158carljm merged 8 commits intoastral-sh:mainfrom
Conversation
Diagnostic diff on typing conformance testsChanges were detected when running ty on typing conformance tests--- old-output.txt 2025-11-11 20:42:52.251115074 +0000
+++ new-output.txt 2025-11-11 20:42:55.645138741 +0000
@@ -864,10 +864,7 @@
qualifiers_annotated.py:87:1: error[call-non-callable] Object of type `GenericAlias` is not callable
qualifiers_annotated.py:88:1: error[call-non-callable] Object of type `GenericAlias` is not callable
qualifiers_final_annotation.py:18:7: error[invalid-type-form] Type qualifier `typing.Final` expected exactly 1 argument, got 2
-qualifiers_final_annotation.py:52:9: error[invalid-assignment] Cannot assign to final attribute `ID4` on type `Self@__init__`
-qualifiers_final_annotation.py:54:9: error[invalid-assignment] Cannot assign to final attribute `ID5` on type `Self@__init__`
-qualifiers_final_annotation.py:57:13: error[invalid-assignment] Cannot assign to final attribute `ID6` on type `Self@__init__`
-qualifiers_final_annotation.py:59:13: error[invalid-assignment] Cannot assign to final attribute `ID6` on type `Self@__init__`
+qualifiers_final_annotation.py:54:9: error[invalid-assignment] Cannot assign to final attribute `ID5` in `__init__` because it already has a value at class level
qualifiers_final_annotation.py:65:9: error[invalid-assignment] Cannot assign to final attribute `ID7` on type `Self@method1`
qualifiers_final_annotation.py:71:1: error[invalid-assignment] Reassignment of `Final` symbol `RATE` is not allowed: Symbol later reassigned here
qualifiers_final_annotation.py:81:1: error[invalid-assignment] Cannot assign to final attribute `DEFAULT_ID` on type `<class 'ClassB'>`
@@ -1006,5 +1003,5 @@
typeddicts_usage.py:28:17: error[missing-typed-dict-key] Missing required key 'name' in TypedDict `Movie` constructor
typeddicts_usage.py:28:18: error[invalid-key] Invalid key for TypedDict `Movie`: Unknown key "title"
typeddicts_usage.py:40:24: error[invalid-type-form] The special form `typing.TypedDict` is not allowed in type expressions. Did you mean to use a concrete TypedDict or `collections.abc.Mapping[str, object]` instead?
-Found 1008 diagnostics
+Found 1005 diagnostics
WARN A fatal error occurred while checking some files. Not all project files were analyzed. See the diagnostics list above for details.
|
|
5577180 to
5d10a3e
Compare
sharkdp
left a comment
There was a problem hiding this comment.
Thank you for working on this. I think we should probably just allow the first assignment to a Final-qualified attribute in __init__? It would also be good to add some tests that assignments to those attributes from other methods is not allowed.
|
Thank you for the PR!! Some other tests that I think we're currently missing: this should probably be disallowed. It looks like we already do disallow it, but I can't find any explicit tests for it (@sharkdp, please correct me if I'm wrong!): from typing import Final
class Foo:
X: Final = 42
def __init__(self):
self.X = 56This should probably be allowed, and it would be great to add a test for it: import sys
from typing import Final
```py
class Bar:
X: Final[int]
def __init__(self):
if sys.version_info >= (3, 11):
self.X = 42
else:
self.X = 56 |
ca0574f to
1af08d7
Compare
|
Thanks for the feedback. After several iterations, I think this is the cleanest approach. The Rust fix works by modifying the |
Resolves astral-sh#1409 Allow Final instance attributes to be initialized in __init__ methods, as specified by the Python typing spec. Previously, ty incorrectly prevented this initialization. Changes: - Modified attribute assignment validation to permit Final assignments when inside __init__ methods - Updated tests to reflect correct behavior - Added test coverage for issue astral-sh#1409 with Self annotations
Per feedback from sharkdp and AlexWaygood, this allows Final instance attributes to be initialized in __init__ methods as specified in PEP 591. The implementation adds an is_in_init() helper closure and modifies the existing invalid_assignment_to_final closure to check for __init__ context. This centralizes the logic and avoids duplication across the three call sites. Known limitations (require flow-sensitive analysis for future work): - Reassignment to class-level assigned Finals in __init__ - Multiple assignments to same Final within __init__ These limitations are documented in the test file with TODO markers. All 278 mdtest tests pass. Refs astral-sh#1409
cdc8462 to
5afa7af
Compare
Implements detection for PEP 591 violation when a Final attribute
with a class-level value is reassigned in __init__.
For example, this now correctly errors:
```python
class Test:
attr: Final[int] = 10 # Has value
def __init__(self):
self.attr = 20 # ERROR
```
While still allowing initialization when no value exists:
```python
class Test:
attr: Final[int] # Just annotation
def __init__(self):
self.attr = 20 # OK
```
Implementation uses the semantic index to check if the class-level
declaration is an AnnotatedAssignment with a value field.
|
We should confirm our assumptions about the desired behavior here against the conformance tests and the spec and verify that the behavior we're implementing matches them. It's also useful to validate against other type checkers.
The conformance suite does not require this. It does require that we allow multiple conditional assignments in class C:
x: Final[int]
def __init__(self, cond: bool):
if cond:
self.x = 1
else:
self.x = 2It appears that both mypy and pyright simply always allow multiple assignments in The other thing highlighted by the conformance suite results is that we should still error on assignment in |
5afa7af to
04234f9
Compare
|
I checked the other type checkers and here's a summary of what was found Our implementation matches closer to pyright!
We might have to take a stance here since there's a gap in behavior between the tools |
…ance suite Per Carl's feedback, the typing conformance suite requires that multiple assignments to Final attributes in __init__ are allowed. This matches the behavior of mypy and pyright. Our implementation already correctly allows this - this commit just updates the documentation and comments to clarify that this is intentional behavior, not a limitation. Changes: - Updated test documentation to remove TODOs and clarify allowed behavior - Updated code comments to reflect conformance suite requirements - Added test case with conditional parameter per Carl's example
07c18b4 to
7fcb32a
Compare
|
@saada Thanks for generating the chart. I think the chart is not really accurate, though, so I'm wondering what method you used to generate it. For example, I checked the first row, and mypy does allow the two conditional assignments, so I'm not sure why it's marked with an X in that row on your chart. In fact, as I mentioned in my previous comment, mypy allows two subsequent assignments in And in the second row, you claim that pyright is OK with assignment in In general I think the behavior of mypy and pyright is much more consistent with each other in this area than your chart suggests, and where they agree that's a pretty strong signal that we should also agree, unless we have very good reason to do otherwise. |
|
@carljm Thank you for the detailed review and pointing me to the conformance suite! After thorough investigation, I can confirm our implementation is correct. Key Finding: Mypy Playground Mystery SolvedYour mypy playground link shows no error because it uses untyped parameters: def __init__(self, cond): # ← No type annotationWhen I test with typed parameters, mypy errors (as documented in the conformance suite): def __init__(self, cond: bool): # ← With type annotation
# mypy ERROR: Cannot assign to final attributeComprehensive ComparisonCase 1: Sequential Assignments in
|
| Test Case | mypy | pyright | ty | Conformance |
|---|---|---|---|---|
| Sequential in init | ✅ | ✅ | ✅ | Must allow |
| Conditional (untyped) | ✅ | ✅ | ✅ | Must allow |
| Conditional (typed) | ❌ ERROR | ✅ | ✅ | Must allow |
| Class-level value | ✅ WRONG | ❌ | ❌ | Must error |
| Outside init | ❌ | ❌ | ❌ | Must error |
Result: ty matches pyright perfectly (5/5 conformant)
Official Conformance Results
From the python/typing repository:
pyright: conformant = "Pass" ✅
mypy: conformant = "Partial" with note:
"Does not allow conditional assignment of Final instance variable in init method."
Our implementation is correct as-is and matches the conformance suite requirements.
Note: Our test suite (final.md) has been updated to explicitly cover both typed and untyped conditional assignment cases to ensure comprehensive coverage of all scenarios discussed above.
|
Aha! Thank you for pointing out that my mypy result was due to not having any type annotations on the |
carljm
left a comment
There was a problem hiding this comment.
Thanks for working on this! I agree with your conclusions above about the correct semantics.
crates/ty_python_semantic/resources/mdtest/type_qualifiers/final.md
Outdated
Show resolved
Hide resolved
crates/ty_python_semantic/resources/mdtest/type_qualifiers/final.md
Outdated
Show resolved
Hide resolved
crates/ty_python_semantic/resources/mdtest/type_qualifiers/final.md
Outdated
Show resolved
Hide resolved
crates/ty_python_semantic/resources/mdtest/type_qualifiers/final.md
Outdated
Show resolved
Hide resolved
- Remove unnecessary 'OK' comments from test assertions - Fix false negatives: verify __init__ is a method of the class being mutated - Add test cases for invalid scenarios: * Standalone functions named __init__ * Assignment from another class's __init__ * Assignment to non-self parameter (marked as TODO - requires parameter tracking) The implementation now checks: 1. We're in a function named __init__ 2. The function is a method (has class context) 3. The object type matches the class we're in (handles both NominalInstance and Self types) This prevents false negatives where Final attributes could be assigned from: - Standalone functions named __init__ - Other classes' __init__ methods All 280 mdtest tests pass. Refs astral-sh#1409
|
Thank you for the thorough feedback! How does this look? |
|
Looks good, thank you! I pushed a few simplifications, will merge once CI is green. |
Summary
Fixes astral-sh/ty#1409
This PR allows
Finalinstance 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 the first assignment during instance initialization while still preventing reassignment elsewhere.Test Plan
final.mdfor the reported issue withSelfannotationsThis is my first contribution to the project, so I'd appreciate any feedback or guidance!