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)",