[ty] Enforce typing.Final (#19178)

## Summary

Emit a diagnostic when a `Final`-qualified symbol is modified. This
first iteration only works for name targets. Tests with TODO comments
were added for attribute assignments as well.

related ticket: https://github.com/astral-sh/ty/issues/158

## Ecosystem impact

Correctly identified [modification of a `Final`
symbol](7b4164a5f2/sphinx/__init__.py (L44))
(behind a `# type: ignore`):
```diff
- warning[unused-ignore-comment] sphinx/__init__.py:44:56: Unused blanket `type: ignore` directive
```
And the same
[here](5471a37e82/src/trio/_core/_run.py (L128)):
```diff
- warning[unused-ignore-comment] src/trio/_core/_run.py:128:45: Unused blanket `type: ignore` directive
```

## Test Plan

New Markdown tests
This commit is contained in:
David Peter 2025-07-08 16:26:09 +02:00 committed by GitHub
parent 6a42d28867
commit 149350bf39
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 252 additions and 40 deletions

View file

@ -0,0 +1,42 @@
---
source: crates/ty_test/src/lib.rs
expression: snapshot
---
---
mdtest name: final.md - `typing.Final` - Full diagnostics
mdtest path: crates/ty_python_semantic/resources/mdtest/type_qualifiers/final.md
---
# Python source files
## mdtest_snippet.py
```
1 | from typing import Final
2 |
3 | MY_CONSTANT: Final[int] = 1
4 |
5 | # more code
6 |
7 | MY_CONSTANT = 2 # error: [invalid-assignment]
```
# Diagnostics
```
error[invalid-assignment]: Reassignment of `Final` symbol `MY_CONSTANT` is not allowed
--> src/mdtest_snippet.py:3:1
|
1 | from typing import Final
2 |
3 | MY_CONSTANT: Final[int] = 1
| ----------- Original definition
4 |
5 | # more code
6 |
7 | MY_CONSTANT = 2 # error: [invalid-assignment]
| ^^^^^^^^^^^ Reassignment of `Final` symbol
|
info: rule `invalid-assignment` is enabled by default
```

View file

@ -100,9 +100,13 @@ reveal_type(C().FINAL_D) # revealed: Unknown
## Not modifiable
### Names
Symbols qualified with `Final` cannot be reassigned, and attempting to do so will result in an
error:
`mod.py`:
```py
from typing import Final, Annotated
@ -114,13 +118,97 @@ FINAL_E: Final[int]
FINAL_E = 1
FINAL_F: Final = 1
# TODO: all of these should be errors
FINAL_A = 2
FINAL_B = 2
FINAL_C = 2
FINAL_D = 2
FINAL_E = 2
FINAL_F = 2
FINAL_A = 2 # error: [invalid-assignment] "Reassignment of `Final` symbol `FINAL_A` is not allowed"
FINAL_B = 2 # error: [invalid-assignment] "Reassignment of `Final` symbol `FINAL_B` is not allowed"
FINAL_C = 2 # error: [invalid-assignment] "Reassignment of `Final` symbol `FINAL_C` is not allowed"
FINAL_D = 2 # error: [invalid-assignment] "Reassignment of `Final` symbol `FINAL_D` is not allowed"
FINAL_E = 2 # error: [invalid-assignment] "Reassignment of `Final` symbol `FINAL_E` is not allowed"
FINAL_F = 2 # error: [invalid-assignment] "Reassignment of `Final` symbol `FINAL_F` is not allowed"
def global_use():
global FINAL_A, FINAL_B, FINAL_C, FINAL_D, FINAL_E, FINAL_F
FINAL_A = 2 # error: [invalid-assignment] "Reassignment of `Final` symbol `FINAL_A` is not allowed"
FINAL_B = 2 # error: [invalid-assignment] "Reassignment of `Final` symbol `FINAL_B` is not allowed"
FINAL_C = 2 # error: [invalid-assignment] "Reassignment of `Final` symbol `FINAL_C` is not allowed"
FINAL_D = 2 # error: [invalid-assignment] "Reassignment of `Final` symbol `FINAL_D` is not allowed"
FINAL_E = 2 # error: [invalid-assignment] "Reassignment of `Final` symbol `FINAL_E` is not allowed"
FINAL_F = 2 # error: [invalid-assignment] "Reassignment of `Final` symbol `FINAL_F` is not allowed"
def local_use():
# These are not errors, because they refer to local variables
FINAL_A = 2
FINAL_B = 2
FINAL_C = 2
FINAL_D = 2
FINAL_E = 2
FINAL_F = 2
def nonlocal_use():
X: Final[int] = 1
def inner():
nonlocal X
# TODO: this should be an error
X = 2
```
`main.py`:
```py
from mod import FINAL_A, FINAL_B, FINAL_C, FINAL_D, FINAL_E, FINAL_F
FINAL_A = 2 # error: [invalid-assignment] "Reassignment of `Final` symbol `FINAL_A` is not allowed"
FINAL_B = 2 # error: [invalid-assignment] "Reassignment of `Final` symbol `FINAL_B` is not allowed"
FINAL_C = 2 # error: [invalid-assignment] "Reassignment of `Final` symbol `FINAL_C` is not allowed"
FINAL_D = 2 # error: [invalid-assignment] "Reassignment of `Final` symbol `FINAL_D` is not allowed"
FINAL_E = 2 # error: [invalid-assignment] "Reassignment of `Final` symbol `FINAL_E` is not allowed"
FINAL_F = 2 # error: [invalid-assignment] "Reassignment of `Final` symbol `FINAL_F` is not allowed"
```
### Attributes
Assignments to attributes qualified with `Final` are also not allowed:
```py
from typing import Final
class C:
FINAL_A: Final[int] = 1
FINAL_B: Final = 1
def __init__(self):
self.FINAL_C: Final[int] = 1
self.FINAL_D: Final = 1
# TODO: these should be errors (that mention `Final`)
C.FINAL_A = 2
# error: [invalid-assignment] "Object of type `Literal[2]` is not assignable to attribute `FINAL_B` of type `Literal[1]`"
C.FINAL_B = 2
# TODO: these should be errors (that mention `Final`)
c = C()
c.FINAL_A = 2
# error: [invalid-assignment] "Object of type `Literal[2]` is not assignable to attribute `FINAL_B` of type `Literal[1]`"
c.FINAL_B = 2
c.FINAL_C = 2
c.FINAL_D = 2
```
## Mutability
Objects qualified with `Final` *can be modified*. `Final` represents a constant reference to an
object, but that object itself may still be mutable:
```py
from typing import Final
class C:
x: int = 1
FINAL_C_INSTANCE: Final[C] = C()
FINAL_C_INSTANCE.x = 2
FINAL_LIST: Final[list[int]] = [1, 2, 3]
FINAL_LIST[0] = 4
```
## Too many arguments
@ -168,4 +256,18 @@ class C:
NO_RHS: Final
```
## Full diagnostics
<!-- snapshot-diagnostics -->
```py
from typing import Final
MY_CONSTANT: Final[int] = 1
# more code
MY_CONSTANT = 2 # error: [invalid-assignment]
```
[`typing.final`]: https://docs.python.org/3/library/typing.html#typing.Final