Skip to content

Commit da7f33a

Browse files
[ty] Add a diagnostic for Final without assignment (#23001)
## Summary Closes astral-sh/ty#872.
1 parent e65f9a6 commit da7f33a

File tree

7 files changed

+479
-117
lines changed

7 files changed

+479
-117
lines changed

crates/ty/docs/rules.md

Lines changed: 124 additions & 91 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/ty_python_semantic/resources/mdtest/generics/pep695/variance.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -468,7 +468,7 @@ class A: ...
468468
class B(A): ...
469469

470470
class C[T]:
471-
x: Final[T]
471+
x: Final[T] # error: [final-without-value]
472472

473473
static_assert(is_subtype_of(C[B], C[A]))
474474
static_assert(not is_subtype_of(C[A], C[B]))

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/type_qualifiers/final.md
2323
8 | from _stat import ST_INO
2424
9 |
2525
10 | ST_INO = 1 # error: [invalid-assignment]
26+
11 | from typing import Final
27+
12 |
28+
13 | UNINITIALIZED: Final[int] # error: [final-without-value]
2629
```
2730

2831
# Diagnostics
@@ -54,7 +57,21 @@ error[invalid-assignment]: Reassignment of `Final` symbol `ST_INO` is not allowe
5457
9 |
5558
10 | ST_INO = 1 # error: [invalid-assignment]
5659
| ^^^^^^^^^^ Reassignment of `Final` symbol
60+
11 | from typing import Final
5761
|
5862
info: rule `invalid-assignment` is enabled by default
5963
6064
```
65+
66+
```
67+
error[final-without-value]: `Final` symbol `UNINITIALIZED` is not assigned a value
68+
--> src/mdtest_snippet.py:13:1
69+
|
70+
11 | from typing import Final
71+
12 |
72+
13 | UNINITIALIZED: Final[int] # error: [final-without-value]
73+
| ^^^^^^^^^^^^^^^^^^^^^^^^^
74+
|
75+
info: rule `final-without-value` is enabled by default
76+
77+
```

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

Lines changed: 143 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -463,23 +463,149 @@ DECLARED_THEN_BOUND = 1
463463
```py
464464
from typing import Final
465465

466-
# TODO: This should be an error
467-
NO_ASSIGNMENT_A: Final
468-
# TODO: This should be an error
469-
NO_ASSIGNMENT_B: Final[int]
466+
NO_ASSIGNMENT_A: Final # error: [final-without-value] "`Final` symbol `NO_ASSIGNMENT_A` is not assigned a value"
467+
NO_ASSIGNMENT_B: Final[int] # error: [final-without-value] "`Final` symbol `NO_ASSIGNMENT_B` is not assigned a value"
470468

471469
class C:
472-
# TODO: This should be an error
473-
NO_ASSIGNMENT_A: Final
474-
# TODO: This should be an error
475-
NO_ASSIGNMENT_B: Final[int]
470+
NO_ASSIGNMENT_A: Final # error: [final-without-value] "`Final` symbol `NO_ASSIGNMENT_A` is not assigned a value"
471+
NO_ASSIGNMENT_B: Final[int] # error: [final-without-value] "`Final` symbol `NO_ASSIGNMENT_B` is not assigned a value"
476472

477473
DEFINED_IN_INIT: Final[int]
478474

479475
def __init__(self):
480476
self.DEFINED_IN_INIT = 1
481477
```
482478

479+
### Function-local `Final` without value
480+
481+
```py
482+
from typing import Final
483+
484+
def f():
485+
x: Final[int] # error: [final-without-value] "`Final` symbol `x` is not assigned a value"
486+
```
487+
488+
### `typing_extensions.Final` without value
489+
490+
```py
491+
from typing_extensions import Final
492+
493+
TEXF_NO_VALUE: Final[str] # error: [final-without-value] "`Final` symbol `TEXF_NO_VALUE` is not assigned a value"
494+
```
495+
496+
### `Annotated[Final[...], ...]` without value
497+
498+
```py
499+
from typing import Annotated, Final
500+
501+
ANNOTATED_FINAL: Annotated[ # error: [final-without-value] "`Final` symbol `ANNOTATED_FINAL` is not assigned a value"
502+
Final[int], "metadata"
503+
]
504+
```
505+
506+
### Imported `Final` symbol
507+
508+
Importing a symbol that is declared `Final` in its source module should not trigger
509+
`final-without-value`, because the import itself provides the binding.
510+
511+
`module.py`:
512+
513+
```py
514+
from typing import Final
515+
516+
MODULE_FINAL: Final[int] = 1
517+
```
518+
519+
`test.py`:
520+
521+
```py
522+
from module import MODULE_FINAL
523+
```
524+
525+
Even if the imported symbol is later deleted (a common pattern to clean up module namespaces), it
526+
should not trigger the diagnostic.
527+
528+
`test_del.py`:
529+
530+
```py
531+
from module import MODULE_FINAL
532+
533+
_ = MODULE_FINAL
534+
535+
del MODULE_FINAL
536+
```
537+
538+
### Stub file `Final` without value
539+
540+
In stub files, `Final` declarations without a value are permitted, at both module and class scope.
541+
542+
`stub.pyi`:
543+
544+
```pyi
545+
from typing import Final
546+
547+
STUB_FINAL: Final[int]
548+
549+
class StubClass:
550+
STUB_ATTR: Final[str]
551+
```
552+
553+
### Conditional assignment in `__init__`
554+
555+
A `Final` attribute declared in the class body and conditionally assigned in `__init__` should not
556+
trigger `final-without-value`, since at least one path provides a binding.
557+
558+
```py
559+
from typing import Final
560+
561+
class C:
562+
x: Final[int]
563+
564+
def __init__(self, flag: bool):
565+
if flag:
566+
self.x = 1
567+
else:
568+
self.x = 2
569+
570+
class D:
571+
y: Final[int]
572+
573+
def __init__(self, flag: bool):
574+
if flag:
575+
self.y = 1
576+
# No else: y may be unbound at runtime, but there is still an assignment path
577+
```
578+
579+
### Assignment in non-`__init__` method
580+
581+
Per the typing spec, a `Final` attribute declared in a class body without a value must be
582+
initialized in `__init__`. Assignment in other methods does not satisfy the requirement.
583+
584+
```py
585+
from typing import Final
586+
587+
class E:
588+
x: Final[int] # error: [final-without-value] "`Final` symbol `x` is not assigned a value"
589+
590+
def setup(self):
591+
# error: [invalid-assignment] "Cannot assign to final attribute `x`"
592+
self.x = 1 # Too late: not __init__
593+
```
594+
595+
### Dataclass with `Final` field
596+
597+
Dataclass-like classes do not report `final-without-value` because the `__init__` is synthesized by
598+
the framework.
599+
600+
```py
601+
from dataclasses import dataclass
602+
from typing import Final
603+
604+
@dataclass
605+
class D:
606+
x: Final[int] # No error: dataclass generates __init__
607+
```
608+
483609
## Final attributes with Self annotation in `__init__`
484610

485611
Issue #1409: Final instance attributes should be assignable in `__init__` even when using `Self`
@@ -566,7 +692,7 @@ class DeclareAndAssignInInit:
566692

567693
# Case 6: Assignment outside __init__ should still fail
568694
class AssignmentOutsideInit:
569-
attr6: Final[int]
695+
attr6: Final[int] # error: [final-without-value] "`Final` symbol `attr6` is not assigned a value"
570696

571697
def other_method(self):
572698
# error: [invalid-assignment] "Cannot assign to final attribute `attr6`"
@@ -634,4 +760,12 @@ from _stat import ST_INO
634760
ST_INO = 1 # error: [invalid-assignment]
635761
```
636762

763+
`Final` declaration without value:
764+
765+
```py
766+
from typing import Final
767+
768+
UNINITIALIZED: Final[int] # error: [final-without-value]
769+
```
770+
637771
[`typing.final`]: https://docs.python.org/3/library/typing.html#typing.Final

crates/ty_python_semantic/src/types/diagnostic.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ pub(crate) fn register_lints(registry: &mut LintRegistryBuilder) {
117117
registry.register_lint(&SUBCLASS_OF_FINAL_CLASS);
118118
registry.register_lint(&OVERRIDE_OF_FINAL_METHOD);
119119
registry.register_lint(&INEFFECTIVE_FINAL);
120+
registry.register_lint(&FINAL_WITHOUT_VALUE);
120121
registry.register_lint(&ABSTRACT_METHOD_IN_FINAL_CLASS);
121122
registry.register_lint(&TYPE_ASSERTION_FAILURE);
122123
registry.register_lint(&ASSERT_TYPE_UNSPELLABLE_SUBTYPE);
@@ -2056,6 +2057,33 @@ declare_lint! {
20562057
}
20572058
}
20582059

2060+
declare_lint! {
2061+
/// ## What it does
2062+
/// Checks for `Final` symbols that are declared without a value and are never
2063+
/// assigned a value in their scope.
2064+
///
2065+
/// ## Why is this bad?
2066+
/// A `Final` symbol must be initialized with a value at the time of declaration
2067+
/// or in a subsequent assignment. At module or function scope, the assignment must
2068+
/// occur in the same scope. In a class body, the assignment may occur in `__init__`.
2069+
///
2070+
/// ## Examples
2071+
/// ```python
2072+
/// from typing import Final
2073+
///
2074+
/// # Error: `Final` symbol without a value
2075+
/// MY_CONSTANT: Final[int]
2076+
///
2077+
/// # OK: `Final` symbol with a value
2078+
/// MY_CONSTANT: Final[int] = 1
2079+
/// ```
2080+
pub(crate) static FINAL_WITHOUT_VALUE = {
2081+
summary: "detects `Final` declarations without a value",
2082+
status: LintStatus::stable("0.0.15"),
2083+
default_level: Level::Error,
2084+
}
2085+
}
2086+
20592087
declare_lint! {
20602088
/// ## What it does
20612089
/// Checks for `@final` classes that have unimplemented abstract methods.

0 commit comments

Comments
 (0)