diff --git a/CHANGELOG.md b/CHANGELOG.md index 801d592945c1..a8208fb48294 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,46 @@ ## Next release +### Change to enum membership semantics + +As per the updated [typing specification for enums](https://typing.readthedocs.io/en/latest/spec/enums.html#defining-members), +enum members must be left unannotated. + +```python +class Pet(Enum): + CAT = 1 # Member attribute + DOG = 2 # Member attribute + WOLF: int = 3 # New error: Enum members must be left unannotated + + species: str # Considered a non-member attribute +``` + +In particular, the specification change can result in issues in type stubs (`.pyi` files), since +historically it was common to leave the value absent: + +```python +# In a type stub (.pyi file) + +class Pet(Enum): + # Change in semantics: previously considered members, now non-member attributes + CAT: int + DOG: int + + # Mypy will now issue a warning if it detects this situation in type stubs: + # > Detected enum "Pet" 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` + +class Pet(Enum): + # As per the specification, you should now do one of the following: + DOG = 1 # Member attribute with value 1 and known type + WOLF = cast(int, ...) # Member attribute with unknown value but known type + LION = ... # Member attribute with unknown value and unknown type +``` + +Contributed by Terence Honles in PR [17207](https://github.com/python/mypy/pull/17207) and +Shantanu Jain in PR [18068](https://github.com/python/mypy/pull/18068). + ## Mypy 1.13 We’ve just uploaded mypy 1.13 to the Python Package Index ([PyPI](https://pypi.org/project/mypy/)). diff --git a/mypy/checker.py b/mypy/checker.py index b26970d996b3..a650bdf2a639 100644 --- a/mypy/checker.py +++ b/mypy/checker.py @@ -2588,20 +2588,30 @@ def check_typevar_defaults(self, tvars: Sequence[TypeVarLikeType]) -> None: def check_enum(self, defn: ClassDef) -> None: assert defn.info.is_enum - if defn.info.fullname not in ENUM_BASES: - for sym in defn.info.names.values(): - if ( - isinstance(sym.node, Var) - and sym.node.has_explicit_value - and sym.node.name == "__members__" - ): - # `__members__` will always be overwritten by `Enum` and is considered - # read-only so we disallow assigning a value to it - self.fail(message_registry.ENUM_MEMBERS_ATTR_WILL_BE_OVERRIDEN, sym.node) + if defn.info.fullname not in ENUM_BASES and "__members__" in defn.info.names: + sym = defn.info.names["__members__"] + if isinstance(sym.node, Var) and sym.node.has_explicit_value: + # `__members__` will always be overwritten by `Enum` and is considered + # read-only so we disallow assigning a value to it + self.fail(message_registry.ENUM_MEMBERS_ATTR_WILL_BE_OVERRIDEN, sym.node) for base in defn.info.mro[1:-1]: # we don't need self and `object` if base.is_enum and base.fullname not in ENUM_BASES: self.check_final_enum(defn, base) + if self.is_stub and self.tree.fullname not in {"enum", "_typeshed"}: + if not defn.info.enum_members: + self.fail( + f'Detected enum "{defn.info.fullname}" 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`", + defn, + ) + self.note( + "See https://typing.readthedocs.io/en/latest/spec/enums.html#defining-members", + defn, + ) + self.check_enum_bases(defn) self.check_enum_new(defn) diff --git a/mypy/checkmember.py b/mypy/checkmember.py index e6f7570c3df2..9dc8d5475b1a 100644 --- a/mypy/checkmember.py +++ b/mypy/checkmember.py @@ -17,6 +17,7 @@ ARG_POS, ARG_STAR, ARG_STAR2, + EXCLUDED_ENUM_ATTRIBUTES, SYMBOL_FUNCBASE_TYPES, Context, Decorator, @@ -48,7 +49,6 @@ type_object_type_from_function, ) from mypy.types import ( - ENUM_REMOVED_PROPS, AnyType, CallableType, DeletedType, @@ -1173,7 +1173,7 @@ def analyze_enum_class_attribute_access( itype: Instance, name: str, mx: MemberContext ) -> Type | None: # Skip these since Enum will remove it - if name in ENUM_REMOVED_PROPS: + if name in EXCLUDED_ENUM_ATTRIBUTES: return report_missing_attribute(mx.original_type, itype, name, mx) # Dunders and private names are not Enum members if name.startswith("__") and name.replace("_", "") != "": diff --git a/mypy/nodes.py b/mypy/nodes.py index 7b620bd52008..dabfb463cc95 100644 --- a/mypy/nodes.py +++ b/mypy/nodes.py @@ -2902,6 +2902,10 @@ def accept(self, visitor: ExpressionVisitor[T]) -> T: } ) +# Attributes that can optionally be defined in the body of a subclass of +# enum.Enum but are removed from the class __dict__ by EnumMeta. +EXCLUDED_ENUM_ATTRIBUTES: Final = frozenset({"_ignore_", "_order_", "__order__"}) + class TypeInfo(SymbolNode): """The type structure of a single class. @@ -3229,6 +3233,19 @@ def protocol_members(self) -> list[str]: members.add(name) return sorted(members) + @property + def enum_members(self) -> list[str]: + return [ + name + for name, sym in self.names.items() + if ( + isinstance(sym.node, Var) + and name not in EXCLUDED_ENUM_ATTRIBUTES + and not name.startswith("__") + and sym.node.has_explicit_value + ) + ] + def __getitem__(self, name: str) -> SymbolTableNode: n = self.get(name) if n: diff --git a/mypy/semanal_enum.py b/mypy/semanal_enum.py index 0094b719bc96..b1e267b4c781 100644 --- a/mypy/semanal_enum.py +++ b/mypy/semanal_enum.py @@ -10,6 +10,7 @@ from mypy.nodes import ( ARG_NAMED, ARG_POS, + EXCLUDED_ENUM_ATTRIBUTES, MDEF, AssignmentStmt, CallExpr, @@ -30,7 +31,7 @@ ) from mypy.options import Options from mypy.semanal_shared import SemanticAnalyzerInterface -from mypy.types import ENUM_REMOVED_PROPS, LiteralType, get_proper_type +from mypy.types import LiteralType, get_proper_type # Note: 'enum.EnumMeta' is deliberately excluded from this list. Classes that directly use # enum.EnumMeta do not necessarily automatically have the 'name' and 'value' attributes. @@ -43,7 +44,7 @@ "value", "_name_", "_value_", - *ENUM_REMOVED_PROPS, + *EXCLUDED_ENUM_ATTRIBUTES, # Also attributes from `object`: "__module__", "__annotations__", diff --git a/mypy/test/teststubtest.py b/mypy/test/teststubtest.py index 4cab62875647..fcbf07b4d371 100644 --- a/mypy/test/teststubtest.py +++ b/mypy/test/teststubtest.py @@ -1267,9 +1267,9 @@ def test_enum(self) -> Iterator[Case]: yield Case( stub=""" class X(enum.Enum): - a: int - b: str - c: str + a = ... + b = "asdf" + c = "oops" """, runtime=""" class X(enum.Enum): @@ -1282,8 +1282,8 @@ class X(enum.Enum): yield Case( stub=""" class Flags1(enum.Flag): - a: int - b: int + a = ... + b = 2 def foo(x: Flags1 = ...) -> None: ... """, runtime=""" @@ -1297,8 +1297,8 @@ def foo(x=Flags1.a|Flags1.b): pass yield Case( stub=""" class Flags2(enum.Flag): - a: int - b: int + a = ... + b = 2 def bar(x: Flags2 | None = None) -> None: ... """, runtime=""" @@ -1312,8 +1312,8 @@ def bar(x=Flags2.a|Flags2.b): pass yield Case( stub=""" class Flags3(enum.Flag): - a: int - b: int + a = ... + b = 2 def baz(x: Flags3 | None = ...) -> None: ... """, runtime=""" @@ -1346,8 +1346,8 @@ class WeirdEnum(enum.Enum): yield Case( stub=""" class Flags4(enum.Flag): - a: int - b: int + a = 1 + b = 2 def spam(x: Flags4 | None = None) -> None: ... """, runtime=""" @@ -1362,7 +1362,7 @@ def spam(x=Flags4(0)): pass stub=""" from typing_extensions import Final, Literal class BytesEnum(bytes, enum.Enum): - a: bytes + a = b'foo' FOO: Literal[BytesEnum.a] BAR: Final = BytesEnum.a BAZ: BytesEnum @@ -1897,7 +1897,7 @@ def test_good_literal(self) -> Iterator[Case]: import enum class Color(enum.Enum): - RED: int + RED = ... NUM: Literal[1] CHAR: Literal['a'] diff --git a/mypy/typeops.py b/mypy/typeops.py index 36e929284bf4..9e5e347533c6 100644 --- a/mypy/typeops.py +++ b/mypy/typeops.py @@ -957,16 +957,20 @@ class Status(Enum): items = [ try_expanding_sum_type_to_union(item, target_fullname) for item in typ.relevant_items() ] - elif isinstance(typ, Instance) and typ.type.fullname == target_fullname: - if typ.type.is_enum: - items = [LiteralType(name, typ) for name in typ.get_enum_values()] - elif typ.type.fullname == "builtins.bool": + return make_simplified_union(items, contract_literals=False) + + if isinstance(typ, Instance) and typ.type.fullname == target_fullname: + if typ.type.fullname == "builtins.bool": items = [LiteralType(True, typ), LiteralType(False, typ)] - else: - return typ + return make_simplified_union(items, contract_literals=False) + + if typ.type.is_enum: + items = [LiteralType(name, typ) for name in typ.type.enum_members] + if not items: + return typ + return make_simplified_union(items, contract_literals=False) - # if the expanded union would be `Never` leave the type as is - return typ if not items else make_simplified_union(items, contract_literals=False) + return typ 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] if fullname not in sum_types: sum_types[fullname] = ( ( - set(typ.fallback.get_enum_values()) + set(typ.fallback.type.enum_members) if typ.fallback.type.is_enum else {True, False} ), @@ -1023,7 +1027,7 @@ def coerce_to_literal(typ: Type) -> Type: if typ.last_known_value: return typ.last_known_value elif typ.type.is_enum: - enum_values = typ.get_enum_values() + enum_values = typ.type.enum_members if len(enum_values) == 1: return LiteralType(value=enum_values[0], fallback=typ) return original_type diff --git a/mypy/types.py b/mypy/types.py index 9632bd22adb6..cc9c65299ee8 100644 --- a/mypy/types.py +++ b/mypy/types.py @@ -150,10 +150,6 @@ OVERLOAD_NAMES: Final = ("typing.overload", "typing_extensions.overload") -# Attributes that can optionally be defined in the body of a subclass of -# enum.Enum but are removed from the class __dict__ by EnumMeta. -ENUM_REMOVED_PROPS: Final = ("_ignore_", "_order_", "__order__") - NEVER_NAMES: Final = ( "typing.NoReturn", "typing_extensions.NoReturn", @@ -1559,23 +1555,10 @@ def is_singleton_type(self) -> bool: # Also make this return True if the type corresponds to NotImplemented? return ( self.type.is_enum - and len(self.get_enum_values()) == 1 + and len(self.type.enum_members) == 1 or self.type.fullname in {"builtins.ellipsis", "types.EllipsisType"} ) - def get_enum_values(self) -> list[str]: - """Return the list of values for an Enum.""" - return [ - name - for name, sym in self.type.names.items() - if ( - isinstance(sym.node, mypy.nodes.Var) - and name not in ENUM_REMOVED_PROPS - and not name.startswith("__") - and sym.node.has_explicit_value - ) - ] - class FunctionLike(ProperType): """Abstract base class for function types.""" diff --git a/mypyc/irbuild/classdef.py b/mypyc/irbuild/classdef.py index 6ff6308b81d8..590bd12ce7b3 100644 --- a/mypyc/irbuild/classdef.py +++ b/mypyc/irbuild/classdef.py @@ -7,6 +7,7 @@ from typing import Callable, Final from mypy.nodes import ( + EXCLUDED_ENUM_ATTRIBUTES, TYPE_VAR_TUPLE_KIND, AssignmentStmt, CallExpr, @@ -27,7 +28,7 @@ TypeParam, is_class_var, ) -from mypy.types import ENUM_REMOVED_PROPS, Instance, UnboundType, get_proper_type +from mypy.types import Instance, UnboundType, get_proper_type from mypyc.common import PROPSET_PREFIX from mypyc.ir.class_ir import ClassIR, NonExtClassInfo from mypyc.ir.func_ir import FuncDecl, FuncSignature @@ -683,7 +684,7 @@ def add_non_ext_class_attr( cdef.info.bases and cdef.info.bases[0].type.fullname == "enum.Enum" # Skip these since Enum will remove it - and lvalue.name not in ENUM_REMOVED_PROPS + and lvalue.name not in EXCLUDED_ENUM_ATTRIBUTES ): # Enum values are always boxed, so use object_rprimitive. attr_to_cache.append((lvalue, object_rprimitive)) diff --git a/test-data/unit/check-enum.test b/test-data/unit/check-enum.test index 09e2abb30358..533da6652f8f 100644 --- a/test-data/unit/check-enum.test +++ b/test-data/unit/check-enum.test @@ -1788,14 +1788,17 @@ import lib [file lib.pyi] from enum import Enum -class A(Enum): +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` \ + # N: See https://typing.readthedocs.io/en/latest/spec/enums.html#defining-members x: int class B(A): # E: Cannot extend enum with existing members: "A" x = 1 # E: Cannot override writable attribute "x" with a final one class C(Enum): x = 1 -class D(C): # E: Cannot extend enum with existing members: "C" +class D(C): # E: Cannot extend enum with existing members: "C" \ + # 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` \ + # N: See https://typing.readthedocs.io/en/latest/spec/enums.html#defining-members x: int # E: Cannot assign to final name "x" [builtins fixtures/bool.pyi]