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

13 KiB

Tests for the @typing(_extensions).final decorator

Cannot subclass a class decorated with @final

Don't do this:

import typing_extensions
from typing import final

@final
class A: ...

class B(A): ...  # error: 9 [subclass-of-final-class] "Class `B` cannot inherit from final class `A`"

@typing_extensions.final
class C: ...

class D(C): ...  # error: [subclass-of-final-class]
class E: ...
class F: ...
class G: ...

# fmt: off
class H(
    E,
    F,
    A,  # error: [subclass-of-final-class]
    G,
): ...

Cannot override a method decorated with @final

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: ...

    @property
    @final
    def my_property3(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]
    @my_property2.setter
    def my_property2(self, x: int) -> None: ...

    @property
    def my_property3(self) -> int: ...  # error: [override-of-final-method]
    @my_property3.deleter
    def my_proeprty3(self) -> None: ...

    @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

module1.py:

from typing import final

class Foo:
    @final
    def f(self): ...

module2.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.

stub.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:

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.

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

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.)

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

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

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

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]

    # check that autofixes don't introduce invalid syntax
    # if there are multiple statements on one line
    #
    # TODO: we should emit a Liskov violation here too
    # error: [override-of-final-method]
    method4 = 42; unrelated = 56  # fmt: skip

# Possible overrides of possibly `@final` methods...
class C(A):
    if coinflip():
        def method1(self) -> None: ...  # error: [override-of-final-method]
    else:
        pass

    if coinflip():
        def method2(self) -> None: ...  # error: [override-of-final-method]
    else:
        def method2(self) -> None: ...

    if coinflip():
        def method3(self) -> None: ...  # error: [override-of-final-method]

    # TODO: we should emit Liskov violations here too:
    if coinflip():
        method4 = 42  # error: [override-of-final-method]
    else:
        method4 = 56

Definitions in statically known branches

[environment]
python-version = "3.10"
import sys
from typing_extensions import final

class Parent:
    if sys.version_info >= (3, 10):
        @final
        def foo(self) -> None: ...
        @final
        def foooo(self) -> None: ...
        @final
        def baaaaar(self) -> None: ...
    else:
        @final
        def bar(self) -> None: ...
        @final
        def baz(self) -> None: ...
        @final
        def spam(self) -> None: ...

class Child(Parent):
    def foo(self) -> None: ...  # error: [override-of-final-method]

    # The declaration on `Parent` is not reachable,
    # so this is fine
    def bar(self) -> None: ...

    if sys.version_info >= (3, 10):
        def foooo(self) -> None: ...  # error: [override-of-final-method]
        def baz(self) -> None: ...
    else:
        # Fine because this doesn't override any reachable definitions
        def foooo(self) -> None: ...
        # There are `@final` definitions being overridden here,
        # but the definitions that override them are unreachable
        def spam(self) -> None: ...
        def baaaaar(self) -> None: ...

Overloads in statically-known branches in stub files

[environment]
python-version = "3.10"
import sys
from typing_extensions import overload, final

class Foo:
    if sys.version_info >= (3, 10):
        @overload
        @final
        def method(self, x: int) -> int: ...
    else:
        @overload
        def method(self, x: int) -> int: ...
    @overload
    def method(self, x: str) -> str: ...

    if sys.version_info >= (3, 10):
        @overload
        def method2(self, x: int) -> int: ...
    else:
        @overload
        @final
        def method2(self, x: int) -> int: ...
    @overload
    def method2(self, x: str) -> str: ...

class Bar(Foo):
    @overload
    def method(self, x: int) -> int: ...
    @overload
    def method(self, x: str) -> str: ...  # error: [override-of-final-method]

    # This is fine: the only overload that is marked `@final`
    # is in a statically-unreachable branch
    @overload
    def method2(self, x: int) -> int: ...
    @overload
    def method2(self, x: str) -> str: ...