diff --git a/crates/ty/docs/rules.md b/crates/ty/docs/rules.md index 00eb3841c08ac..a4db6c70ddf15 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 @@ -640,7 +640,7 @@ def foo() -> "intt\b": ... Default level: error · Added in 0.0.20 · Related issues · -View source +View source @@ -672,7 +672,7 @@ def my_function() -> int: Default level: error · Added in 0.0.15 · Related issues · -View source +View source @@ -798,7 +798,7 @@ def test(): -> "Literal[5]": Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -828,7 +828,7 @@ class C(A, B): ... Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -854,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 @@ -888,7 +888,7 @@ class MyClass: ... Default level: error · Added in 0.0.1-alpha.12 · Related issues · -View source +View source @@ -977,7 +977,7 @@ an atypical memory layout. Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1004,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 @@ -1032,7 +1032,7 @@ a: int = '' Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1066,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 @@ -1102,7 +1102,7 @@ asyncio.run(main()) Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1126,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 @@ -1153,7 +1153,7 @@ with 1: Default level: error · Added in 0.0.12 · Related issues · -View source +View source @@ -1190,7 +1190,7 @@ class Foo(NamedTuple): Default level: error · Added in 0.0.13 · Related issues · -View source +View source @@ -1222,7 +1222,7 @@ class A: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1245,13 +1245,62 @@ a: str [assignable to]: https://typing.python.org/en/latest/spec/glossary.html#term-assignable +## `invalid-enum-member-annotation` + + +Default level: warn · +Added in 0.0.20 · +Related issues · +View source + + + +**What it does** + +Checks for enum members that have explicit type annotations. + +**Why is this bad?** + +The [typing spec] states that type checkers should infer a literal type +for all enum members. An explicit type annotation on an enum member is +misleading because the annotated type will be incorrect — the actual +runtime type is the enum class itself, not the annotated type. + +In CPython's `enum` module, annotated assignments with values are still +treated as members at runtime, but the annotation will confuse readers of the code. + +**Examples** + +```python +from enum import Enum + +class Pet(Enum): + CAT = 1 # OK + DOG: int = 2 # Error: enum members should not be annotated +``` + +Use instead: +```python +from enum import Enum + +class Pet(Enum): + CAT = 1 + DOG = 2 +``` + +**References** + +- [Typing spec: Enum members](https://typing.python.org/en/latest/spec/enums.html#enum-members) + +[typing spec]: https://typing.python.org/en/latest/spec/enums.html#enum-members + ## `invalid-exception-caught` Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1295,7 +1344,7 @@ except ZeroDivisionError: Default level: error · Added in 0.0.1-alpha.28 · Related issues · -View source +View source @@ -1337,7 +1386,7 @@ class D(A): Default level: error · Added in 0.0.1-alpha.35 · Related issues · -View source +View source @@ -1381,7 +1430,7 @@ class NonFrozenChild(FrozenBase): # Error raised here Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1419,7 +1468,7 @@ class D(Generic[U, T]): ... Default level: error · Added in 0.0.12 · Related issues · -View source +View source @@ -1498,7 +1547,7 @@ a = 20 / 0 # type: ignore Default level: error · Added in 0.0.1-alpha.17 · Related issues · -View source +View source @@ -1537,7 +1586,7 @@ carol = Person(name="Carol", age=25) # typo! Default level: warn · Added in 0.0.15 · Related issues · -View source +View source @@ -1598,7 +1647,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 @@ -1633,7 +1682,7 @@ def f(t: TypeVar("U")): ... Default level: error · Added in 0.0.18 · Related issues · -View source +View source @@ -1661,7 +1710,7 @@ match x: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1695,7 +1744,7 @@ class B(metaclass=f): ... Default level: error · Added in 0.0.1-alpha.20 · Related issues · -View source +View source @@ -1802,7 +1851,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 @@ -1856,7 +1905,7 @@ AttributeError: Cannot overwrite NamedTuple attribute _asdict Default level: error · Added in 0.0.1-alpha.27 · Related issues · -View source +View source @@ -1886,7 +1935,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 @@ -1936,7 +1985,7 @@ def foo(x: int) -> int: ... Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1962,7 +2011,7 @@ def f(a: int = ''): ... Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1993,7 +2042,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 @@ -2027,7 +2076,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 @@ -2076,7 +2125,7 @@ def g(): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2105,7 +2154,7 @@ def func() -> int: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2201,7 +2250,7 @@ class C: ... Default level: error · Added in 0.0.10 · Related issues · -View source +View source @@ -2247,7 +2296,7 @@ class MyClass: Default level: error · Added in 0.0.1-alpha.6 · Related issues · -View source +View source @@ -2274,7 +2323,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 @@ -2321,7 +2370,7 @@ Bar[int] # error: too few arguments Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2351,7 +2400,7 @@ TYPE_CHECKING = '' Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2381,7 +2430,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 @@ -2415,7 +2464,7 @@ f(10) # Error Default level: error · Added in 0.0.1-alpha.11 · Related issues · -View source +View source @@ -2449,7 +2498,7 @@ class C: Default level: error · Added in 0.0.15 · Related issues · -View source +View source @@ -2480,7 +2529,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 @@ -2527,7 +2576,7 @@ U = TypeVar('U', list[int], int) # valid constrained Type Default level: error · Added in 0.0.16 · Related issues · -View source +View source @@ -2559,7 +2608,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 @@ -2594,7 +2643,7 @@ def f(x: dict): Default level: error · Added in 0.0.9 · Related issues · -View source +View source @@ -2625,7 +2674,7 @@ class Foo(TypedDict): Default level: error · Added in 0.0.14 · Related issues · -View source +View source @@ -2680,7 +2729,7 @@ def h(arg2: type): Default level: error · Added in 0.0.15 · Related issues · -View source +View source @@ -2723,7 +2772,7 @@ def g(arg: object): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2748,7 +2797,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 @@ -2781,7 +2830,7 @@ alice["age"] # KeyError Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2810,7 +2859,7 @@ func("string") # error: [no-matching-overload] Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2836,7 +2885,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 @@ -2860,7 +2909,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 @@ -2893,7 +2942,7 @@ class B(A): Default level: error · Added in 0.0.16 · Related issues · -View source +View source @@ -2926,7 +2975,7 @@ class B(A): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2953,7 +3002,7 @@ f(1, x=2) # Error raised here Default level: error · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -2980,7 +3029,7 @@ f(x=1) # Error raised here Default level: warn · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -3008,7 +3057,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 @@ -3040,7 +3089,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 @@ -3077,7 +3126,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 @@ -3141,7 +3190,7 @@ def test(): -> "int": Default level: warn · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3168,7 +3217,7 @@ cast(int, f()) # Redundant Default level: warn · Added in 0.0.18 · Related issues · -View source +View source @@ -3200,7 +3249,7 @@ class C: Default level: error · Added in 0.0.20 · Related issues · -View source +View source @@ -3234,7 +3283,7 @@ class Outer[T]: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3264,7 +3313,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 @@ -3293,7 +3342,7 @@ class B(A): ... # Error raised here Default level: error · Added in 0.0.1-alpha.30 · Related issues · -View source +View source @@ -3327,7 +3376,7 @@ class F(NamedTuple): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3354,7 +3403,7 @@ f("foo") # Error raised here Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3382,7 +3431,7 @@ def _(x: int): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3428,7 +3477,7 @@ class A: Default level: error · Added in 0.0.20 · Related issues · -View source +View source @@ -3465,7 +3514,7 @@ class C(Generic[T]): Default level: warn · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3489,7 +3538,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 @@ -3516,7 +3565,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 @@ -3544,7 +3593,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 @@ -3602,7 +3651,7 @@ def g(): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3627,7 +3676,7 @@ import foo # ModuleNotFoundError: No module named 'foo' Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3652,7 +3701,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 @@ -3691,7 +3740,7 @@ class D(C): ... # error: [unsupported-base] Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3728,7 +3777,7 @@ b1 < b2 < b1 # exception raised here Default level: ignore · Added in 0.0.12 · Related issues · -View source +View source @@ -3769,7 +3818,7 @@ def factory(base: type[Base]) -> type: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3870,7 +3919,7 @@ to `false`. Default level: warn · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -3933,7 +3982,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/enums.md b/crates/ty_python_semantic/resources/mdtest/enums.md index 90f59bad8e520..5455c3dff4e60 100644 --- a/crates/ty_python_semantic/resources/mdtest/enums.md +++ b/crates/ty_python_semantic/resources/mdtest/enums.md @@ -78,8 +78,7 @@ class Answer(Enum): non_member_1: int - # TODO: this could be considered an error: - non_member_1: str = "some value" + non_member_1: str = "some value" # error: [invalid-enum-member-annotation] # revealed: tuple[Literal["YES"], Literal["NO"]] reveal_type(enum_members(Answer)) @@ -100,6 +99,109 @@ class Answer(Enum): reveal_type(enum_members(Answer)) ``` +### Annotated enum members + +The [typing spec] states that enum members should not have explicit type annotations. Type checkers +should report an error for annotated enum members because the annotation is misleading — the actual +type of an enum member is the enum class itself, not the annotated type. + +```toml +[environment] +python-version = "3.11" +``` + +```py +from enum import Enum, IntEnum, StrEnum, member +from typing import Callable, Final + +class Pet(Enum): + CAT = 1 + DOG: int = 2 # error: [invalid-enum-member-annotation] "Type annotation on enum member `DOG` is not allowed" + BIRD: str = "bird" # error: [invalid-enum-member-annotation] +``` + +Bare `Final` annotations are allowed (they don't specify a type): + +```py +class Pet2(Enum): + CAT: Final = 1 # OK + DOG: Final = 2 # OK +``` + +But `Final` with a type argument is not allowed: + +```py +class Pet3(Enum): + CAT: Final[int] = 1 # error: [invalid-enum-member-annotation] + DOG: Final[str] = "woof" # error: [invalid-enum-member-annotation] +``` + +`enum.member` used as value wrapper is the standard way to declare members explicitly: + +```py +class Pet4(Enum): + CAT = member(1) # OK +``` + +Dunder and private names are not enum members, so they don't trigger the diagnostic: + +```py +class Pet5(Enum): + CAT = 1 + __private: int = 2 # OK: dunder/private names are never members + __module__: str = "my_module" # OK +``` + +Pure declarations (annotations without values) are non-members and are fine: + +```py +class Pet6(Enum): + CAT = 1 + species: str # OK: no value, so this is a non-member declaration +``` + +Callable values are never enum members at runtime, so annotating them is fine: + +```py +def identity(x: int) -> int: + return x + +class Pet7(Enum): + CAT = 1 + declared_callable: Callable[[int], int] = identity # OK: callables are never members +``` + +The check also works for subclasses of `Enum`: + +```py +class Status(IntEnum): + OK: int = 200 # error: [invalid-enum-member-annotation] + NOT_FOUND = 404 # OK + +class Color(StrEnum): + RED: str = "red" # error: [invalid-enum-member-annotation] + GREEN = "green" # OK +``` + +Special sunder names like `_value_` and `_ignore_` are not flagged: + +```py +class Pet8(Enum): + _value_: int = 0 # OK: `_value_` is a special enum name + _ignore_: str = "TEMP" # OK: `_ignore_` is a special enum name + CAT = 1 +``` + +Names listed in `_ignore_` are not members, so annotating them is fine: + +```py +class Pet9(Enum): + _ignore_ = "A B" + A: int = 42 # OK: `A` is listed in `_ignore_` + B: str = "hello" # OK: `B` is listed in `_ignore_` + C: int = 3 # error: [invalid-enum-member-annotation] +``` + ### Declared `_value_` annotation If a `_value_` annotation is defined on an `Enum` class, all enum member values must be compatible @@ -814,7 +916,7 @@ class Answer(Enum): def is_yes(self) -> bool: return self == Answer.YES - constant: int = 1 + constant: int = 1 # error: [invalid-enum-member-annotation] reveal_type(Answer.YES.is_yes()) # revealed: bool reveal_type(Answer.YES.constant) # revealed: int @@ -1353,3 +1455,4 @@ class MyEnum[T](MyEnumBase): - Documentation: [class-private names]: https://docs.python.org/3/reference/lexical_analysis.html#reserved-classes-of-identifiers +[typing spec]: https://typing.python.org/en/latest/spec/enums.html#enum-members diff --git a/crates/ty_python_semantic/src/types/diagnostic.rs b/crates/ty_python_semantic/src/types/diagnostic.rs index ae3ee181c4cc8..d8910b48dcdb5 100644 --- a/crates/ty_python_semantic/src/types/diagnostic.rs +++ b/crates/ty_python_semantic/src/types/diagnostic.rs @@ -83,6 +83,7 @@ pub(crate) fn register_lints(registry: &mut LintRegistryBuilder) { registry.register_lint(&INVALID_CONTEXT_MANAGER); registry.register_lint(&INVALID_DECLARATION); registry.register_lint(&INVALID_EXCEPTION_CAUGHT); + registry.register_lint(&INVALID_ENUM_MEMBER_ANNOTATION); registry.register_lint(&INVALID_GENERIC_ENUM); registry.register_lint(&INVALID_GENERIC_CLASS); registry.register_lint(&INVALID_LEGACY_TYPE_VARIABLE); @@ -1193,6 +1194,48 @@ declare_lint! { } } +declare_lint! { + /// ## What it does + /// Checks for enum members that have explicit type annotations. + /// + /// ## Why is this bad? + /// The [typing spec] states that type checkers should infer a literal type + /// for all enum members. An explicit type annotation on an enum member is + /// misleading because the annotated type will be incorrect — the actual + /// runtime type is the enum class itself, not the annotated type. + /// + /// In CPython's `enum` module, annotated assignments with values are still + /// treated as members at runtime, but the annotation will confuse readers of the code. + /// + /// ## Examples + /// ```python + /// from enum import Enum + /// + /// class Pet(Enum): + /// CAT = 1 # OK + /// DOG: int = 2 # Error: enum members should not be annotated + /// ``` + /// + /// Use instead: + /// ```python + /// from enum import Enum + /// + /// class Pet(Enum): + /// CAT = 1 + /// DOG = 2 + /// ``` + /// + /// ## References + /// - [Typing spec: Enum members](https://typing.python.org/en/latest/spec/enums.html#enum-members) + /// + /// [typing spec]: https://typing.python.org/en/latest/spec/enums.html#enum-members + pub(crate) static INVALID_ENUM_MEMBER_ANNOTATION = { + summary: "detects type annotations on enum members", + status: LintStatus::stable("0.0.20"), + default_level: Level::Warn, + } +} + declare_lint! { /// ## What it does /// Checks for enum classes that are also generic. diff --git a/crates/ty_python_semantic/src/types/enums.rs b/crates/ty_python_semantic/src/types/enums.rs index 19d920662914b..0f724641a7b5b 100644 --- a/crates/ty_python_semantic/src/types/enums.rs +++ b/crates/ty_python_semantic/src/types/enums.rs @@ -71,6 +71,36 @@ impl<'db> EnumMetadata<'db> { } } +/// Returns the set of names listed in an enum's `_ignore_` attribute. +#[salsa::tracked(returns(ref), heap_size=ruff_memory_usage::heap_size)] +pub(crate) fn enum_ignored_names<'db>(db: &'db dyn Db, scope_id: ScopeId<'db>) -> FxHashSet { + let use_def_map = use_def_map(db, scope_id); + let table = place_table(db, scope_id); + + let Some(ignore) = table.symbol_id("_ignore_") else { + return FxHashSet::default(); + }; + + let ignore_bindings = use_def_map.reachable_symbol_bindings(ignore); + let ignore_place = place_from_bindings(db, ignore_bindings).place; + + match ignore_place { + Place::Defined(DefinedPlace { ty, .. }) => ty + .as_string_literal() + .map(|ignored_names| { + ignored_names + .value(db) + .split_ascii_whitespace() + .map(Name::new) + .collect() + }) + .unwrap_or_default(), + + // TODO: support the list-variant of `_ignore_`. + Place::Undefined => FxHashSet::default(), + } +} + /// List all members of an enum. #[allow(clippy::ref_option, clippy::unnecessary_wraps)] #[salsa::tracked(returns(as_ref), cycle_initial=|_, _, _| Some(EnumMetadata::empty()), heap_size=ruff_memory_usage::heap_size)] @@ -114,21 +144,7 @@ pub(crate) fn enum_metadata<'db>( let mut enum_values: FxHashMap, Name> = FxHashMap::default(); let mut auto_counter = 0; let mut auto_members = FxHashSet::default(); - let ignored_names: Option> = if let Some(ignore) = table.symbol_id("_ignore_") { - let ignore_bindings = use_def_map.reachable_symbol_bindings(ignore); - let ignore_place = place_from_bindings(db, ignore_bindings).place; - - match ignore_place { - Place::Defined(DefinedPlace { ty, .. }) => ty - .as_string_literal() - .map(|ignored_names| ignored_names.value(db).split_ascii_whitespace().collect()), - - // TODO: support the list-variant of `_ignore_`. - Place::Undefined => None, - } - } else { - None - }; + let ignored_names = enum_ignored_names(db, scope_id); let mut aliases = FxHashMap::default(); @@ -143,11 +159,7 @@ pub(crate) fn enum_metadata<'db>( return None; } - if name == "_ignore_" - || ignored_names - .as_ref() - .is_some_and(|names| names.contains(&name.as_str())) - { + if name == "_ignore_" || ignored_names.contains(name) { // Skip ignored attributes return None; } diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index 879992eb50006..243e3e8a1c9e2 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -74,18 +74,18 @@ use crate::types::diagnostic::{ DATACLASS_FIELD_ORDER, DUPLICATE_BASE, DUPLICATE_KW_ONLY, 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, 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, + INVALID_DECLARATION, INVALID_ENUM_MEMBER_ANNOTATION, 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, 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, @@ -107,7 +107,7 @@ use crate::types::diagnostic::{ report_shadowed_type_variable, report_unsupported_augmented_assignment, report_unsupported_base, report_unsupported_comparison, }; -use crate::types::enums::is_enum_class_by_inheritance; +use crate::types::enums::{enum_ignored_names, is_enum_class_by_inheritance}; use crate::types::function::{ FunctionBodyKind, FunctionDecorators, FunctionLiteral, FunctionType, KnownFunction, OverloadLiteral, function_body_kind, is_implicit_classmethod, @@ -9635,6 +9635,43 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { &DeclaredAndInferredType::AreTheSame(TypeAndQualifiers::declared(inferred_ty)), ); } else { + // Check for annotated enum members. The typing spec states that enum + // members should not have explicit type annotations. + if let Some(name_expr) = target.as_name_expr() + && !name_expr.id.starts_with("__") + && !matches!(name_expr.id.as_str(), "_ignore_" | "_value_" | "_name_") + // Not bare Final (bare Final is allowed on enum members) + && !(declared.qualifiers.contains(TypeQualifiers::FINAL) + && matches!(declared.inner_type(), Type::Dynamic(DynamicType::Unknown))) + // Value type would be an enum member at runtime (exclude callables, + // which are never members) + && !inferred_ty.is_subtype_of( + self.db(), + Type::Callable(CallableType::unknown(self.db())) + .top_materialization(self.db()), + ) + { + let current_scope_id = self.scope().file_scope_id(self.db()); + let current_scope = self.index.scope(current_scope_id); + if current_scope.kind() == ScopeKind::Class + && let Some(class) = + nearest_enclosing_class(self.db(), self.index, self.scope()) + && is_enum_class_by_inheritance(self.db(), class) + && !enum_ignored_names(self.db(), self.scope()).contains(&name_expr.id) + && let Some(builder) = self + .context + .report_lint(&INVALID_ENUM_MEMBER_ANNOTATION, annotation) + { + let mut diag = builder.into_diagnostic(format_args!( + "Type annotation on enum member `{}` is not allowed", + &name_expr.id + )); + diag.info( + "See: https://typing.python.org/en/latest/spec/enums.html#enum-members", + ); + } + } + self.add_declaration_with_binding( target.into(), definition, diff --git a/scripts/conformance.py b/scripts/conformance.py index a3680619daeaf..6e492d1c1b307 100644 --- a/scripts/conformance.py +++ b/scripts/conformance.py @@ -470,6 +470,7 @@ def collect_ty_diagnostics( f"--python-version={python_version}", "--output-format=gitlab", "--ignore=assert-type-unspellable-subtype", + "--error=invalid-enum-member-annotation", "--error=invalid-legacy-positional-parameter", "--error=deprecated", "--error=redundant-final-classvar", diff --git a/ty.schema.json b/ty.schema.json index f3204fc366ccc..5e43d4cd15c68 100644 --- a/ty.schema.json +++ b/ty.schema.json @@ -735,6 +735,16 @@ } ] }, + "invalid-enum-member-annotation": { + "title": "detects type annotations on enum members", + "description": "## What it does\nChecks for enum members that have explicit type annotations.\n\n## Why is this bad?\nThe [typing spec] states that type checkers should infer a literal type\nfor all enum members. An explicit type annotation on an enum member is\nmisleading because the annotated type will be incorrect — the actual\nruntime type is the enum class itself, not the annotated type.\n\nIn CPython's `enum` module, annotated assignments with values are still\ntreated as members at runtime, but the annotation will confuse readers of the code.\n\n## Examples\n```python\nfrom enum import Enum\n\nclass Pet(Enum):\n CAT = 1 # OK\n DOG: int = 2 # Error: enum members should not be annotated\n```\n\nUse instead:\n```python\nfrom enum import Enum\n\nclass Pet(Enum):\n CAT = 1\n DOG = 2\n```\n\n## References\n- [Typing spec: Enum members](https://typing.python.org/en/latest/spec/enums.html#enum-members)\n\n[typing spec]: https://typing.python.org/en/latest/spec/enums.html#enum-members", + "default": "warn", + "oneOf": [ + { + "$ref": "#/definitions/Level" + } + ] + }, "invalid-exception-caught": { "title": "detects exception handlers that catch classes that do not inherit from `BaseException`", "description": "## What it does\nChecks for exception handlers that catch non-exception classes.\n\n## Why is this bad?\nCatching classes that do not inherit from `BaseException` will raise a `TypeError` at runtime.\n\n## Example\n```python\ntry:\n 1 / 0\nexcept 1:\n ...\n```\n\nUse instead:\n```python\ntry:\n 1 / 0\nexcept ZeroDivisionError:\n ...\n```\n\n## References\n- [Python documentation: except clause](https://docs.python.org/3/reference/compound_stmts.html#except-clause)\n- [Python documentation: Built-in Exceptions](https://docs.python.org/3/library/exceptions.html#built-in-exceptions)\n\n## Ruff rule\n This rule corresponds to Ruff's [`except-with-non-exception-classes` (`B030`)](https://docs.astral.sh/ruff/rules/except-with-non-exception-classes)",