Skip to content

Commit 58f7628

Browse files
authored
Issue warning for enum with no members in stub (#18068)
Follow up to #17207
1 parent 3596793 commit 58f7628

File tree

10 files changed

+118
-59
lines changed

10 files changed

+118
-59
lines changed

CHANGELOG.md

+40
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,46 @@
22

33
## Next release
44

5+
### Change to enum membership semantics
6+
7+
As per the updated [typing specification for enums](https://typing.readthedocs.io/en/latest/spec/enums.html#defining-members),
8+
enum members must be left unannotated.
9+
10+
```python
11+
class Pet(Enum):
12+
CAT = 1 # Member attribute
13+
DOG = 2 # Member attribute
14+
WOLF: int = 3 # New error: Enum members must be left unannotated
15+
16+
species: str # Considered a non-member attribute
17+
```
18+
19+
In particular, the specification change can result in issues in type stubs (`.pyi` files), since
20+
historically it was common to leave the value absent:
21+
22+
```python
23+
# In a type stub (.pyi file)
24+
25+
class Pet(Enum):
26+
# Change in semantics: previously considered members, now non-member attributes
27+
CAT: int
28+
DOG: int
29+
30+
# Mypy will now issue a warning if it detects this situation in type stubs:
31+
# > Detected enum "Pet" in a type stub with zero members.
32+
# > There is a chance this is due to a recent change in the semantics of enum membership.
33+
# > If so, use `member = value` to mark an enum member, instead of `member: type`
34+
35+
class Pet(Enum):
36+
# As per the specification, you should now do one of the following:
37+
DOG = 1 # Member attribute with value 1 and known type
38+
WOLF = cast(int, ...) # Member attribute with unknown value but known type
39+
LION = ... # Member attribute with unknown value and unknown type
40+
```
41+
42+
Contributed by Terence Honles in PR [17207](https://github.com/python/mypy/pull/17207) and
43+
Shantanu Jain in PR [18068](https://github.com/python/mypy/pull/18068).
44+
545
## Mypy 1.13
646

747
We’ve just uploaded mypy 1.13 to the Python Package Index ([PyPI](https://pypi.org/project/mypy/)).

mypy/checker.py

+20-10
Original file line numberDiff line numberDiff line change
@@ -2588,20 +2588,30 @@ def check_typevar_defaults(self, tvars: Sequence[TypeVarLikeType]) -> None:
25882588

25892589
def check_enum(self, defn: ClassDef) -> None:
25902590
assert defn.info.is_enum
2591-
if defn.info.fullname not in ENUM_BASES:
2592-
for sym in defn.info.names.values():
2593-
if (
2594-
isinstance(sym.node, Var)
2595-
and sym.node.has_explicit_value
2596-
and sym.node.name == "__members__"
2597-
):
2598-
# `__members__` will always be overwritten by `Enum` and is considered
2599-
# read-only so we disallow assigning a value to it
2600-
self.fail(message_registry.ENUM_MEMBERS_ATTR_WILL_BE_OVERRIDEN, sym.node)
2591+
if defn.info.fullname not in ENUM_BASES and "__members__" in defn.info.names:
2592+
sym = defn.info.names["__members__"]
2593+
if isinstance(sym.node, Var) and sym.node.has_explicit_value:
2594+
# `__members__` will always be overwritten by `Enum` and is considered
2595+
# read-only so we disallow assigning a value to it
2596+
self.fail(message_registry.ENUM_MEMBERS_ATTR_WILL_BE_OVERRIDEN, sym.node)
26012597
for base in defn.info.mro[1:-1]: # we don't need self and `object`
26022598
if base.is_enum and base.fullname not in ENUM_BASES:
26032599
self.check_final_enum(defn, base)
26042600

2601+
if self.is_stub and self.tree.fullname not in {"enum", "_typeshed"}:
2602+
if not defn.info.enum_members:
2603+
self.fail(
2604+
f'Detected enum "{defn.info.fullname}" in a type stub with zero members. '
2605+
"There is a chance this is due to a recent change in the semantics of "
2606+
"enum membership. If so, use `member = value` to mark an enum member, "
2607+
"instead of `member: type`",
2608+
defn,
2609+
)
2610+
self.note(
2611+
"See https://typing.readthedocs.io/en/latest/spec/enums.html#defining-members",
2612+
defn,
2613+
)
2614+
26052615
self.check_enum_bases(defn)
26062616
self.check_enum_new(defn)
26072617

mypy/checkmember.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
ARG_POS,
1818
ARG_STAR,
1919
ARG_STAR2,
20+
EXCLUDED_ENUM_ATTRIBUTES,
2021
SYMBOL_FUNCBASE_TYPES,
2122
Context,
2223
Decorator,
@@ -48,7 +49,6 @@
4849
type_object_type_from_function,
4950
)
5051
from mypy.types import (
51-
ENUM_REMOVED_PROPS,
5252
AnyType,
5353
CallableType,
5454
DeletedType,
@@ -1173,7 +1173,7 @@ def analyze_enum_class_attribute_access(
11731173
itype: Instance, name: str, mx: MemberContext
11741174
) -> Type | None:
11751175
# Skip these since Enum will remove it
1176-
if name in ENUM_REMOVED_PROPS:
1176+
if name in EXCLUDED_ENUM_ATTRIBUTES:
11771177
return report_missing_attribute(mx.original_type, itype, name, mx)
11781178
# Dunders and private names are not Enum members
11791179
if name.startswith("__") and name.replace("_", "") != "":

mypy/nodes.py

+17
Original file line numberDiff line numberDiff line change
@@ -2902,6 +2902,10 @@ def accept(self, visitor: ExpressionVisitor[T]) -> T:
29022902
}
29032903
)
29042904

2905+
# Attributes that can optionally be defined in the body of a subclass of
2906+
# enum.Enum but are removed from the class __dict__ by EnumMeta.
2907+
EXCLUDED_ENUM_ATTRIBUTES: Final = frozenset({"_ignore_", "_order_", "__order__"})
2908+
29052909

29062910
class TypeInfo(SymbolNode):
29072911
"""The type structure of a single class.
@@ -3229,6 +3233,19 @@ def protocol_members(self) -> list[str]:
32293233
members.add(name)
32303234
return sorted(members)
32313235

3236+
@property
3237+
def enum_members(self) -> list[str]:
3238+
return [
3239+
name
3240+
for name, sym in self.names.items()
3241+
if (
3242+
isinstance(sym.node, Var)
3243+
and name not in EXCLUDED_ENUM_ATTRIBUTES
3244+
and not name.startswith("__")
3245+
and sym.node.has_explicit_value
3246+
)
3247+
]
3248+
32323249
def __getitem__(self, name: str) -> SymbolTableNode:
32333250
n = self.get(name)
32343251
if n:

mypy/semanal_enum.py

+3-2
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from mypy.nodes import (
1111
ARG_NAMED,
1212
ARG_POS,
13+
EXCLUDED_ENUM_ATTRIBUTES,
1314
MDEF,
1415
AssignmentStmt,
1516
CallExpr,
@@ -30,7 +31,7 @@
3031
)
3132
from mypy.options import Options
3233
from mypy.semanal_shared import SemanticAnalyzerInterface
33-
from mypy.types import ENUM_REMOVED_PROPS, LiteralType, get_proper_type
34+
from mypy.types import LiteralType, get_proper_type
3435

3536
# Note: 'enum.EnumMeta' is deliberately excluded from this list. Classes that directly use
3637
# enum.EnumMeta do not necessarily automatically have the 'name' and 'value' attributes.
@@ -43,7 +44,7 @@
4344
"value",
4445
"_name_",
4546
"_value_",
46-
*ENUM_REMOVED_PROPS,
47+
*EXCLUDED_ENUM_ATTRIBUTES,
4748
# Also attributes from `object`:
4849
"__module__",
4950
"__annotations__",

mypy/test/teststubtest.py

+13-13
Original file line numberDiff line numberDiff line change
@@ -1267,9 +1267,9 @@ def test_enum(self) -> Iterator[Case]:
12671267
yield Case(
12681268
stub="""
12691269
class X(enum.Enum):
1270-
a: int
1271-
b: str
1272-
c: str
1270+
a = ...
1271+
b = "asdf"
1272+
c = "oops"
12731273
""",
12741274
runtime="""
12751275
class X(enum.Enum):
@@ -1282,8 +1282,8 @@ class X(enum.Enum):
12821282
yield Case(
12831283
stub="""
12841284
class Flags1(enum.Flag):
1285-
a: int
1286-
b: int
1285+
a = ...
1286+
b = 2
12871287
def foo(x: Flags1 = ...) -> None: ...
12881288
""",
12891289
runtime="""
@@ -1297,8 +1297,8 @@ def foo(x=Flags1.a|Flags1.b): pass
12971297
yield Case(
12981298
stub="""
12991299
class Flags2(enum.Flag):
1300-
a: int
1301-
b: int
1300+
a = ...
1301+
b = 2
13021302
def bar(x: Flags2 | None = None) -> None: ...
13031303
""",
13041304
runtime="""
@@ -1312,8 +1312,8 @@ def bar(x=Flags2.a|Flags2.b): pass
13121312
yield Case(
13131313
stub="""
13141314
class Flags3(enum.Flag):
1315-
a: int
1316-
b: int
1315+
a = ...
1316+
b = 2
13171317
def baz(x: Flags3 | None = ...) -> None: ...
13181318
""",
13191319
runtime="""
@@ -1346,8 +1346,8 @@ class WeirdEnum(enum.Enum):
13461346
yield Case(
13471347
stub="""
13481348
class Flags4(enum.Flag):
1349-
a: int
1350-
b: int
1349+
a = 1
1350+
b = 2
13511351
def spam(x: Flags4 | None = None) -> None: ...
13521352
""",
13531353
runtime="""
@@ -1362,7 +1362,7 @@ def spam(x=Flags4(0)): pass
13621362
stub="""
13631363
from typing_extensions import Final, Literal
13641364
class BytesEnum(bytes, enum.Enum):
1365-
a: bytes
1365+
a = b'foo'
13661366
FOO: Literal[BytesEnum.a]
13671367
BAR: Final = BytesEnum.a
13681368
BAZ: BytesEnum
@@ -1897,7 +1897,7 @@ def test_good_literal(self) -> Iterator[Case]:
18971897
18981898
import enum
18991899
class Color(enum.Enum):
1900-
RED: int
1900+
RED = ...
19011901
19021902
NUM: Literal[1]
19031903
CHAR: Literal['a']

mypy/typeops.py

+14-10
Original file line numberDiff line numberDiff line change
@@ -957,16 +957,20 @@ class Status(Enum):
957957
items = [
958958
try_expanding_sum_type_to_union(item, target_fullname) for item in typ.relevant_items()
959959
]
960-
elif isinstance(typ, Instance) and typ.type.fullname == target_fullname:
961-
if typ.type.is_enum:
962-
items = [LiteralType(name, typ) for name in typ.get_enum_values()]
963-
elif typ.type.fullname == "builtins.bool":
960+
return make_simplified_union(items, contract_literals=False)
961+
962+
if isinstance(typ, Instance) and typ.type.fullname == target_fullname:
963+
if typ.type.fullname == "builtins.bool":
964964
items = [LiteralType(True, typ), LiteralType(False, typ)]
965-
else:
966-
return typ
965+
return make_simplified_union(items, contract_literals=False)
966+
967+
if typ.type.is_enum:
968+
items = [LiteralType(name, typ) for name in typ.type.enum_members]
969+
if not items:
970+
return typ
971+
return make_simplified_union(items, contract_literals=False)
967972

968-
# if the expanded union would be `Never` leave the type as is
969-
return typ if not items else make_simplified_union(items, contract_literals=False)
973+
return typ
970974

971975

972976
def try_contracting_literals_in_union(types: Sequence[Type]) -> list[ProperType]:
@@ -990,7 +994,7 @@ def try_contracting_literals_in_union(types: Sequence[Type]) -> list[ProperType]
990994
if fullname not in sum_types:
991995
sum_types[fullname] = (
992996
(
993-
set(typ.fallback.get_enum_values())
997+
set(typ.fallback.type.enum_members)
994998
if typ.fallback.type.is_enum
995999
else {True, False}
9961000
),
@@ -1023,7 +1027,7 @@ def coerce_to_literal(typ: Type) -> Type:
10231027
if typ.last_known_value:
10241028
return typ.last_known_value
10251029
elif typ.type.is_enum:
1026-
enum_values = typ.get_enum_values()
1030+
enum_values = typ.type.enum_members
10271031
if len(enum_values) == 1:
10281032
return LiteralType(value=enum_values[0], fallback=typ)
10291033
return original_type

mypy/types.py

+1-18
Original file line numberDiff line numberDiff line change
@@ -150,10 +150,6 @@
150150

151151
OVERLOAD_NAMES: Final = ("typing.overload", "typing_extensions.overload")
152152

153-
# Attributes that can optionally be defined in the body of a subclass of
154-
# enum.Enum but are removed from the class __dict__ by EnumMeta.
155-
ENUM_REMOVED_PROPS: Final = ("_ignore_", "_order_", "__order__")
156-
157153
NEVER_NAMES: Final = (
158154
"typing.NoReturn",
159155
"typing_extensions.NoReturn",
@@ -1559,23 +1555,10 @@ def is_singleton_type(self) -> bool:
15591555
# Also make this return True if the type corresponds to NotImplemented?
15601556
return (
15611557
self.type.is_enum
1562-
and len(self.get_enum_values()) == 1
1558+
and len(self.type.enum_members) == 1
15631559
or self.type.fullname in {"builtins.ellipsis", "types.EllipsisType"}
15641560
)
15651561

1566-
def get_enum_values(self) -> list[str]:
1567-
"""Return the list of values for an Enum."""
1568-
return [
1569-
name
1570-
for name, sym in self.type.names.items()
1571-
if (
1572-
isinstance(sym.node, mypy.nodes.Var)
1573-
and name not in ENUM_REMOVED_PROPS
1574-
and not name.startswith("__")
1575-
and sym.node.has_explicit_value
1576-
)
1577-
]
1578-
15791562

15801563
class FunctionLike(ProperType):
15811564
"""Abstract base class for function types."""

mypyc/irbuild/classdef.py

+3-2
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from typing import Callable, Final
88

99
from mypy.nodes import (
10+
EXCLUDED_ENUM_ATTRIBUTES,
1011
TYPE_VAR_TUPLE_KIND,
1112
AssignmentStmt,
1213
CallExpr,
@@ -27,7 +28,7 @@
2728
TypeParam,
2829
is_class_var,
2930
)
30-
from mypy.types import ENUM_REMOVED_PROPS, Instance, UnboundType, get_proper_type
31+
from mypy.types import Instance, UnboundType, get_proper_type
3132
from mypyc.common import PROPSET_PREFIX
3233
from mypyc.ir.class_ir import ClassIR, NonExtClassInfo
3334
from mypyc.ir.func_ir import FuncDecl, FuncSignature
@@ -683,7 +684,7 @@ def add_non_ext_class_attr(
683684
cdef.info.bases
684685
and cdef.info.bases[0].type.fullname == "enum.Enum"
685686
# Skip these since Enum will remove it
686-
and lvalue.name not in ENUM_REMOVED_PROPS
687+
and lvalue.name not in EXCLUDED_ENUM_ATTRIBUTES
687688
):
688689
# Enum values are always boxed, so use object_rprimitive.
689690
attr_to_cache.append((lvalue, object_rprimitive))

test-data/unit/check-enum.test

+5-2
Original file line numberDiff line numberDiff line change
@@ -1788,14 +1788,17 @@ import lib
17881788

17891789
[file lib.pyi]
17901790
from enum import Enum
1791-
class A(Enum):
1791+
class A(Enum): # E: Detected enum "lib.A" in a type stub with zero members. There is a chance this is due to a recent change in the semantics of enum membership. If so, use `member = value` to mark an enum member, instead of `member: type` \
1792+
# N: See https://typing.readthedocs.io/en/latest/spec/enums.html#defining-members
17921793
x: int
17931794
class B(A): # E: Cannot extend enum with existing members: "A"
17941795
x = 1 # E: Cannot override writable attribute "x" with a final one
17951796

17961797
class C(Enum):
17971798
x = 1
1798-
class D(C): # E: Cannot extend enum with existing members: "C"
1799+
class D(C): # E: Cannot extend enum with existing members: "C" \
1800+
# E: Detected enum "lib.D" in a type stub with zero members. There is a chance this is due to a recent change in the semantics of enum membership. If so, use `member = value` to mark an enum member, instead of `member: type` \
1801+
# N: See https://typing.readthedocs.io/en/latest/spec/enums.html#defining-members
17991802
x: int # E: Cannot assign to final name "x"
18001803
[builtins fixtures/bool.pyi]
18011804

0 commit comments

Comments
 (0)