Skip to content

Commit

Permalink
Issue warning for enum with no members in stub (python#18068)
Browse files Browse the repository at this point in the history
Follow up to python#17207
  • Loading branch information
hauntsaninja authored Oct 30, 2024
1 parent 3596793 commit 58f7628
Show file tree
Hide file tree
Showing 10 changed files with 118 additions and 59 deletions.
40 changes: 40 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/)).
Expand Down
30 changes: 20 additions & 10 deletions mypy/checker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
4 changes: 2 additions & 2 deletions mypy/checkmember.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
ARG_POS,
ARG_STAR,
ARG_STAR2,
EXCLUDED_ENUM_ATTRIBUTES,
SYMBOL_FUNCBASE_TYPES,
Context,
Decorator,
Expand Down Expand Up @@ -48,7 +49,6 @@
type_object_type_from_function,
)
from mypy.types import (
ENUM_REMOVED_PROPS,
AnyType,
CallableType,
DeletedType,
Expand Down Expand Up @@ -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("_", "") != "":
Expand Down
17 changes: 17 additions & 0 deletions mypy/nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand Down
5 changes: 3 additions & 2 deletions mypy/semanal_enum.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from mypy.nodes import (
ARG_NAMED,
ARG_POS,
EXCLUDED_ENUM_ATTRIBUTES,
MDEF,
AssignmentStmt,
CallExpr,
Expand All @@ -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.
Expand All @@ -43,7 +44,7 @@
"value",
"_name_",
"_value_",
*ENUM_REMOVED_PROPS,
*EXCLUDED_ENUM_ATTRIBUTES,
# Also attributes from `object`:
"__module__",
"__annotations__",
Expand Down
26 changes: 13 additions & 13 deletions mypy/test/teststubtest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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="""
Expand All @@ -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="""
Expand All @@ -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="""
Expand Down Expand Up @@ -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="""
Expand All @@ -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
Expand Down Expand Up @@ -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']
Expand Down
24 changes: 14 additions & 10 deletions mypy/typeops.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand All @@ -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}
),
Expand Down Expand Up @@ -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
Expand Down
19 changes: 1 addition & 18 deletions mypy/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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."""
Expand Down
5 changes: 3 additions & 2 deletions mypyc/irbuild/classdef.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from typing import Callable, Final

from mypy.nodes import (
EXCLUDED_ENUM_ATTRIBUTES,
TYPE_VAR_TUPLE_KIND,
AssignmentStmt,
CallExpr,
Expand All @@ -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
Expand Down Expand Up @@ -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))
Expand Down
7 changes: 5 additions & 2 deletions test-data/unit/check-enum.test
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down

0 comments on commit 58f7628

Please sign in to comment.