Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Issue warning for enum with no members in stub #18068

Merged
merged 5 commits into from
Oct 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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),
JelleZijlstra marked this conversation as resolved.
Show resolved Hide resolved
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:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is just an optimization because previously we were iterating over the whole dict unnecessarily, right?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yup

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
Loading