Skip to content

Comments

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

Merged
carljm merged 8 commits intoastral-sh:mainfrom
saada:fix-final-init-1409
Nov 11, 2025
Merged

[ty] Fix false positive for Final attribute assignment in __init__#21158
carljm merged 8 commits intoastral-sh:mainfrom
saada:fix-final-init-1409

Conversation

@saada
Copy link
Contributor

@saada saada commented Oct 31, 2025

Summary

Fixes astral-sh/ty#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 the first assignment 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

This is my first contribution to the project, so I'd appreciate any feedback or guidance!

@github-actions
Copy link
Contributor

github-actions bot commented Oct 31, 2025

Diagnostic diff on typing conformance tests

Changes 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.

@github-actions
Copy link
Contributor

github-actions bot commented Oct 31, 2025

mypy_primer results

Changes were detected when running on open source projects
schema_salad (https://github.com/common-workflow-language/schema_salad)
- schema_salad/metaschema.py:73:9: error[invalid-assignment] Cannot assign to final attribute `original_doc` on type `Self@__init__`
- schema_salad/metaschema.py:79:9: error[invalid-assignment] Cannot assign to final attribute `idx` on type `Self@__init__`
- schema_salad/metaschema.py:85:9: error[invalid-assignment] Cannot assign to final attribute `fileuri` on type `Self@__init__`
- schema_salad/metaschema.py:91:9: error[invalid-assignment] Cannot assign to final attribute `baseuri` on type `Self@__init__`
- schema_salad/metaschema.py:97:9: error[invalid-assignment] Cannot assign to final attribute `namespaces` on type `Self@__init__`
- schema_salad/metaschema.py:103:9: error[invalid-assignment] Cannot assign to final attribute `schemas` on type `Self@__init__`
- schema_salad/metaschema.py:109:9: error[invalid-assignment] Cannot assign to final attribute `addl_metadata` on type `Self@__init__`
- schema_salad/metaschema.py:115:9: error[invalid-assignment] Cannot assign to final attribute `imports` on type `Self@__init__`
- schema_salad/metaschema.py:121:9: error[invalid-assignment] Cannot assign to final attribute `includes` on type `Self@__init__`
- schema_salad/metaschema.py:127:9: error[invalid-assignment] Cannot assign to final attribute `no_link_check` on type `Self@__init__`
- schema_salad/metaschema.py:133:9: error[invalid-assignment] Cannot assign to final attribute `container` on type `Self@__init__`
- schema_salad/metaschema.py:150:9: error[invalid-assignment] Cannot assign to final attribute `fetcher` on type `Self@__init__`
- schema_salad/metaschema.py:152:9: error[invalid-assignment] Cannot assign to final attribute `cache` on type `Self@__init__`
- schema_salad/metaschema.py:163:9: error[invalid-assignment] Cannot assign to final attribute `vocab` on type `Self@__init__`
- schema_salad/metaschema.py:164:9: error[invalid-assignment] Cannot assign to final attribute `rvocab` on type `Self@__init__`
- schema_salad/python_codegen_support.py:70:9: error[invalid-assignment] Cannot assign to final attribute `original_doc` on type `Self@__init__`
- schema_salad/python_codegen_support.py:76:9: error[invalid-assignment] Cannot assign to final attribute `idx` on type `Self@__init__`
- schema_salad/python_codegen_support.py:82:9: error[invalid-assignment] Cannot assign to final attribute `fileuri` on type `Self@__init__`
- schema_salad/python_codegen_support.py:88:9: error[invalid-assignment] Cannot assign to final attribute `baseuri` on type `Self@__init__`
- schema_salad/python_codegen_support.py:94:9: error[invalid-assignment] Cannot assign to final attribute `namespaces` on type `Self@__init__`
- schema_salad/python_codegen_support.py:100:9: error[invalid-assignment] Cannot assign to final attribute `schemas` on type `Self@__init__`
- schema_salad/python_codegen_support.py:106:9: error[invalid-assignment] Cannot assign to final attribute `addl_metadata` on type `Self@__init__`
- schema_salad/python_codegen_support.py:112:9: error[invalid-assignment] Cannot assign to final attribute `imports` on type `Self@__init__`
- schema_salad/python_codegen_support.py:118:9: error[invalid-assignment] Cannot assign to final attribute `includes` on type `Self@__init__`
- schema_salad/python_codegen_support.py:124:9: error[invalid-assignment] Cannot assign to final attribute `no_link_check` on type `Self@__init__`
- schema_salad/python_codegen_support.py:130:9: error[invalid-assignment] Cannot assign to final attribute `container` on type `Self@__init__`
- schema_salad/python_codegen_support.py:147:9: error[invalid-assignment] Cannot assign to final attribute `fetcher` on type `Self@__init__`
- schema_salad/python_codegen_support.py:149:9: error[invalid-assignment] Cannot assign to final attribute `cache` on type `Self@__init__`
- schema_salad/python_codegen_support.py:160:9: error[invalid-assignment] Cannot assign to final attribute `vocab` on type `Self@__init__`
- schema_salad/python_codegen_support.py:161:9: error[invalid-assignment] Cannot assign to final attribute `rvocab` on type `Self@__init__`
- Found 195 diagnostics
+ Found 165 diagnostics

mypy (https://github.com/python/mypy)
- mypy/types.py:554:9: error[invalid-assignment] Cannot assign to final attribute `raw_id` on type `Self@__init__`
- mypy/typestate.py:106:9: error[invalid-assignment] Cannot assign to final attribute `_subtype_caches` on type `Self@__init__`
- mypy/typestate.py:107:9: error[invalid-assignment] Cannot assign to final attribute `_negative_subtype_caches` on type `Self@__init__`
- mypy/typestate.py:109:9: error[invalid-assignment] Cannot assign to final attribute `_attempted_protocols` on type `Self@__init__`
- mypy/typestate.py:110:9: error[invalid-assignment] Cannot assign to final attribute `_checked_against_members` on type `Self@__init__`
- mypy/typestate.py:111:9: error[invalid-assignment] Cannot assign to final attribute `_rechecked_types` on type `Self@__init__`
- mypy/typestate.py:112:9: error[invalid-assignment] Cannot assign to final attribute `_assuming` on type `Self@__init__`
- mypy/typestate.py:113:9: error[invalid-assignment] Cannot assign to final attribute `_assuming_proper` on type `Self@__init__`
- mypy/typestate.py:114:9: error[invalid-assignment] Cannot assign to final attribute `inferring` on type `Self@__init__`
- Found 1712 diagnostics
+ Found 1703 diagnostics

hydpy (https://github.com/hydpy-dev/hydpy)
- hydpy/core/threadingtools.py:214:9: error[invalid-assignment] Cannot assign to final attribute `upstream2downstream` on type `Self@__init__`
- hydpy/core/threadingtools.py:215:9: error[invalid-assignment] Cannot assign to final attribute `starters` on type `Self@__init__`
- hydpy/core/threadingtools.py:216:9: error[invalid-assignment] Cannot assign to final attribute `dependencies` on type `Self@__init__`
- Found 665 diagnostics
+ Found 662 diagnostics

No memory usage changes detected ✅

@saada saada force-pushed the fix-final-init-1409 branch from 5577180 to 5d10a3e Compare October 31, 2025 03:34
Copy link
Contributor

@sharkdp sharkdp left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@AlexWaygood AlexWaygood added the ty Multi-file analysis & type inference label Oct 31, 2025
@AlexWaygood
Copy link
Member

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 = 56

This 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

@saada saada force-pushed the fix-final-init-1409 branch 3 times, most recently from ca0574f to 1af08d7 Compare October 31, 2025 14:33
@saada
Copy link
Contributor Author

saada commented Oct 31, 2025

Thanks for the feedback. After several iterations, I think this is the cleanest approach.

The Rust fix works by modifying the invalid_assignment_to_final closure to check whether we're currently inside an __init__ method before rejecting Final attribute assignments. Before the fix, ty was rejecting all assignments to Final attributes regardless of context, which caused false positives for valid Python code like self.x: Final[int] followed by self.x = 1 in __init__. Per PEP 591, Final instance attributes must be initialized exactly once, and __init__ is the designated place for that initialization. The fix adds an is_in_init() helper that checks current_function_definition().name.id == "__init__", and only rejects Final assignments when this check is false.

saada added 2 commits October 31, 2025 12:07
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
@saada saada force-pushed the fix-final-init-1409 branch from cdc8462 to 5afa7af Compare October 31, 2025 16:09
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.
@carljm
Copy link
Contributor

carljm commented Oct 31, 2025

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.

I think we should probably just allow the first assignment to a Final-qualified attribute in __init__?

The conformance suite does not require this. It does require that we allow multiple conditional assignments in __init__ (and not only in cases where one of them is unreachable):

class C:
    x: Final[int]

    def __init__(self, cond: bool):
        if cond:
            self.x = 1
        else:
            self.x = 2

It appears that both mypy and pyright simply always allow multiple assignments in __init__. I think it's fine if we do that, too.

The other thing highlighted by the conformance suite results is that we should still error on assignment in __init__ if a value was already assigned at class level.

@saada saada force-pushed the fix-final-init-1409 branch from 5afa7af to 04234f9 Compare October 31, 2025 16:23
@saada
Copy link
Contributor Author

saada commented Oct 31, 2025

I checked the other type checkers and here's a summary of what was found

Our implementation matches closer to pyright!

Case Description ty pyright mypy Winner
Conditional assignments in init if cond: self.x=1 else: self.x=2 ✅ Allow ✅ Allow ❌ Error ty & pyright ✓
Reassign class-level Final in init Class has x: Final = 10, reassign in __init__ ❌ Error ❌ Error ✅ Allow ty & pyright ✓
Assignment in helper method Called from __init__ but different method ❌ Error ❌ Error ✅ Allow ty & pyright ✓
Nested conditionals Multiple if/else levels in __init__ ✅ Allow ✅ Allow ❌ Error ty & pyright ✓
Try/except assignments Assign in try and except blocks ✅ Allow ✅ Allow ❌ Error ty & pyright ✓
Uninitialized Final x: Final[int] with no __init__ or value ✅ Allow ❌ Error ❌ Error pyright & mypy ✓
Assignment after super() Assign before and after super().__init__() ✅ Allow ✅ Allow ❌ Error ty & pyright ✓
Bare Final with value x: Final = 1, reassign in __init__ ❌ Error ❌ Error ✅ Allow ty & 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
@saada saada force-pushed the fix-final-init-1409 branch from 07c18b4 to 7fcb32a Compare October 31, 2025 16:40
@carljm
Copy link
Contributor

carljm commented Oct 31, 2025

@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 __init__ even if they are not conditional at all.

And in the second row, you claim that pyright is OK with assignment in __init__ if there is a class-level value assigned, but that doesn't seem to be true either.

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.

@saada
Copy link
Contributor Author

saada commented Oct 31, 2025

@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 Solved

Your mypy playground link shows no error because it uses untyped parameters:

def __init__(self, cond):  # ← No type annotation

When 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 attribute

Comprehensive Comparison

Case 1: Sequential Assignments in __init__

class C:
    x: Final[int]
    def __init__(self):
        self.x = 1
        self.x = 2

Playground links: mypy ✅ | pyright ✅


Case 2: Conditional Assignments (untyped)

class C:
    x: Final[int]
    def __init__(self, cond):  # ← NO type annotation
        if cond:
            self.x = 1
        else:
            self.x = 2

Playground links: mypy ✅ | pyright ✅

Note: Mypy skips checking untyped function bodies by default. Pyright shows parameter type warnings but no Final assignment error.


Case 3: Conditional Assignments (typed)

class C:
    x: Final[int]
    def __init__(self, cond: bool):  # ← WITH type annotation
        if cond:
            self.x = 1
        else:
            self.x = 2

Playground links: mypy ❌ | pyright ✅

Conformance: Lines 57-59 in qualifiers_final_annotation.py have NO # E marker → must allow


Case 4: Class-Level Value Reassignment ⚠️

class C:
    x: Final[int] = 10  # ← Has value at class level
    def __init__(self):
        self.x = 20

Playground links: mypy ✅ WRONG | pyright ❌

Conformance: Line 54 in qualifiers_final_annotation.py has # E: Already initialized → must error


Summary Table

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.

@carljm
Copy link
Contributor

carljm commented Nov 4, 2025

Aha! Thank you for pointing out that my mypy result was due to not having any type annotations on the __init__ method. This always trips me up!

Copy link
Contributor

@carljm carljm left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for working on this! I agree with your conclusions above about the correct semantics.

- 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
@saada
Copy link
Contributor Author

saada commented Nov 6, 2025

Thank you for the thorough feedback! How does this look?

@carljm
Copy link
Contributor

carljm commented Nov 11, 2025

Looks good, thank you! I pushed a few simplifications, will merge once CI is green.

@carljm carljm merged commit 4373974 into astral-sh:main Nov 11, 2025
40 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ty Multi-file analysis & type inference

Projects

None yet

Development

Successfully merging this pull request may close these issues.

False positive when assigning to Final instance attribute in __init__

4 participants