Skip to content

Commit 929eb52

Browse files
[ty] Enforce Final attribute assignment rules for annotated and augmented writes (#23880)
## Summary Reject `Final` attribute writes that previously slipped through the `annotated-assignment` and `augmented-`assignment code paths, e.g.: ```python self.x: Final[int] = 1 ``` Closes astral-sh/ty#1888.
1 parent 34998be commit 929eb52

File tree

6 files changed

+522
-121
lines changed

6 files changed

+522
-121
lines changed

crates/ty_python_semantic/resources/mdtest/attributes.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,33 @@ class C:
256256
reveal_type(C().w) # revealed: Unknown | Weird
257257
```
258258

259+
#### Nested augmented assignments after narrowing
260+
261+
Augmented assignments to nested attributes (e.g., `self.inner.value += ...`) should work correctly
262+
after narrowing away `None` from the intermediate attribute. This is a regression test for a case
263+
where the combination of narrowing and augmented assignment on a nested attribute caused a false
264+
positive.
265+
266+
```py
267+
from unknown_module import unknown # error: [unresolved-import]
268+
269+
class Inner:
270+
value: int = 0
271+
272+
class Outer:
273+
def __init__(self) -> None:
274+
self.inner = None
275+
self.load()
276+
277+
def load(self) -> None:
278+
self.inner = Inner() if unknown else unknown
279+
280+
def update(self) -> None:
281+
if self.inner is None:
282+
return
283+
self.inner.value += unknown
284+
```
285+
259286
#### Attributes defined in tuple unpackings
260287

261288
```py

crates/ty_python_semantic/resources/mdtest/snapshots/final.md_-_`typing.Final`_-_Full_diagnostics_(174fdd8134fb325b).snap

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,21 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/type_qualifiers/final.md
2525
10 | ST_INO = 1 # error: [invalid-assignment]
2626
11 | from typing import Final
2727
12 |
28-
13 | UNINITIALIZED: Final[int] # error: [final-without-value]
28+
13 | class C:
29+
14 | x: Final[int] = 1
30+
15 |
31+
16 | def f(self):
32+
17 | self.x = 2 # error: [invalid-assignment]
33+
18 | from typing import Final
34+
19 |
35+
20 | class C:
36+
21 | x: Final[int] = 1
37+
22 |
38+
23 | def __init__(c: C):
39+
24 | c.x = 2 # error: [invalid-assignment]
40+
25 | from typing import Final
41+
26 |
42+
27 | UNINITIALIZED: Final[int] # error: [final-without-value]
2943
```
3044

3145
# Diagnostics
@@ -63,13 +77,39 @@ info: rule `invalid-assignment` is enabled by default
6377
6478
```
6579

80+
```
81+
error[invalid-assignment]: Cannot assign to final attribute `x` on type `Self@f`
82+
--> src/mdtest_snippet.py:17:9
83+
|
84+
16 | def f(self):
85+
17 | self.x = 2 # error: [invalid-assignment]
86+
| ^^^^^^ `Final` attributes can only be assigned in the class body or `__init__`
87+
18 | from typing import Final
88+
|
89+
info: rule `invalid-assignment` is enabled by default
90+
91+
```
92+
93+
```
94+
error[invalid-assignment]: Cannot assign to final attribute `x` on type `C`
95+
--> src/mdtest_snippet.py:24:5
96+
|
97+
23 | def __init__(c: C):
98+
24 | c.x = 2 # error: [invalid-assignment]
99+
| ^^^ `Final` attributes can only be assigned in the class body or `__init__`
100+
25 | from typing import Final
101+
|
102+
info: rule `invalid-assignment` is enabled by default
103+
104+
```
105+
66106
```
67107
error[final-without-value]: `Final` symbol `UNINITIALIZED` is not assigned a value
68-
--> src/mdtest_snippet.py:13:1
108+
--> src/mdtest_snippet.py:27:1
69109
|
70-
11 | from typing import Final
71-
12 |
72-
13 | UNINITIALIZED: Final[int] # error: [final-without-value]
110+
25 | from typing import Final
111+
26 |
112+
27 | UNINITIALIZED: Final[int] # error: [final-without-value]
73113
| ^^^^^^^^^^^^^^^^^^^^^^^^^
74114
|
75115
info: rule `final-without-value` is enabled by default

crates/ty_python_semantic/resources/mdtest/type_qualifiers/final.md

Lines changed: 159 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -261,11 +261,15 @@ class C(metaclass=Meta):
261261
C.META_FINAL_A = 2
262262
# error: [invalid-assignment] "Cannot assign to final attribute `META_FINAL_B` on type `<class 'C'>`"
263263
C.META_FINAL_B = 2
264+
# error: [invalid-assignment] "Cannot assign to final attribute `META_FINAL_A` on type `<class 'C'>`"
265+
C.META_FINAL_A += 1
264266

265267
# error: [invalid-assignment] "Cannot assign to final attribute `CLASS_FINAL_A` on type `<class 'C'>`"
266268
C.CLASS_FINAL_A = 2
267269
# error: [invalid-assignment] "Cannot assign to final attribute `CLASS_FINAL_B` on type `<class 'C'>`"
268270
C.CLASS_FINAL_B = 2
271+
# error: [invalid-assignment] "Cannot assign to final attribute `CLASS_FINAL_A` on type `<class 'C'>`"
272+
C.CLASS_FINAL_A += 1
269273

270274
c = C()
271275
# error: [invalid-assignment] "Cannot assign to final attribute `CLASS_FINAL_A` on type `C`"
@@ -278,6 +282,93 @@ c.INSTANCE_FINAL_A = 2
278282
c.INSTANCE_FINAL_B = 2
279283
# error: [invalid-assignment] "Cannot assign to final attribute `INSTANCE_FINAL_C` on type `C`"
280284
c.INSTANCE_FINAL_C = 2
285+
# error: [invalid-assignment] "Cannot assign to final attribute `INSTANCE_FINAL_A` on type `C`"
286+
c.INSTANCE_FINAL_A += 1
287+
```
288+
289+
### Attributes via indirections
290+
291+
`Final` attribute assignments are also detected through type aliases, `NewType`s, and type
292+
variables:
293+
294+
```toml
295+
[environment]
296+
python-version = "3.12"
297+
```
298+
299+
```py
300+
from typing import Final, NewType
301+
302+
class Foo:
303+
x: Final = 42
304+
305+
NT = NewType("NT", Foo)
306+
307+
n = NT(Foo())
308+
n.x = 42 # error: [invalid-assignment]
309+
310+
def f[T: Foo](x: T) -> T:
311+
x.x = 56 # error: [invalid-assignment]
312+
return x
313+
314+
type TA = Foo
315+
316+
def g(x: TA):
317+
x.x = 56 # error: [invalid-assignment]
318+
```
319+
320+
### Attributes on unions and intersections
321+
322+
When the object type is a union, we still detect `Final` attribute assignments for elements that
323+
declare the attribute as `Final`:
324+
325+
```py
326+
from typing import Final
327+
328+
class HasFinal:
329+
x: Final[int] = 42
330+
331+
class NotFinal:
332+
x: int = 42
333+
334+
def union_both_final(arg: HasFinal | HasFinal):
335+
arg.x = 1 # error: [invalid-assignment]
336+
337+
def union_one_final(arg: HasFinal | NotFinal):
338+
arg.x = 1 # error: [invalid-assignment]
339+
340+
def union_augmented(arg: HasFinal | NotFinal):
341+
# error: [invalid-assignment]
342+
arg.x += 1
343+
```
344+
345+
Intersections also detect `Final` attribute assignments:
346+
347+
```py
348+
from typing import Final
349+
from ty_extensions import Intersection
350+
351+
class HasFinal:
352+
x: Final[int] = 42
353+
354+
class NotFinal:
355+
x: int = 42
356+
357+
class Other:
358+
pass
359+
360+
def intersection_with_final(arg: Intersection[HasFinal, Other]):
361+
arg.x = 1 # error: [invalid-assignment]
362+
363+
def intersection_both_final(arg: Intersection[HasFinal, HasFinal]):
364+
arg.x = 1 # error: [invalid-assignment]
365+
366+
def intersection_one_final(arg: Intersection[HasFinal, NotFinal]):
367+
arg.x = 1 # error: [invalid-assignment]
368+
369+
def intersection_augmented(arg: Intersection[HasFinal, NotFinal]):
370+
# error: [invalid-assignment]
371+
arg.x += 1
281372
```
282373

283374
## Mutability
@@ -624,10 +715,31 @@ from typing import Final
624715

625716
class C:
626717
def some_method(self):
627-
# TODO: This should be an error
718+
# error: [invalid-assignment]
628719
self.x: Final[int] = 1
629720
```
630721

722+
### Protocol members
723+
724+
Assignments to `Final` protocol members are also invalid, both through a protocol-typed value and
725+
through `self` inside the protocol method body.
726+
727+
```py
728+
from typing import Final, Protocol
729+
730+
class Foo(Protocol):
731+
value: Final[int] = 42
732+
733+
def foo(self, value: int):
734+
# TODO: should emit an invalid-assignment error
735+
self.value = value
736+
737+
def bar(x: Foo, value: int):
738+
reveal_type(x.value) # revealed: int
739+
# error: [invalid-assignment] "Cannot assign to final attribute `value` on type `Foo`: `Final` attributes can only be assigned in the class body or `__init__`"
740+
x.value = value
741+
```
742+
631743
### Explicit `Final` redeclaration
632744

633745
Explicit `Final` redeclaration in the same scope is accepted (shadowing).
@@ -889,7 +1001,7 @@ python-version = "3.11"
8891001
```
8901002

8911003
```py
892-
from typing import Final, Self
1004+
from typing import Final, Generic, Self, TypeVar
8931005

8941006
class ClassA:
8951007
ID4: Final[int] # OK because initialized in __init__
@@ -907,8 +1019,17 @@ class ClassB:
9071019
def __init__(self): # Without Self annotation
9081020
self.ID5 = 1 # Should also be OK
9091021

1022+
T = TypeVar("T")
1023+
1024+
class ClassC(Generic[T]):
1025+
value: Final[T]
1026+
1027+
def __init__(self: Self, value: T):
1028+
self.value = value
1029+
9101030
reveal_type(ClassA().ID4) # revealed: int
9111031
reveal_type(ClassB().ID5) # revealed: int
1032+
reveal_type(ClassC(1).value) # revealed: int
9121033
```
9131034

9141035
## Reassignment to Final in `__init__`
@@ -1003,9 +1124,18 @@ class D:
10031124

10041125
def __init__(self, other: "D"):
10051126
self.y = 1 # OK: Assigning to self
1006-
# TODO: Should error - assigning to non-self parameter
1007-
# Requires tracking which parameter the base expression refers to
1008-
other.y = 2
1127+
# error: [invalid-assignment] "Cannot assign to final attribute `y`"
1128+
other.y = 2 # Error: not the implicit `self` parameter
1129+
1130+
# When the first parameter is not named `self`, only the first parameter
1131+
# is treated as the implicit receiver.
1132+
class E:
1133+
x: Final[int]
1134+
1135+
def __init__(sssself, self: "E"):
1136+
sssself.x = 1 # OK: first parameter is the implicit receiver
1137+
# error: [invalid-assignment] "Cannot assign to final attribute `x`"
1138+
self.x = 2 # Error: `self` is the second parameter, not the implicit receiver
10091139
```
10101140

10111141
## Full diagnostics
@@ -1032,6 +1162,30 @@ from _stat import ST_INO
10321162
ST_INO = 1 # error: [invalid-assignment]
10331163
```
10341164

1165+
Instance attribute assignment outside `__init__`:
1166+
1167+
```py
1168+
from typing import Final
1169+
1170+
class C:
1171+
x: Final[int] = 1
1172+
1173+
def f(self):
1174+
self.x = 2 # error: [invalid-assignment]
1175+
```
1176+
1177+
Standalone function named `__init__`:
1178+
1179+
```py
1180+
from typing import Final
1181+
1182+
class C:
1183+
x: Final[int] = 1
1184+
1185+
def __init__(c: C):
1186+
c.x = 2 # error: [invalid-assignment]
1187+
```
1188+
10351189
`Final` declaration without value:
10361190

10371191
```py

crates/ty_python_semantic/src/types/function.rs

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1446,16 +1446,13 @@ fn is_instance_truthiness<'db>(
14461446
class: ClassLiteral<'db>,
14471447
) -> Truthiness {
14481448
let is_instance = |ty: &Type<'_>| {
1449-
if let Type::NominalInstance(instance) = ty
1450-
&& instance
1449+
ty.as_nominal_instance().is_some_and(|instance| {
1450+
instance
14511451
.class(db)
14521452
.iter_mro(db)
14531453
.filter_map(ClassBase::into_class)
1454-
.any(|c| c.class_literal(db) == class)
1455-
{
1456-
return true;
1457-
}
1458-
false
1454+
.any(|mro_class| mro_class.class_literal(db) == class)
1455+
})
14591456
};
14601457

14611458
let always_true_if = |test: bool| {

0 commit comments

Comments
 (0)