From ccb0ba14fe193fc8d83f3c2a82e0159365c2280f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Feb 2026 11:44:39 +0000 Subject: [PATCH 1/6] [ty] Add `final-on-non-method` diagnostic for `@final` on non-method functions Per the typing spec, `@final` is only valid on methods and classes. Using it on a module-level function or a nested function is an error. This adds a new `final-on-non-method` lint rule that detects this misuse and emits a diagnostic. This should cause the upstream conformance test `qualifiers_final_decorator.py` to pass, as the only previously missing check was for `@final` applied to non-method functions. https://claude.ai/code/session_015ajybUNEuqCYeVVnSsiFCS --- crates/ty/docs/rules.md | 228 ++++++++++-------- .../resources/mdtest/final.md | 47 ++++ .../src/types/diagnostic.rs | 27 +++ .../src/types/infer/builder.rs | 54 +++-- ty.schema.json | 10 + 5 files changed, 253 insertions(+), 113 deletions(-) diff --git a/crates/ty/docs/rules.md b/crates/ty/docs/rules.md index 178fb687ba991..f228e77ffbae6 100644 --- a/crates/ty/docs/rules.md +++ b/crates/ty/docs/rules.md @@ -8,7 +8,7 @@ Default level: error · Added in 0.0.13 · Related issues · -View source +View source @@ -49,7 +49,7 @@ class Derived(Base): # Error: `Derived` does not implement `method` Default level: warn · Added in 0.0.1-alpha.20 · Related issues · -View source +View source @@ -90,7 +90,7 @@ class SubProto(BaseProto, Protocol): Default level: error · Added in 0.0.14 · Related issues · -View source +View source @@ -157,7 +157,7 @@ def test(): -> "int": Default level: error · Preview (since 0.0.16) · Related issues · -View source +View source @@ -206,7 +206,7 @@ Foo.method() # Error: cannot call abstract classmethod Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -230,7 +230,7 @@ Calling a non-callable object will raise a `TypeError` at runtime. Default level: error · Added in 0.0.7 · Related issues · -View source +View source @@ -261,7 +261,7 @@ def f(x: object): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -293,7 +293,7 @@ f(int) # error Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -324,7 +324,7 @@ a = 1 Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -356,7 +356,7 @@ class C(A, B): ... Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -388,7 +388,7 @@ class B(A): ... Default level: error · Added in 0.0.1-alpha.29 · Related issues · -View source +View source @@ -416,7 +416,7 @@ type B = A Default level: error · Preview (since 1.0.0) · Related issues · -View source +View source @@ -448,7 +448,7 @@ class Example: Default level: warn · Added in 0.0.1-alpha.16 · Related issues · -View source +View source @@ -475,7 +475,7 @@ old_func() # emits [deprecated] diagnostic Default level: ignore · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -504,7 +504,7 @@ false positives it can produce. Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -531,7 +531,7 @@ class B(A, A): ... Default level: error · Added in 0.0.1-alpha.12 · Related issues · -View source +View source @@ -569,7 +569,7 @@ class A: # Crash at runtime Default level: error · Added in 0.0.14 · Related issues · -View source +View source @@ -634,13 +634,45 @@ Static analysis tools like ty can't analyze type annotations that contain escape def foo() -> "intt\b": ... ``` +## `final-on-non-method` + + +Default level: error · +Added in 0.0.22 · +Related issues · +View source + + + +**What it does** + +Checks for `@final` decorators applied to non-method functions. + +**Why is this bad?** + +The `@final` decorator is only meaningful on methods (instance methods, +class methods, static methods) and classes. Applying it to a module-level +function or a nested function has no effect and is likely a mistake. + +**Example** + + +```python +from typing import final + +# Error: @final is not allowed on non-method functions +@final +def my_function() -> int: + return 0 +``` + ## `final-without-value` Default level: error · Added in 0.0.15 · Related issues · -View source +View source @@ -766,7 +798,7 @@ def test(): -> "Literal[5]": Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -796,7 +828,7 @@ class C(A, B): ... Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -822,7 +854,7 @@ t[3] # IndexError: tuple index out of range Default level: warn · Added in 0.0.1-alpha.33 · Related issues · -View source +View source @@ -856,7 +888,7 @@ class MyClass: ... Default level: error · Added in 0.0.1-alpha.12 · Related issues · -View source +View source @@ -945,7 +977,7 @@ an atypical memory layout. Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -972,7 +1004,7 @@ func("foo") # error: [invalid-argument-type] Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1000,7 +1032,7 @@ a: int = '' Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1034,7 +1066,7 @@ C.instance_var = 3 # error: Cannot assign to instance variable Default level: error · Added in 0.0.1-alpha.19 · Related issues · -View source +View source @@ -1070,7 +1102,7 @@ asyncio.run(main()) Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1094,7 +1126,7 @@ class A(42): ... # error: [invalid-base] Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1121,7 +1153,7 @@ with 1: Default level: error · Added in 0.0.12 · Related issues · -View source +View source @@ -1158,7 +1190,7 @@ class Foo(NamedTuple): Default level: error · Added in 0.0.13 · Related issues · -View source +View source @@ -1190,7 +1222,7 @@ class A: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1219,7 +1251,7 @@ a: str Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1263,7 +1295,7 @@ except ZeroDivisionError: Default level: error · Added in 0.0.1-alpha.28 · Related issues · -View source +View source @@ -1305,7 +1337,7 @@ class D(A): Default level: error · Added in 0.0.1-alpha.35 · Related issues · -View source +View source @@ -1349,7 +1381,7 @@ class NonFrozenChild(FrozenBase): # Error raised here Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1387,7 +1419,7 @@ class D(Generic[U, T]): ... Default level: error · Added in 0.0.12 · Related issues · -View source +View source @@ -1466,7 +1498,7 @@ a = 20 / 0 # type: ignore Default level: error · Added in 0.0.1-alpha.17 · Related issues · -View source +View source @@ -1505,7 +1537,7 @@ carol = Person(name="Carol", age=25) # typo! Default level: warn · Added in 0.0.15 · Related issues · -View source +View source @@ -1566,7 +1598,7 @@ def f(x, y, /): # Python 3.8+ syntax Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1601,7 +1633,7 @@ def f(t: TypeVar("U")): ... Default level: error · Added in 0.0.18 · Related issues · -View source +View source @@ -1629,7 +1661,7 @@ match x: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1663,7 +1695,7 @@ class B(metaclass=f): ... Default level: error · Added in 0.0.1-alpha.20 · Related issues · -View source +View source @@ -1770,7 +1802,7 @@ Correct use of `@override` is enforced by ty's `invalid-explicit-override` rule. Default level: error · Added in 0.0.1-alpha.19 · Related issues · -View source +View source @@ -1824,7 +1856,7 @@ AttributeError: Cannot overwrite NamedTuple attribute _asdict Default level: error · Added in 0.0.1-alpha.27 · Related issues · -View source +View source @@ -1854,7 +1886,7 @@ Baz = NewType("Baz", int | str) # error: invalid base for `typing.NewType` Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1904,7 +1936,7 @@ def foo(x: int) -> int: ... Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1930,7 +1962,7 @@ def f(a: int = ''): ... Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1961,7 +1993,7 @@ P2 = ParamSpec("S2") # error: ParamSpec name must match the variable it's assig Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1995,7 +2027,7 @@ TypeError: Protocols can only inherit from other protocols, got Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2044,7 +2076,7 @@ def g(): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2073,7 +2105,7 @@ def func() -> int: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2169,7 +2201,7 @@ class C: ... Default level: error · Added in 0.0.10 · Related issues · -View source +View source @@ -2215,7 +2247,7 @@ class MyClass: Default level: error · Added in 0.0.1-alpha.6 · Related issues · -View source +View source @@ -2242,7 +2274,7 @@ NewAlias = TypeAliasType(get_name(), int) # error: TypeAliasType name mus Default level: error · Added in 0.0.1-alpha.29 · Related issues · -View source +View source @@ -2289,7 +2321,7 @@ Bar[int] # error: too few arguments Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2319,7 +2351,7 @@ TYPE_CHECKING = '' Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2349,7 +2381,7 @@ b: Annotated[int] # `Annotated` expects at least two arguments Default level: error · Added in 0.0.1-alpha.11 · Related issues · -View source +View source @@ -2383,7 +2415,7 @@ f(10) # Error Default level: error · Added in 0.0.1-alpha.11 · Related issues · -View source +View source @@ -2417,7 +2449,7 @@ class C: Default level: error · Added in 0.0.15 · Related issues · -View source +View source @@ -2448,7 +2480,7 @@ def g[U, T: U](): ... # error: [invalid-type-variable-bound] Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2495,7 +2527,7 @@ U = TypeVar('U', list[int], int) # valid constrained Type Default level: error · Added in 0.0.16 · Related issues · -View source +View source @@ -2527,7 +2559,7 @@ U = TypeVar("U", int, str, default=bytes) # error: [invalid-type-variable-defau Default level: error · Added in 0.0.14 · Related issues · -View source +View source @@ -2562,7 +2594,7 @@ def f(x: dict): Default level: error · Added in 0.0.9 · Related issues · -View source +View source @@ -2593,7 +2625,7 @@ class Foo(TypedDict): Default level: error · Added in 0.0.14 · Related issues · -View source +View source @@ -2648,7 +2680,7 @@ def h(arg2: type): Default level: error · Added in 0.0.15 · Related issues · -View source +View source @@ -2691,7 +2723,7 @@ def g(arg: object): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2716,7 +2748,7 @@ func() # TypeError: func() missing 1 required positional argument: 'x' Default level: error · Added in 0.0.1-alpha.20 · Related issues · -View source +View source @@ -2749,7 +2781,7 @@ alice["age"] # KeyError Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2778,7 +2810,7 @@ func("string") # error: [no-matching-overload] Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2804,7 +2836,7 @@ for i in 34: # TypeError: 'int' object is not iterable Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2828,7 +2860,7 @@ Subscripting an object that does not support it will raise a `TypeError` at runt Default level: error · Added in 0.0.1-alpha.29 · Related issues · -View source +View source @@ -2861,7 +2893,7 @@ class B(A): Default level: error · Added in 0.0.16 · Related issues · -View source +View source @@ -2894,7 +2926,7 @@ class B(A): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2921,7 +2953,7 @@ f(1, x=2) # Error raised here Default level: error · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -2948,7 +2980,7 @@ f(x=1) # Error raised here Default level: warn · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -2976,7 +3008,7 @@ A.c # AttributeError: type object 'A' has no attribute 'c' Default level: warn · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -3008,7 +3040,7 @@ A()[0] # TypeError: 'A' object is not subscriptable Default level: ignore · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -3045,7 +3077,7 @@ from module import a # ImportError: cannot import name 'a' from 'module' Default level: ignore · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3109,7 +3141,7 @@ def test(): -> "int": Default level: warn · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3136,7 +3168,7 @@ cast(int, f()) # Redundant Default level: warn · Added in 0.0.18 · Related issues · -View source +View source @@ -3168,7 +3200,7 @@ class C: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3198,7 +3230,7 @@ static_assert(int(2.0 * 3.0) == 6) # error: does not have a statically known tr Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3227,7 +3259,7 @@ class B(A): ... # Error raised here Default level: error · Added in 0.0.1-alpha.30 · Related issues · -View source +View source @@ -3261,7 +3293,7 @@ class F(NamedTuple): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3288,7 +3320,7 @@ f("foo") # Error raised here Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3316,7 +3348,7 @@ def _(x: int): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3362,7 +3394,7 @@ class A: Default level: warn · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3386,7 +3418,7 @@ reveal_type(1) # NameError: name 'reveal_type' is not defined Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3413,7 +3445,7 @@ f(x=1, y=2) # Error raised here Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3441,7 +3473,7 @@ A().foo # AttributeError: 'A' object has no attribute 'foo' Default level: warn · Added in 0.0.1-alpha.15 · Related issues · -View source +View source @@ -3499,7 +3531,7 @@ def g(): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3524,7 +3556,7 @@ import foo # ModuleNotFoundError: No module named 'foo' Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3549,7 +3581,7 @@ print(x) # NameError: name 'x' is not defined Default level: warn · Added in 0.0.1-alpha.7 · Related issues · -View source +View source @@ -3588,7 +3620,7 @@ class D(C): ... # error: [unsupported-base] Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3625,7 +3657,7 @@ b1 < b2 < b1 # exception raised here Default level: ignore · Added in 0.0.12 · Related issues · -View source +View source @@ -3666,7 +3698,7 @@ def factory(base: type[Base]) -> type: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3767,7 +3799,7 @@ to `false`. Default level: warn · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -3830,7 +3862,7 @@ def foo(x: int | str) -> int | str: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source diff --git a/crates/ty_python_semantic/resources/mdtest/final.md b/crates/ty_python_semantic/resources/mdtest/final.md index 1b6081908f339..c703689071dad 100644 --- a/crates/ty_python_semantic/resources/mdtest/final.md +++ b/crates/ty_python_semantic/resources/mdtest/final.md @@ -428,6 +428,53 @@ class D(B): # error: [subclass-of-final-class] def method(self): ... # error: [override-of-final-variable] ``` +## `@final` cannot be applied to non-method functions + +The `@final` decorator is only valid on methods and classes. Using it on a module-level or nested +function is an error. + +```py +from typing import final + +@final # error: [final-on-non-method] "`@final` cannot be applied to a non-method function `func1`" +def func1() -> int: + return 0 + +# Nested function decorated with `@final` is also invalid +def outer(): + @final # error: [final-on-non-method] + def inner() -> None: + pass + +# Using `typing_extensions.final` should also be detected +import typing_extensions + +@typing_extensions.final # error: [final-on-non-method] +def func2() -> None: + pass + +# `@final` on methods is fine (no error) +class MyClass: + @final + def method(self) -> None: + pass + + @final + @classmethod + def class_method(cls) -> None: + pass + + @final + @staticmethod + def static_method() -> None: + pass + +# `@final` on classes is fine (no error) +@final +class FinalClass: + pass +``` + ## An `@final` method is overridden by an implicit instance attribute ```py diff --git a/crates/ty_python_semantic/src/types/diagnostic.rs b/crates/ty_python_semantic/src/types/diagnostic.rs index 6fcfec2fcc7d4..ad34c0083badb 100644 --- a/crates/ty_python_semantic/src/types/diagnostic.rs +++ b/crates/ty_python_semantic/src/types/diagnostic.rs @@ -119,6 +119,7 @@ pub(crate) fn register_lints(registry: &mut LintRegistryBuilder) { registry.register_lint(&OVERRIDE_OF_FINAL_METHOD); registry.register_lint(&OVERRIDE_OF_FINAL_VARIABLE); registry.register_lint(&INEFFECTIVE_FINAL); + registry.register_lint(&FINAL_ON_NON_METHOD); registry.register_lint(&FINAL_WITHOUT_VALUE); registry.register_lint(&ABSTRACT_METHOD_IN_FINAL_CLASS); registry.register_lint(&CALL_ABSTRACT_METHOD); @@ -2162,6 +2163,32 @@ declare_lint! { } } +declare_lint! { + /// ## What it does + /// Checks for `@final` decorators applied to non-method functions. + /// + /// ## Why is this bad? + /// The `@final` decorator is only meaningful on methods (instance methods, + /// class methods, static methods) and classes. Applying it to a module-level + /// function or a nested function has no effect and is likely a mistake. + /// + /// ## Example + /// + /// ```python + /// from typing import final + /// + /// # Error: @final is not allowed on non-method functions + /// @final + /// def my_function() -> int: + /// return 0 + /// ``` + pub(crate) static FINAL_ON_NON_METHOD = { + summary: "detects `@final` applied to non-method functions", + status: LintStatus::stable("0.0.22"), + default_level: Level::Error, + } +} + declare_lint! { /// ## What it does /// Checks for `Final` symbols that are declared without a value and are never diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index cc66d51609d0e..7bccdd5597502 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -73,21 +73,21 @@ use crate::types::diagnostic::{ self, ABSTRACT_METHOD_IN_FINAL_CLASS, CALL_NON_CALLABLE, CONFLICTING_DECLARATIONS, CONFLICTING_METACLASS, CYCLIC_CLASS_DEFINITION, CYCLIC_TYPE_ALIAS_DEFINITION, DATACLASS_FIELD_ORDER, DIVISION_BY_ZERO, DUPLICATE_BASE, DUPLICATE_KW_ONLY, - FINAL_WITHOUT_VALUE, INCONSISTENT_MRO, INEFFECTIVE_FINAL, INVALID_ARGUMENT_TYPE, - INVALID_ASSIGNMENT, INVALID_ATTRIBUTE_ACCESS, INVALID_BASE, INVALID_DATACLASS, - INVALID_DECLARATION, INVALID_GENERIC_CLASS, INVALID_GENERIC_ENUM, INVALID_KEY, - INVALID_LEGACY_POSITIONAL_PARAMETER, INVALID_LEGACY_TYPE_VARIABLE, INVALID_METACLASS, - INVALID_NAMED_TUPLE, INVALID_NEWTYPE, INVALID_OVERLOAD, INVALID_PARAMETER_DEFAULT, - INVALID_PARAMSPEC, INVALID_PROTOCOL, INVALID_TYPE_ALIAS_TYPE, INVALID_TYPE_ARGUMENTS, - INVALID_TYPE_FORM, INVALID_TYPE_GUARD_CALL, INVALID_TYPE_GUARD_DEFINITION, - INVALID_TYPE_VARIABLE_BOUND, INVALID_TYPE_VARIABLE_CONSTRAINTS, INVALID_TYPE_VARIABLE_DEFAULT, - INVALID_TYPED_DICT_HEADER, INVALID_TYPED_DICT_STATEMENT, IncompatibleBases, MISSING_ARGUMENT, - NO_MATCHING_OVERLOAD, NOT_SUBSCRIPTABLE, PARAMETER_ALREADY_ASSIGNED, - POSSIBLY_MISSING_ATTRIBUTE, POSSIBLY_MISSING_IMPLICIT_CALL, POSSIBLY_MISSING_IMPORT, - SUBCLASS_OF_FINAL_CLASS, TOO_MANY_POSITIONAL_ARGUMENTS, TypedDictDeleteErrorKind, - UNDEFINED_REVEAL, UNKNOWN_ARGUMENT, UNRESOLVED_ATTRIBUTE, UNRESOLVED_GLOBAL, UNRESOLVED_IMPORT, - UNRESOLVED_REFERENCE, UNSUPPORTED_DYNAMIC_BASE, UNSUPPORTED_OPERATOR, USELESS_OVERLOAD_BODY, - hint_if_stdlib_attribute_exists_on_other_versions, + FINAL_ON_NON_METHOD, FINAL_WITHOUT_VALUE, INCONSISTENT_MRO, INEFFECTIVE_FINAL, + INVALID_ARGUMENT_TYPE, INVALID_ASSIGNMENT, INVALID_ATTRIBUTE_ACCESS, INVALID_BASE, + INVALID_DATACLASS, INVALID_DECLARATION, INVALID_GENERIC_CLASS, INVALID_GENERIC_ENUM, + INVALID_KEY, INVALID_LEGACY_POSITIONAL_PARAMETER, INVALID_LEGACY_TYPE_VARIABLE, + INVALID_METACLASS, INVALID_NAMED_TUPLE, INVALID_NEWTYPE, INVALID_OVERLOAD, + INVALID_PARAMETER_DEFAULT, INVALID_PARAMSPEC, INVALID_PROTOCOL, INVALID_TYPE_ALIAS_TYPE, + INVALID_TYPE_ARGUMENTS, INVALID_TYPE_FORM, INVALID_TYPE_GUARD_CALL, + INVALID_TYPE_GUARD_DEFINITION, INVALID_TYPE_VARIABLE_BOUND, INVALID_TYPE_VARIABLE_CONSTRAINTS, + INVALID_TYPE_VARIABLE_DEFAULT, INVALID_TYPED_DICT_HEADER, INVALID_TYPED_DICT_STATEMENT, + IncompatibleBases, MISSING_ARGUMENT, NO_MATCHING_OVERLOAD, NOT_SUBSCRIPTABLE, + PARAMETER_ALREADY_ASSIGNED, POSSIBLY_MISSING_ATTRIBUTE, POSSIBLY_MISSING_IMPLICIT_CALL, + POSSIBLY_MISSING_IMPORT, SUBCLASS_OF_FINAL_CLASS, TOO_MANY_POSITIONAL_ARGUMENTS, + TypedDictDeleteErrorKind, UNDEFINED_REVEAL, UNKNOWN_ARGUMENT, UNRESOLVED_ATTRIBUTE, + UNRESOLVED_GLOBAL, UNRESOLVED_IMPORT, UNRESOLVED_REFERENCE, UNSUPPORTED_DYNAMIC_BASE, + UNSUPPORTED_OPERATOR, USELESS_OVERLOAD_BODY, hint_if_stdlib_attribute_exists_on_other_versions, hint_if_stdlib_submodule_exists_on_other_versions, report_attempted_protocol_instantiation, report_bad_dunder_set_call, report_bad_frozen_dataclass_inheritance, report_call_to_abstract_method, report_cannot_delete_typed_dict_key, @@ -3217,6 +3217,30 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { decorator_types_and_nodes.push((decorator_type, decorator)); } + // Check for `@final` applied to non-method functions. + // `@final` is only meaningful on methods and classes; using it on a module-level + // or nested function is an error per the typing spec. + if function_decorators.contains(FunctionDecorators::FINAL) { + let file_scope_id = self.scope().file_scope_id(self.db()); + if !self.index.scope(file_scope_id).kind().is_class() { + if let Some(final_decorator) = decorator_list.iter().find(|decorator| { + matches!( + self.expression_type(&decorator.expression), + Type::FunctionLiteral(f) if f.is_known(self.db(), KnownFunction::Final) + ) + }) { + if let Some(builder) = self + .context + .report_lint(&FINAL_ON_NON_METHOD, final_decorator) + { + builder.into_diagnostic(format_args!( + "`@final` cannot be applied to a non-method function `{name}`", + )); + } + } + } + } + let has_defaults = parameters .iter_non_variadic_params() .any(|param| param.default.is_some()); diff --git a/ty.schema.json b/ty.schema.json index cd9f861c8c7a3..ac6f9f68da7b5 100644 --- a/ty.schema.json +++ b/ty.schema.json @@ -555,6 +555,16 @@ } ] }, + "final-on-non-method": { + "title": "detects `@final` applied to non-method functions", + "description": "## What it does\nChecks for `@final` decorators applied to non-method functions.\n\n## Why is this bad?\nThe `@final` decorator is only meaningful on methods (instance methods,\nclass methods, static methods) and classes. Applying it to a module-level\nfunction or a nested function has no effect and is likely a mistake.\n\n## Example\n\n```python\nfrom typing import final\n\n# Error: @final is not allowed on non-method functions\n@final\ndef my_function() -> int:\n return 0\n```", + "default": "error", + "oneOf": [ + { + "$ref": "#/definitions/Level" + } + ] + }, "final-without-value": { "title": "detects `Final` declarations without a value", "description": "## What it does\nChecks for `Final` symbols that are declared without a value and are never\nassigned a value in their scope.\n\n## Why is this bad?\nA `Final` symbol must be initialized with a value at the time of declaration\nor in a subsequent assignment. At module or function scope, the assignment must\noccur in the same scope. In a class body, the assignment may occur in `__init__`.\n\n## Examples\n```python\nfrom typing import Final\n\n# Error: `Final` symbol without a value\nMY_CONSTANT: Final[int]\n\n# OK: `Final` symbol with a value\nMY_CONSTANT: Final[int] = 1\n```", From 638845d9d867874958e8d6a94e5a54c27ce9bae2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Feb 2026 12:07:46 +0000 Subject: [PATCH 2/6] Address review feedback - Use let chains instead of nested if blocks - Remove redundant tests (typing_extensions, methods, classes are tested elsewhere) - Add test for @final on function nested inside a method - Fix version to 0.0.20 https://claude.ai/code/session_015ajybUNEuqCYeVVnSsiFCS --- crates/ty/docs/rules.md | 2 +- .../resources/mdtest/final.md | 32 +++------------- .../src/types/diagnostic.rs | 2 +- .../src/types/infer/builder.rs | 38 +++++++++---------- 4 files changed, 26 insertions(+), 48 deletions(-) diff --git a/crates/ty/docs/rules.md b/crates/ty/docs/rules.md index f228e77ffbae6..903c1fda9f22a 100644 --- a/crates/ty/docs/rules.md +++ b/crates/ty/docs/rules.md @@ -638,7 +638,7 @@ def foo() -> "intt\b": ... Default level: error · -Added in 0.0.22 · +Added in 0.0.20 · Related issues · View source diff --git a/crates/ty_python_semantic/resources/mdtest/final.md b/crates/ty_python_semantic/resources/mdtest/final.md index c703689071dad..bae8632bc0aff 100644 --- a/crates/ty_python_semantic/resources/mdtest/final.md +++ b/crates/ty_python_semantic/resources/mdtest/final.md @@ -446,33 +446,11 @@ def outer(): def inner() -> None: pass -# Using `typing_extensions.final` should also be detected -import typing_extensions - -@typing_extensions.final # error: [final-on-non-method] -def func2() -> None: - pass - -# `@final` on methods is fine (no error) -class MyClass: - @final - def method(self) -> None: - pass - - @final - @classmethod - def class_method(cls) -> None: - pass - - @final - @staticmethod - def static_method() -> None: - pass - -# `@final` on classes is fine (no error) -@final -class FinalClass: - pass +# A function nested inside a method is also not a method +class F: + def method(self): + @final # error: [final-on-non-method] + def not_a_method() -> None: ... ``` ## An `@final` method is overridden by an implicit instance attribute diff --git a/crates/ty_python_semantic/src/types/diagnostic.rs b/crates/ty_python_semantic/src/types/diagnostic.rs index ad34c0083badb..66e8cd541502a 100644 --- a/crates/ty_python_semantic/src/types/diagnostic.rs +++ b/crates/ty_python_semantic/src/types/diagnostic.rs @@ -2184,7 +2184,7 @@ declare_lint! { /// ``` pub(crate) static FINAL_ON_NON_METHOD = { summary: "detects `@final` applied to non-method functions", - status: LintStatus::stable("0.0.22"), + status: LintStatus::stable("0.0.20"), default_level: Level::Error, } } diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index 7bccdd5597502..9c2d469d28553 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -3220,25 +3220,25 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { // Check for `@final` applied to non-method functions. // `@final` is only meaningful on methods and classes; using it on a module-level // or nested function is an error per the typing spec. - if function_decorators.contains(FunctionDecorators::FINAL) { - let file_scope_id = self.scope().file_scope_id(self.db()); - if !self.index.scope(file_scope_id).kind().is_class() { - if let Some(final_decorator) = decorator_list.iter().find(|decorator| { - matches!( - self.expression_type(&decorator.expression), - Type::FunctionLiteral(f) if f.is_known(self.db(), KnownFunction::Final) - ) - }) { - if let Some(builder) = self - .context - .report_lint(&FINAL_ON_NON_METHOD, final_decorator) - { - builder.into_diagnostic(format_args!( - "`@final` cannot be applied to a non-method function `{name}`", - )); - } - } - } + if function_decorators.contains(FunctionDecorators::FINAL) + && !self + .index + .scope(self.scope().file_scope_id(self.db())) + .kind() + .is_class() + && let Some(final_decorator) = decorator_list.iter().find(|decorator| { + matches!( + self.expression_type(&decorator.expression), + Type::FunctionLiteral(f) if f.is_known(self.db(), KnownFunction::Final) + ) + }) + && let Some(builder) = self + .context + .report_lint(&FINAL_ON_NON_METHOD, final_decorator) + { + builder.into_diagnostic(format_args!( + "`@final` cannot be applied to a non-method function `{name}`", + )); } let has_defaults = parameters From 1bc92b6bc1f66d9f2cb5fbc2dd5acfc7f2e25d52 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Feb 2026 12:09:39 +0000 Subject: [PATCH 3/6] Use `...` instead of `pass` in empty function body https://claude.ai/code/session_015ajybUNEuqCYeVVnSsiFCS --- crates/ty_python_semantic/resources/mdtest/final.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/final.md b/crates/ty_python_semantic/resources/mdtest/final.md index bae8632bc0aff..1c91aac12922a 100644 --- a/crates/ty_python_semantic/resources/mdtest/final.md +++ b/crates/ty_python_semantic/resources/mdtest/final.md @@ -443,8 +443,7 @@ def func1() -> int: # Nested function decorated with `@final` is also invalid def outer(): @final # error: [final-on-non-method] - def inner() -> None: - pass + def inner() -> None: ... # A function nested inside a method is also not a method class F: From b65cb91638cff163408de084f9853f6f5c6f3786 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Feb 2026 12:11:33 +0000 Subject: [PATCH 4/6] Simplify test bodies: remove return annotations, use empty bodies https://claude.ai/code/session_015ajybUNEuqCYeVVnSsiFCS --- crates/ty_python_semantic/resources/mdtest/final.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/final.md b/crates/ty_python_semantic/resources/mdtest/final.md index 1c91aac12922a..51923589d1584 100644 --- a/crates/ty_python_semantic/resources/mdtest/final.md +++ b/crates/ty_python_semantic/resources/mdtest/final.md @@ -437,19 +437,18 @@ function is an error. from typing import final @final # error: [final-on-non-method] "`@final` cannot be applied to a non-method function `func1`" -def func1() -> int: - return 0 +def func1(): ... # Nested function decorated with `@final` is also invalid def outer(): @final # error: [final-on-non-method] - def inner() -> None: ... + def inner(): ... # A function nested inside a method is also not a method class F: def method(self): @final # error: [final-on-non-method] - def not_a_method() -> None: ... + def not_a_method(): ... ``` ## An `@final` method is overridden by an implicit instance attribute From 2e46471580c0b0df3b9320dbec43dbf643400fab Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Fri, 27 Feb 2026 13:01:18 +0000 Subject: [PATCH 5/6] cleanups --- crates/ty/docs/rules.md | 6 ++-- .../resources/mdtest/final.md | 2 +- .../src/types/diagnostic.rs | 6 ++-- .../src/types/infer/builder.rs | 28 +++++++++---------- ty.schema.json | 2 +- 5 files changed, 22 insertions(+), 22 deletions(-) diff --git a/crates/ty/docs/rules.md b/crates/ty/docs/rules.md index 903c1fda9f22a..cf883cca905cc 100644 --- a/crates/ty/docs/rules.md +++ b/crates/ty/docs/rules.md @@ -650,9 +650,9 @@ Checks for `@final` decorators applied to non-method functions. **Why is this bad?** -The `@final` decorator is only meaningful on methods (instance methods, -class methods, static methods) and classes. Applying it to a module-level -function or a nested function has no effect and is likely a mistake. +The `@final` decorator is only meaningful on methods and classes. +Applying it to a module-level function or a nested function has no +effect and is likely a mistake. **Example** diff --git a/crates/ty_python_semantic/resources/mdtest/final.md b/crates/ty_python_semantic/resources/mdtest/final.md index 51923589d1584..78149aabcd1bc 100644 --- a/crates/ty_python_semantic/resources/mdtest/final.md +++ b/crates/ty_python_semantic/resources/mdtest/final.md @@ -436,7 +436,7 @@ function is an error. ```py from typing import final -@final # error: [final-on-non-method] "`@final` cannot be applied to a non-method function `func1`" +@final # error: [final-on-non-method] "`@final` cannot be applied to non-method function `func1`" def func1(): ... # Nested function decorated with `@final` is also invalid diff --git a/crates/ty_python_semantic/src/types/diagnostic.rs b/crates/ty_python_semantic/src/types/diagnostic.rs index 66e8cd541502a..89a9dea43daa8 100644 --- a/crates/ty_python_semantic/src/types/diagnostic.rs +++ b/crates/ty_python_semantic/src/types/diagnostic.rs @@ -2168,9 +2168,9 @@ declare_lint! { /// Checks for `@final` decorators applied to non-method functions. /// /// ## Why is this bad? - /// The `@final` decorator is only meaningful on methods (instance methods, - /// class methods, static methods) and classes. Applying it to a module-level - /// function or a nested function has no effect and is likely a mistake. + /// The `@final` decorator is only meaningful on methods and classes. + /// Applying it to a module-level function or a nested function has no + /// effect and is likely a mistake. /// /// ## Example /// diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index 9c2d469d28553..8d456ef5a410a 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -3186,6 +3186,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { let mut function_decorators = FunctionDecorators::empty(); let mut deprecated = None; let mut dataclass_transformer_params = None; + let mut final_decorator = None; for decorator in decorator_list { let decorator_type = self.infer_decorator(decorator); @@ -3194,14 +3195,19 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { function_decorators |= decorator_function_decorator; match decorator_type { - Type::FunctionLiteral(function) => { - if let Some(KnownFunction::NoTypeCheck) = function.known(self.db()) { + Type::FunctionLiteral(function) => match function.known(self.db()) { + Some(KnownFunction::NoTypeCheck) => { // If the function is decorated with the `no_type_check` decorator, // we need to suppress any errors that come after the decorators. self.context.set_in_no_type_check(InNoTypeCheck::Yes); continue; } - } + Some(KnownFunction::Final) => { + final_decorator = Some(decorator); + continue; + } + _ => {} + }, Type::KnownInstance(KnownInstanceType::Deprecated(deprecated_inst)) => { deprecated = Some(deprecated_inst); } @@ -3218,27 +3224,21 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } // Check for `@final` applied to non-method functions. - // `@final` is only meaningful on methods and classes; using it on a module-level - // or nested function is an error per the typing spec. - if function_decorators.contains(FunctionDecorators::FINAL) + // `@final` is only meaningful on methods and classes. + if let Some(final_decorator) = final_decorator && !self .index .scope(self.scope().file_scope_id(self.db())) .kind() .is_class() - && let Some(final_decorator) = decorator_list.iter().find(|decorator| { - matches!( - self.expression_type(&decorator.expression), - Type::FunctionLiteral(f) if f.is_known(self.db(), KnownFunction::Final) - ) - }) && let Some(builder) = self .context .report_lint(&FINAL_ON_NON_METHOD, final_decorator) { - builder.into_diagnostic(format_args!( - "`@final` cannot be applied to a non-method function `{name}`", + let mut diagnostic = builder.into_diagnostic(format_args!( + "`@final` cannot be applied to non-method function `{name}`", )); + diagnostic.info("`@final` is only meaningful on methods and classes"); } let has_defaults = parameters diff --git a/ty.schema.json b/ty.schema.json index ac6f9f68da7b5..3d7e2b3b783db 100644 --- a/ty.schema.json +++ b/ty.schema.json @@ -557,7 +557,7 @@ }, "final-on-non-method": { "title": "detects `@final` applied to non-method functions", - "description": "## What it does\nChecks for `@final` decorators applied to non-method functions.\n\n## Why is this bad?\nThe `@final` decorator is only meaningful on methods (instance methods,\nclass methods, static methods) and classes. Applying it to a module-level\nfunction or a nested function has no effect and is likely a mistake.\n\n## Example\n\n```python\nfrom typing import final\n\n# Error: @final is not allowed on non-method functions\n@final\ndef my_function() -> int:\n return 0\n```", + "description": "## What it does\nChecks for `@final` decorators applied to non-method functions.\n\n## Why is this bad?\nThe `@final` decorator is only meaningful on methods and classes.\nApplying it to a module-level function or a nested function has no\neffect and is likely a mistake.\n\n## Example\n\n```python\nfrom typing import final\n\n# Error: @final is not allowed on non-method functions\n@final\ndef my_function() -> int:\n return 0\n```", "default": "error", "oneOf": [ { From 75b7896492843dadfd3067f077d9f4bdf1e4e297 Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Fri, 27 Feb 2026 13:16:22 +0000 Subject: [PATCH 6/6] Update crates/ty_python_semantic/src/types/diagnostic.rs Co-authored-by: David Peter --- crates/ty/docs/rules.md | 2 +- crates/ty_python_semantic/src/types/diagnostic.rs | 2 +- ty.schema.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/ty/docs/rules.md b/crates/ty/docs/rules.md index cf883cca905cc..759e5754ef232 100644 --- a/crates/ty/docs/rules.md +++ b/crates/ty/docs/rules.md @@ -650,7 +650,7 @@ Checks for `@final` decorators applied to non-method functions. **Why is this bad?** -The `@final` decorator is only meaningful on methods and classes. +The `@final` decorator is only meaningful on methods and classes. Applying it to a module-level function or a nested function has no effect and is likely a mistake. diff --git a/crates/ty_python_semantic/src/types/diagnostic.rs b/crates/ty_python_semantic/src/types/diagnostic.rs index 89a9dea43daa8..0485f2fad1d40 100644 --- a/crates/ty_python_semantic/src/types/diagnostic.rs +++ b/crates/ty_python_semantic/src/types/diagnostic.rs @@ -2168,7 +2168,7 @@ declare_lint! { /// Checks for `@final` decorators applied to non-method functions. /// /// ## Why is this bad? - /// The `@final` decorator is only meaningful on methods and classes. + /// The `@final` decorator is only meaningful on methods and classes. /// Applying it to a module-level function or a nested function has no /// effect and is likely a mistake. /// diff --git a/ty.schema.json b/ty.schema.json index 3d7e2b3b783db..ff954b6d68d2a 100644 --- a/ty.schema.json +++ b/ty.schema.json @@ -557,7 +557,7 @@ }, "final-on-non-method": { "title": "detects `@final` applied to non-method functions", - "description": "## What it does\nChecks for `@final` decorators applied to non-method functions.\n\n## Why is this bad?\nThe `@final` decorator is only meaningful on methods and classes.\nApplying it to a module-level function or a nested function has no\neffect and is likely a mistake.\n\n## Example\n\n```python\nfrom typing import final\n\n# Error: @final is not allowed on non-method functions\n@final\ndef my_function() -> int:\n return 0\n```", + "description": "## What it does\nChecks for `@final` decorators applied to non-method functions.\n\n## Why is this bad?\nThe `@final` decorator is only meaningful on methods and classes.\nApplying it to a module-level function or a nested function has no\neffect and is likely a mistake.\n\n## Example\n\n```python\nfrom typing import final\n\n# Error: @final is not allowed on non-method functions\n@final\ndef my_function() -> int:\n return 0\n```", "default": "error", "oneOf": [ {