[ty] Implement typing.final for methods (#21646)
Co-authored-by: Micha Reiser <micha@reiser.io>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# Tests for the `@typing(_extensions).final` decorator
|
||||
|
||||
## Cannot subclass
|
||||
## Cannot subclass a class decorated with `@final`
|
||||
|
||||
Don't do this:
|
||||
|
||||
@@ -29,3 +29,456 @@ class H(
|
||||
G,
|
||||
): ...
|
||||
```
|
||||
|
||||
## Cannot override a method decorated with `@final`
|
||||
|
||||
<!-- snapshot-diagnostics -->
|
||||
|
||||
```pyi
|
||||
from typing_extensions import final, Callable, TypeVar
|
||||
|
||||
def lossy_decorator(fn: Callable) -> Callable: ...
|
||||
|
||||
class Parent:
|
||||
@final
|
||||
def foo(self): ...
|
||||
|
||||
@final
|
||||
@property
|
||||
def my_property1(self) -> int: ...
|
||||
|
||||
@property
|
||||
@final
|
||||
def my_property2(self) -> int: ...
|
||||
|
||||
@final
|
||||
@classmethod
|
||||
def class_method1(cls) -> int: ...
|
||||
|
||||
@classmethod
|
||||
@final
|
||||
def class_method2(cls) -> int: ...
|
||||
|
||||
@final
|
||||
@staticmethod
|
||||
def static_method1() -> int: ...
|
||||
|
||||
@staticmethod
|
||||
@final
|
||||
def static_method2() -> int: ...
|
||||
|
||||
@lossy_decorator
|
||||
@final
|
||||
def decorated_1(self): ...
|
||||
|
||||
@final
|
||||
@lossy_decorator
|
||||
def decorated_2(self): ...
|
||||
|
||||
class Child(Parent):
|
||||
# explicitly test the concise diagnostic message,
|
||||
# which is different to the verbose diagnostic summary message:
|
||||
#
|
||||
# error: [override-of-final-method] "Cannot override final member `foo` from superclass `Parent`"
|
||||
def foo(self): ...
|
||||
@property
|
||||
def my_property1(self) -> int: ... # error: [override-of-final-method]
|
||||
|
||||
@property
|
||||
def my_property2(self) -> int: ... # error: [override-of-final-method]
|
||||
|
||||
@classmethod
|
||||
def class_method1(cls) -> int: ... # error: [override-of-final-method]
|
||||
|
||||
@staticmethod
|
||||
def static_method1() -> int: ... # error: [override-of-final-method]
|
||||
|
||||
@classmethod
|
||||
def class_method2(cls) -> int: ... # error: [override-of-final-method]
|
||||
|
||||
@staticmethod
|
||||
def static_method2() -> int: ... # error: [override-of-final-method]
|
||||
|
||||
def decorated_1(self): ... # TODO: should emit [override-of-final-method]
|
||||
|
||||
@lossy_decorator
|
||||
def decorated_2(self): ... # TODO: should emit [override-of-final-method]
|
||||
|
||||
class OtherChild(Parent): ...
|
||||
|
||||
class Grandchild(OtherChild):
|
||||
@staticmethod
|
||||
# TODO: we should emit a Liskov violation here too
|
||||
# error: [override-of-final-method]
|
||||
def foo(): ...
|
||||
@property
|
||||
# TODO: we should emit a Liskov violation here too
|
||||
# error: [override-of-final-method]
|
||||
def my_property1(self) -> str: ...
|
||||
# TODO: we should emit a Liskov violation here too
|
||||
# error: [override-of-final-method]
|
||||
class_method1 = None
|
||||
|
||||
# Diagnostic edge case: `final` is very far away from the method definition in the source code:
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
def identity(x: T) -> T: ...
|
||||
|
||||
class Foo:
|
||||
@final
|
||||
@identity
|
||||
@identity
|
||||
@identity
|
||||
@identity
|
||||
@identity
|
||||
@identity
|
||||
@identity
|
||||
@identity
|
||||
@identity
|
||||
@identity
|
||||
@identity
|
||||
@identity
|
||||
@identity
|
||||
@identity
|
||||
@identity
|
||||
@identity
|
||||
@identity
|
||||
@identity
|
||||
def bar(self): ...
|
||||
|
||||
class Baz(Foo):
|
||||
def bar(self): ... # error: [override-of-final-method]
|
||||
```
|
||||
|
||||
## Diagnostic edge case: superclass with `@final` method has the same name as the subclass
|
||||
|
||||
<!-- snapshot-diagnostics -->
|
||||
|
||||
`module1.py`:
|
||||
|
||||
```py
|
||||
from typing import final
|
||||
|
||||
class Foo:
|
||||
@final
|
||||
def f(self): ...
|
||||
```
|
||||
|
||||
`module2.py`:
|
||||
|
||||
```py
|
||||
import module1
|
||||
|
||||
class Foo(module1.Foo):
|
||||
def f(self): ... # error: [override-of-final-method]
|
||||
```
|
||||
|
||||
## Overloaded methods decorated with `@final`
|
||||
|
||||
In a stub file, `@final` should be applied to the first overload. In a runtime file, `@final` should
|
||||
only be applied to the implementation function.
|
||||
|
||||
<!-- snapshot-diagnostics -->
|
||||
|
||||
`stub.pyi`:
|
||||
|
||||
```pyi
|
||||
from typing import final, overload
|
||||
|
||||
class Good:
|
||||
@overload
|
||||
@final
|
||||
def bar(self, x: str) -> str: ...
|
||||
@overload
|
||||
def bar(self, x: int) -> int: ...
|
||||
|
||||
@final
|
||||
@overload
|
||||
def baz(self, x: str) -> str: ...
|
||||
@overload
|
||||
def baz(self, x: int) -> int: ...
|
||||
|
||||
class ChildOfGood(Good):
|
||||
@overload
|
||||
def bar(self, x: str) -> str: ...
|
||||
@overload
|
||||
def bar(self, x: int) -> int: ... # error: [override-of-final-method]
|
||||
|
||||
@overload
|
||||
def baz(self, x: str) -> str: ...
|
||||
@overload
|
||||
def baz(self, x: int) -> int: ... # error: [override-of-final-method]
|
||||
|
||||
class Bad:
|
||||
@overload
|
||||
def bar(self, x: str) -> str: ...
|
||||
@overload
|
||||
@final
|
||||
# error: [invalid-overload]
|
||||
def bar(self, x: int) -> int: ...
|
||||
|
||||
@overload
|
||||
def baz(self, x: str) -> str: ...
|
||||
@final
|
||||
@overload
|
||||
# error: [invalid-overload]
|
||||
def baz(self, x: int) -> int: ...
|
||||
|
||||
class ChildOfBad(Bad):
|
||||
@overload
|
||||
def bar(self, x: str) -> str: ...
|
||||
@overload
|
||||
def bar(self, x: int) -> int: ... # error: [override-of-final-method]
|
||||
|
||||
@overload
|
||||
def baz(self, x: str) -> str: ...
|
||||
@overload
|
||||
def baz(self, x: int) -> int: ... # error: [override-of-final-method]
|
||||
```
|
||||
|
||||
`main.py`:
|
||||
|
||||
```py
|
||||
from typing import overload, final
|
||||
|
||||
class Good:
|
||||
@overload
|
||||
def f(self, x: str) -> str: ...
|
||||
@overload
|
||||
def f(self, x: int) -> int: ...
|
||||
@final
|
||||
def f(self, x: int | str) -> int | str:
|
||||
return x
|
||||
|
||||
class ChildOfGood(Good):
|
||||
@overload
|
||||
def f(self, x: str) -> str: ...
|
||||
@overload
|
||||
def f(self, x: int) -> int: ...
|
||||
# error: [override-of-final-method]
|
||||
def f(self, x: int | str) -> int | str:
|
||||
return x
|
||||
|
||||
class Bad:
|
||||
@overload
|
||||
@final
|
||||
def f(self, x: str) -> str: ...
|
||||
@overload
|
||||
def f(self, x: int) -> int: ...
|
||||
# error: [invalid-overload]
|
||||
def f(self, x: int | str) -> int | str:
|
||||
return x
|
||||
|
||||
@final
|
||||
@overload
|
||||
def g(self, x: str) -> str: ...
|
||||
@overload
|
||||
def g(self, x: int) -> int: ...
|
||||
# error: [invalid-overload]
|
||||
def g(self, x: int | str) -> int | str:
|
||||
return x
|
||||
|
||||
@overload
|
||||
def h(self, x: str) -> str: ...
|
||||
@overload
|
||||
@final
|
||||
def h(self, x: int) -> int: ...
|
||||
# error: [invalid-overload]
|
||||
def h(self, x: int | str) -> int | str:
|
||||
return x
|
||||
|
||||
@overload
|
||||
def i(self, x: str) -> str: ...
|
||||
@final
|
||||
@overload
|
||||
def i(self, x: int) -> int: ...
|
||||
# error: [invalid-overload]
|
||||
def i(self, x: int | str) -> int | str:
|
||||
return x
|
||||
|
||||
class ChildOfBad(Bad):
|
||||
# TODO: these should all cause us to emit Liskov violations as well
|
||||
f = None # error: [override-of-final-method]
|
||||
g = None # error: [override-of-final-method]
|
||||
h = None # error: [override-of-final-method]
|
||||
i = None # error: [override-of-final-method]
|
||||
```
|
||||
|
||||
## Edge case: the function is decorated with `@final` but originally defined elsewhere
|
||||
|
||||
As of 2025-11-26, pyrefly emits a diagnostic on this, but mypy and pyright do not. For mypy and
|
||||
pyright to emit a diagnostic, the superclass definition decorated with `@final` must be a literal
|
||||
function definition: an assignment definition where the right-hand side of the assignment is a
|
||||
`@final-decorated` function is not sufficient for them to consider the superclass definition as
|
||||
being `@final`.
|
||||
|
||||
For now, we choose to follow mypy's and pyright's behaviour here, in order to maximise compatibility
|
||||
with other type checkers. We may decide to change this in the future, however, as it would simplify
|
||||
our implementation. Mypy's and pyright's behaviour here is also arguably inconsistent with their
|
||||
treatment of other type qualifiers such as `Final`. As discussed in
|
||||
<https://discuss.python.org/t/imported-final-variable/82429>, both type checkers view the `Final`
|
||||
type qualifier as travelling *across* scopes.
|
||||
|
||||
```py
|
||||
from typing import final
|
||||
|
||||
class A:
|
||||
@final
|
||||
def method(self) -> None: ...
|
||||
|
||||
class B:
|
||||
method = A.method
|
||||
|
||||
class C(B):
|
||||
def method(self) -> None: ... # no diagnostic here (see prose discussion above)
|
||||
```
|
||||
|
||||
## Constructor methods are also checked
|
||||
|
||||
```py
|
||||
from typing import final
|
||||
|
||||
class A:
|
||||
@final
|
||||
def __init__(self) -> None: ...
|
||||
|
||||
class B(A):
|
||||
def __init__(self) -> None: ... # error: [override-of-final-method]
|
||||
```
|
||||
|
||||
## Only the first `@final` violation is reported
|
||||
|
||||
(Don't do this.)
|
||||
|
||||
<!-- snapshot-diagnostics -->
|
||||
|
||||
```py
|
||||
from typing import final
|
||||
|
||||
class A:
|
||||
@final
|
||||
def f(self): ...
|
||||
|
||||
class B(A):
|
||||
@final
|
||||
def f(self): ... # error: [override-of-final-method]
|
||||
|
||||
class C(B):
|
||||
@final
|
||||
# we only emit one error here, not two
|
||||
def f(self): ... # error: [override-of-final-method]
|
||||
```
|
||||
|
||||
## For when you just really want to drive the point home
|
||||
|
||||
```py
|
||||
from typing import final, Final
|
||||
|
||||
@final
|
||||
@final
|
||||
@final
|
||||
@final
|
||||
@final
|
||||
@final
|
||||
class A:
|
||||
@final
|
||||
@final
|
||||
@final
|
||||
@final
|
||||
@final
|
||||
def method(self): ...
|
||||
|
||||
@final
|
||||
@final
|
||||
@final
|
||||
@final
|
||||
@final
|
||||
class B:
|
||||
method: Final = A.method
|
||||
|
||||
class C(A): # error: [subclass-of-final-class]
|
||||
def method(self): ... # error: [override-of-final-method]
|
||||
|
||||
class D(B): # error: [subclass-of-final-class]
|
||||
# TODO: we should emit a diagnostic here
|
||||
def method(self): ...
|
||||
```
|
||||
|
||||
## An `@final` method is overridden by an implicit instance attribute
|
||||
|
||||
```py
|
||||
from typing import final, Any
|
||||
|
||||
class Parent:
|
||||
@final
|
||||
def method(self) -> None: ...
|
||||
|
||||
class Child(Parent):
|
||||
def __init__(self) -> None:
|
||||
self.method: Any = 42 # TODO: we should emit `[override-of-final-method]` here
|
||||
```
|
||||
|
||||
## A possibly-undefined `@final` method is overridden
|
||||
|
||||
<!-- snapshot-diagnostics -->
|
||||
|
||||
```py
|
||||
from typing import final
|
||||
|
||||
def coinflip() -> bool:
|
||||
return False
|
||||
|
||||
class A:
|
||||
if coinflip():
|
||||
@final
|
||||
def method1(self) -> None: ...
|
||||
else:
|
||||
def method1(self) -> None: ...
|
||||
|
||||
if coinflip():
|
||||
def method2(self) -> None: ...
|
||||
else:
|
||||
@final
|
||||
def method2(self) -> None: ...
|
||||
|
||||
if coinflip():
|
||||
@final
|
||||
def method3(self) -> None: ...
|
||||
else:
|
||||
@final
|
||||
def method3(self) -> None: ...
|
||||
|
||||
if coinflip():
|
||||
def method4(self) -> None: ...
|
||||
elif coinflip():
|
||||
@final
|
||||
def method4(self) -> None: ...
|
||||
else:
|
||||
def method4(self) -> None: ...
|
||||
|
||||
class B(A):
|
||||
def method1(self) -> None: ... # error: [override-of-final-method]
|
||||
def method2(self) -> None: ... # error: [override-of-final-method]
|
||||
def method3(self) -> None: ... # error: [override-of-final-method]
|
||||
def method4(self) -> None: ... # error: [override-of-final-method]
|
||||
|
||||
# Possible overrides of possibly `@final` methods...
|
||||
class C(A):
|
||||
if coinflip():
|
||||
# TODO: the autofix here introduces invalid syntax because there are now no
|
||||
# statements inside the `if:` branch
|
||||
# (but it might still be a useful autofix in an IDE context?)
|
||||
def method1(self) -> None: ... # error: [override-of-final-method]
|
||||
else:
|
||||
pass
|
||||
|
||||
if coinflip():
|
||||
def method2(self) -> None: ... # TODO: should emit [override-of-final-method]
|
||||
else:
|
||||
def method2(self) -> None: ... # TODO: should emit [override-of-final-method]
|
||||
|
||||
if coinflip():
|
||||
def method3(self) -> None: ... # error: [override-of-final-method]
|
||||
def method4(self) -> None: ... # error: [override-of-final-method]
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user