When a variable is marked Final
in one module and imported by another module, should the imported symbol also be treated as Final
? In other words, should the imported symbol inherit the Final
type qualifier and the behaviors associated with Final
?
Consider this example:
# mylib/submodule.py
from typing import Final
Pi: Final[float] = 3.14
# mylib/__init__.py
# This redundant import statement indicates that Pi
# should be re-exported from the top-level module.
# Should Pi be considered Final?
from .submodule import Pi as Pi
# test2.py
import mylib
mylib.Pi = 0 # Should this be allowed?
# test1.py
# Should Pi be considered Final?
from mylib import Pi
Pi = 0 # Should this be allowed?
from mylib import Pi as OtherPi
OtherPi = 0 # Should this be allowed?
I’d like to see if we can agree upon the intended behavior and amend the typing spec so it documents and mandates this behavior for type checkers. This question came up recently in this pyright issue.
I think there are several different behaviors we could settle upon:
- The Final type qualifier is never inherited when a symbol is imported.
- If a module imports a Final variable by name or through a wildcard import, the imported symbol inherits the Final type qualifier. Any attempt to assign a different value to this symbol should be flagged as an error by a type checker.
- A Final type qualifier is not inherited upon import except in cases covered by the rules defined in this section of the typing spec. These rules apply only to stub files and modules within a py.typed package. They do not apply to non-py.typed libraries or local imports.
I can make reasonable arguments for all three of these options, but I slightly prefer 2 over the others. It seems to me what most developers would expect, and it’s less complex and easier to explain than 3.
Pyright and mypy currently implement 2, although mypy does not consistently detect and report reassignment of Final variables. This appears to simply be a bug or missing feature in mypy.
# target.py
ten: Final = 10
# test.py
from target import ten
from target import ten as ten_renamed
# Pyright and mypy: cannot reassign ten because it is Final
ten = 1
# Pyright: cannot reassign ten_renamed because it is Final
# Mypy: cannot reassign ten because it is final
ten_renamed = 1
# Pyright: cannot reassign ten because it is Final
# Mypy: no error
from target import ten
# Pyright: cannot reassign ten because it is Final
# Mypy: no error
for ten in range(10):
pass
It looks like this difference can be explained by the fact that mypy doesn’t enforce Final in a number of situations where the typing spec indicates that it should.
from typing import Any, Final
z: Final[Any] = 1
from typing import List as z # Mypy allows this
for z in range(10): # Mypy allows this
pass
match 2:
case z: # Mypy allows this
pass
Which of the three options do you think we should adopt? Or is there another option that I haven’t considered?