[ty] List all enum members (#19283)

## Summary

Adds a way to list all members of an `Enum` and implements almost all of
the mechanisms by which members are distinguished from non-members
([spec](https://typing.python.org/en/latest/spec/enums.html#defining-members)).
This has no effect on actual enums, so far.

## Test Plan

New Markdown tests using `ty_extensions.enum_members`.
This commit is contained in:
David Peter 2025-07-14 13:18:17 +02:00 committed by GitHub
parent cb530a0216
commit f22da352db
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 639 additions and 2 deletions

View file

@ -0,0 +1,439 @@
# Enums
## Basic
```py
from enum import Enum
class Color(Enum):
RED = 1
GREEN = 2
BLUE = 3
reveal_type(Color.RED) # revealed: @Todo(Attribute access on enum classes)
reveal_type(Color.RED.value) # revealed: @Todo(Attribute access on enum classes)
# TODO: Should be `Color` or `Literal[Color.RED]`
reveal_type(Color["RED"]) # revealed: Unknown
# TODO: Could be `Literal[Color.RED]` to be more precise
reveal_type(Color(1)) # revealed: Color
reveal_type(Color.RED in Color) # revealed: bool
```
## Enum members
### Basic
Simple enums with integer or string values:
```py
from enum import Enum
from ty_extensions import enum_members
class ColorInt(Enum):
RED = 1
GREEN = 2
BLUE = 3
# revealed: tuple[Literal["RED"], Literal["GREEN"], Literal["BLUE"]]
reveal_type(enum_members(ColorInt))
class ColorStr(Enum):
RED = "red"
GREEN = "green"
BLUE = "blue"
# revealed: tuple[Literal["RED"], Literal["GREEN"], Literal["BLUE"]]
reveal_type(enum_members(ColorStr))
```
### When deriving from `IntEnum`
```py
from enum import IntEnum
from ty_extensions import enum_members
class ColorInt(IntEnum):
RED = 1
GREEN = 2
BLUE = 3
# revealed: tuple[Literal["RED"], Literal["GREEN"], Literal["BLUE"]]
reveal_type(enum_members(ColorInt))
```
### Declared non-member attributes
Attributes on the enum class that are declared are not considered members of the enum:
```py
from enum import Enum
from ty_extensions import enum_members
class Answer(Enum):
YES = 1
NO = 2
non_member_1: int
# TODO: this could be considered an error:
non_member_1: str = "some value"
# revealed: tuple[Literal["YES"], Literal["NO"]]
reveal_type(enum_members(Answer))
```
### Non-member attributes with disallowed type
Methods, callables, descriptors (including properties), and nested classes that are defined in the
class are not treated as enum members:
```py
from enum import Enum
from ty_extensions import enum_members
from typing import Callable, Literal
def identity(x) -> int:
return x
class Descriptor:
def __get__(self, instance, owner):
return 0
class Answer(Enum):
YES = 1
NO = 2
def some_method(self) -> None: ...
@staticmethod
def some_static_method() -> None: ...
@classmethod
def some_class_method(cls) -> None: ...
some_callable = lambda x: 0
declared_callable: Callable[[int], int] = identity
function_reference = identity
some_descriptor = Descriptor()
@property
def some_property(self) -> str:
return ""
class NestedClass: ...
# revealed: tuple[Literal["YES"], Literal["NO"]]
reveal_type(enum_members(Answer))
```
### `enum.property`
Enum attributes that are defined using `enum.property` are not considered members:
```toml
[environment]
python-version = "3.11"
```
```py
from enum import Enum, property as enum_property
from ty_extensions import enum_members
class Answer(Enum):
YES = 1
NO = 2
@enum_property
def some_property(self) -> str:
return "property value"
# revealed: tuple[Literal["YES"], Literal["NO"]]
reveal_type(enum_members(Answer))
```
### `types.DynamicClassAttribute`
Attributes defined using `types.DynamicClassAttribute` are not considered members:
```py
from enum import Enum
from ty_extensions import enum_members
from types import DynamicClassAttribute
class Answer(Enum):
YES = 1
NO = 2
@DynamicClassAttribute
def dynamic_property(self) -> str:
return "dynamic value"
# revealed: tuple[Literal["YES"], Literal["NO"]]
reveal_type(enum_members(Answer))
```
### In stubs
Stubs can optionally use `...` for the actual value:
```pyi
from enum import Enum
from ty_extensions import enum_members
from typing import cast
class Color(Enum):
RED = ...
GREEN = cast(int, ...)
BLUE = 3
# revealed: tuple[Literal["RED"], Literal["GREEN"], Literal["BLUE"]]
reveal_type(enum_members(Color))
```
### Aliases
Enum members can have aliases, which are not considered separate members:
```py
from enum import Enum
from ty_extensions import enum_members
class Answer(Enum):
YES = 1
NO = 2
DEFINITELY = YES
# revealed: tuple[Literal["YES"], Literal["NO"]]
reveal_type(enum_members(Answer))
```
### Using `auto()`
```py
from enum import Enum, auto
from ty_extensions import enum_members
class Answer(Enum):
YES = auto()
NO = auto()
# revealed: tuple[Literal["YES"], Literal["NO"]]
reveal_type(enum_members(Answer))
```
Combining aliases with `auto()`:
```py
from enum import Enum, auto
class Answer(Enum):
YES = auto()
NO = auto()
DEFINITELY = YES
# TODO: This should ideally be `tuple[Literal["YES"], Literal["NO"]]`
# revealed: tuple[Literal["YES"], Literal["NO"], Literal["DEFINITELY"]]
reveal_type(enum_members(Answer))
```
### `member` and `nonmember`
```toml
[environment]
python-version = "3.11"
```
```py
from enum import Enum, auto, member, nonmember
from ty_extensions import enum_members
class Answer(Enum):
YES = member(1)
NO = member(2)
OTHER = nonmember(17)
# revealed: tuple[Literal["YES"], Literal["NO"]]
reveal_type(enum_members(Answer))
```
`member` can also be used as a decorator:
```py
from enum import Enum, member
from ty_extensions import enum_members
class Answer(Enum):
yes = member(1)
no = member(2)
@member
def maybe(self) -> None:
return
# revealed: tuple[Literal["yes"], Literal["no"], Literal["maybe"]]
reveal_type(enum_members(Answer))
```
### Class-private names
An attribute with a [class-private name] (beginning with, but not ending in, a double underscore) is
treated as a non-member:
```py
from enum import Enum
from ty_extensions import enum_members
class Answer(Enum):
YES = 1
NO = 2
__private_member = 3
__maybe__ = 4
# revealed: tuple[Literal["YES"], Literal["NO"], Literal["__maybe__"]]
reveal_type(enum_members(Answer))
```
### Ignored names
An enum class can define a class symbol named `_ignore_`. This can be a string containing a
whitespace-delimited list of names:
```py
from enum import Enum
from ty_extensions import enum_members
class Answer(Enum):
_ignore_ = "IGNORED _other_ignored also_ignored"
YES = 1
NO = 2
IGNORED = 3
_other_ignored = "test"
also_ignored = "test2"
# revealed: tuple[Literal["YES"], Literal["NO"]]
reveal_type(enum_members(Answer))
```
`_ignore_` can also be a list of names:
```py
class Answer2(Enum):
_ignore_ = ["MAYBE", "_other"]
YES = 1
NO = 2
MAYBE = 3
_other = "test"
# TODO: This should be `tuple[Literal["YES"], Literal["NO"]]`
# revealed: tuple[Literal["YES"], Literal["NO"], Literal["MAYBE"], Literal["_other"]]
reveal_type(enum_members(Answer2))
```
### Special names
Make sure that special names like `name` and `value` can be used for enum members (without
conflicting with `Enum.name` and `Enum.value`):
```py
from enum import Enum
from ty_extensions import enum_members
class Answer(Enum):
name = 1
value = 2
# revealed: tuple[Literal["name"], Literal["value"]]
reveal_type(enum_members(Answer))
# TODO: These should be `Answer` or `Literal[Answer.name]`/``Literal[Answer.value]`
reveal_type(Answer.name) # revealed: @Todo(Attribute access on enum classes)
reveal_type(Answer.value) # revealed: @Todo(Attribute access on enum classes)
```
## Iterating over enum members
```py
from enum import Enum
class Color(Enum):
RED = 1
GREEN = 2
BLUE = 3
for color in Color:
# TODO: Should be `Color`
reveal_type(color) # revealed: Unknown
# TODO: Should be `list[Color]`
reveal_type(list(Color)) # revealed: list[Unknown]
```
## Properties of enum types
### Implicitly final
An enum with one or more defined members cannot be subclassed. They are implicitly "final".
```py
from enum import Enum
class Color(Enum):
RED = 1
GREEN = 2
BLUE = 3
# TODO: This should emit an error
class ExtendedColor(Color):
YELLOW = 4
def f(color: Color):
if isinstance(color, int):
# TODO: This should be `Never`
reveal_type(color) # revealed: Color & int
```
An `Enum` subclass without any defined members can be subclassed:
```py
from enum import Enum
from ty_extensions import enum_members
class MyEnum(Enum):
def some_method(self) -> None:
pass
class Answer(MyEnum):
YES = 1
NO = 2
# revealed: tuple[Literal["YES"], Literal["NO"]]
reveal_type(enum_members(Answer))
```
## Custom enum types
To do: <https://typing.python.org/en/latest/spec/enums.html#enum-definition>
## Function syntax
To do: <https://typing.python.org/en/latest/spec/enums.html#enum-definition>
## Exhaustiveness checking
To do
## References
- Typing spec: <https://typing.python.org/en/latest/spec/enums.html>
- Documentation: <https://docs.python.org/3/library/enum.html>
[class-private name]: https://docs.python.org/3/reference/lexical_analysis.html#reserved-classes-of-identifiers