Skip to content

Commit 8fbba31

Browse files
authored
Add Enum class that supports deprecated members (#88)
This commit introduces a new `frequenz.core.enum` module that provides a drop-in replacement for the standard library's `enum.Enum` class, with added support for deprecating enum members.
2 parents 4bd8b11 + 0d8fdb5 commit 8fbba31

File tree

8 files changed

+336
-12
lines changed

8 files changed

+336
-12
lines changed

.github/ISSUE_TEMPLATE/bug.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,9 @@ body:
5252
- Collections (part:collections)
5353
- Date and time utilities (part:datetime)
5454
- Documentation (part:docs)
55+
- Enum utilities (part:enum)
5556
- IDs (part:id)
56-
- Mathemathics utilities (part:math)
57+
- Mathematics utilities (part:math)
5758
- Module utilities (part:module)
5859
- Logging utilities (part:logging)
5960
- Type hints and typing (part:typing)

.github/keylabeler.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ labelMappings:
1616
"part:collections": "part:collections"
1717
"part:datetime": "part:datetime"
1818
"part:docs": "part:docs"
19+
"part:enum": "part:enum"
1920
"part:id": "part:id"
2021
"part:logging": "part:logging"
2122
"part:math": "part:math"

.github/labeler.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,14 @@
6363
- "examples/**"
6464
- LICENSE
6565

66+
"part:enum":
67+
- changed-files:
68+
- any-glob-to-any-file:
69+
- "src/frequenz/core/enum.py"
70+
- "src/frequenz/core/enum/**"
71+
- "tests/test_enum.py"
72+
- "tests/enum/**"
73+
6674
"part:id":
6775
- changed-files:
6876
- any-glob-to-any-file:

README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,25 @@ positive = Interval(0, None) # [0, ∞]
9292
assert 1000 in positive # True
9393
```
9494

95+
### `Enum` with deprecated members
96+
97+
Define enums with deprecated members that raise deprecation warnings when
98+
accessed:
99+
100+
```python
101+
from frequenz.core.enum import Enum, DeprecatedMember
102+
103+
class TaskStatus(Enum):
104+
OPEN = 1
105+
IN_PROGRESS = 2
106+
PENDING = DeprecatedMember(1, "PENDING is deprecated, use OPEN instead")
107+
DONE = DeprecatedMember(3, "DONE is deprecated, use FINISHED instead")
108+
FINISHED = 4
109+
110+
status1 = TaskStatus.PENDING # Warns: "PENDING is deprecated, use OPEN instead"
111+
assert status1 is TaskStatus.OPEN
112+
```
113+
95114
### Typing Utilities
96115

97116
Disable class constructors to enforce factory pattern usage:

RELEASE_NOTES.md

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,21 @@
11
# Frequenz Core Library Release Notes
22

3-
## Summary
4-
5-
<!-- Here goes a general summary of what this release is about -->
6-
7-
## Upgrading
3+
## New Features
84

9-
<!-- Here goes notes on how to upgrade from previous versions, including deprecations and what they should be replaced with -->
5+
- A new `frequenz.core.enum` module was added, providing a drop-in replacement `Enum` that supports deprecating members.
106

11-
## New Features
7+
Example:
128

13-
<!-- Here goes the main new features and examples or instructions on how to use them -->
9+
```python
10+
from frequenz.core.enum import Enum, DeprecatedMember
1411

15-
## Bug Fixes
12+
class TaskStatus(Enum):
13+
OPEN = 1
14+
IN_PROGRESS = 2
15+
PENDING = DeprecatedMember(1, "PENDING is deprecated, use OPEN instead")
16+
DONE = DeprecatedMember(3, "DONE is deprecated, use FINISHED instead")
17+
FINISHED = 4
1618

17-
* `BaseId` will now log instead of raising a warning when a duplicate prefix is detected. This is to fix [a problem with code examples](https://github.com/frequenz-floss/frequenz-repo-config-python/issues/421) being tested using sybil and the class being imported multiple times, which caused the exception to be raised. We first tried to use `warn()` but that complicated the building process for all downstream projects, requiring them to add an exception for exactly this warning.
19+
status1 = TaskStatus.PENDING # Warns: "PENDING is deprecated, use OPEN instead"
20+
assert status1 is TaskStatus.OPEN
21+
```

src/frequenz/core/datetime.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# License: MIT
22
# Copyright © 2024 Frequenz Energy-as-a-Service GmbH
33

4-
"""Timeseries basic types."""
4+
"""Date and time utilities."""
55

66
from datetime import datetime, timezone
77
from typing import Final

src/frequenz/core/enum.py

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
# License: MIT
2+
# Copyright © 2025 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Enum utilities with support for deprecated members.
5+
6+
This module provides an [`Enum`][frequenz.core.enum.Enum] base class that extends the
7+
standard library's [`enum.Enum`][] to support marking certain members as deprecated.
8+
9+
See the [class documentation][frequenz.core.enum.Enum] for details and examples.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import enum
15+
import warnings
16+
from collections.abc import Mapping
17+
from typing import TYPE_CHECKING, Any, ClassVar, Self, TypeVar, cast
18+
19+
# Note: This module contains more casts and uses of Any than what's typically
20+
# ideal. This is because type hinting EnumType and Enum subclasses is quite
21+
# challenging, as there is a lot of special behavior in `mypy` for these classes.
22+
#
23+
# The resulting enum should be treated as a regular enum by mypy, so hopefully everthing
24+
# still works as expected.
25+
26+
EnumT = TypeVar("EnumT", bound=enum.Enum)
27+
"""Type variable for enum types."""
28+
29+
30+
class DeprecatedMemberWarning(DeprecationWarning):
31+
"""Warning category for deprecated enum members."""
32+
33+
34+
class DeprecatedMember:
35+
"""Marker used in enum class bodies to declare deprecated members.
36+
37+
Please read the [`Enum`][frequenz.core.enum.Enum] documentation for details and
38+
examples.
39+
"""
40+
41+
# Using slots is just an optimization to make the class more lightweight and avoid
42+
# the creation of a `__dict__` for each instance and its corresponding lookup.
43+
__slots__ = ("value", "message")
44+
45+
def __init__(self, value: Any, message: str) -> None:
46+
"""Initialize this instance."""
47+
self.value = value
48+
self.message = message
49+
50+
51+
class DeprecatingEnumType(enum.EnumType):
52+
"""Enum metaclass that supports `DeprecatedMember` wrappers.
53+
54+
Tip:
55+
Normally it is not necessary to use this class directly, use
56+
[`Enum`][frequenz.core.enum.Enum] instead.
57+
58+
Behavior:
59+
60+
- In the class body, members may be declared as `NAME = DeprecatedMember(value, msg)`.
61+
- During class creation, these wrappers are replaced with `value` so that
62+
a normal enum member or alias is created by [`EnumType`][enum.EnumType].
63+
- The deprecated names are recorded so that:
64+
65+
* `MyEnum.NAME` warns (attribute access by name)
66+
* `MyEnum["NAME"]` warns (lookup by name)
67+
* `MyEnum(value)` warns **only if** the resolved member has **no**
68+
non-deprecated aliases (all names for that member are deprecated).
69+
"""
70+
71+
def __new__( # pylint: disable=too-many-locals
72+
mcs,
73+
name: str,
74+
bases: tuple[type[EnumT], ...],
75+
classdict: Mapping[str, Any],
76+
**kw: Any,
77+
) -> type[EnumT]:
78+
"""Create the new enum class, rewriting `DeprecatedMember` instances."""
79+
deprecated_names: dict[str, str] = {}
80+
prepared = super().__prepare__(name, bases, **kw)
81+
82+
# Unwrap DeprecatedMembers and record them as deprecated
83+
for key, value in classdict.items():
84+
if isinstance(value, DeprecatedMember):
85+
deprecated_names[key] = value.message
86+
prepared[key] = value.value
87+
else:
88+
prepared[key] = value
89+
90+
cls = cast(type[EnumT], super().__new__(mcs, name, bases, prepared, **kw))
91+
92+
# Build alias groups: member -> list of names
93+
member_to_names: dict[EnumT, list[str]] = {}
94+
member: EnumT
95+
for member_name, member in cls.__members__.items():
96+
member_to_names.setdefault(member, []).append(member_name)
97+
98+
warned_by_member: dict[EnumT, str] = {}
99+
for member, names in member_to_names.items():
100+
# warn on value only if all alias names are deprecated
101+
deprecated_aliases = [n for n in names if n in deprecated_names]
102+
if deprecated_aliases and len(deprecated_aliases) == len(names):
103+
warned_by_member[member] = deprecated_names[deprecated_aliases[0]]
104+
105+
# Inject maps quietly
106+
type.__setattr__(cls, "__deprecated_names__", deprecated_names)
107+
type.__setattr__(cls, "__deprecated_value_map__", warned_by_member)
108+
109+
return cls
110+
111+
@staticmethod
112+
def _name_map(cls_: type[Any]) -> Mapping[str, str]:
113+
"""Map from member names to deprecation messages."""
114+
return cast(
115+
Mapping[str, str],
116+
type.__getattribute__(cls_, "__dict__").get("__deprecated_names__", {}),
117+
)
118+
119+
@staticmethod
120+
def _value_map(cls_: type[Any]) -> Mapping[Any, str]:
121+
"""Map from enum members to deprecation messages."""
122+
return cast(
123+
Mapping[Any, str],
124+
type.__getattribute__(cls_, "__dict__").get("__deprecated_value_map__", {}),
125+
)
126+
127+
def __getattribute__(cls, name: str) -> Any:
128+
"""Resolve `name` to a member, warning if the member is deprecated."""
129+
if name in ("__deprecated_names__", "__deprecated_value_map__"):
130+
return type.__getattribute__(cls, name)
131+
deprecated = DeprecatingEnumType._name_map(cls)
132+
if name in deprecated:
133+
warnings.warn(deprecated[name], DeprecatedMemberWarning, stacklevel=2)
134+
return super().__getattribute__(name)
135+
136+
def __getitem__(cls, name: str) -> Any:
137+
"""Resolve `name` to a member, warning if the member is deprecated."""
138+
deprecated = DeprecatingEnumType._name_map(cls)
139+
if name in deprecated:
140+
warnings.warn(deprecated[name], DeprecatedMemberWarning, stacklevel=2)
141+
return super().__getitem__(name)
142+
143+
def __call__(cls, value: Any, *args: Any, **kwargs: Any) -> Any:
144+
"""Resolve `value` to a member, warning if the member is purely deprecated."""
145+
member = super().__call__(value, *args, **kwargs)
146+
value_map: Mapping[Any, str] = DeprecatingEnumType._value_map(cls)
147+
msg = value_map.get(member)
148+
if msg is not None:
149+
warnings.warn(msg, DeprecatedMemberWarning, stacklevel=2)
150+
return member
151+
152+
153+
if TYPE_CHECKING:
154+
# Make type checkers treat it as a plain Enum (so member checks work), if we don't
155+
# do this, mypy will consider the resulting enum completely dynamic and never
156+
# complain if an unexisting member is accessed.
157+
158+
# pylint: disable-next=missing-class-docstring
159+
class Enum(enum.Enum): # noqa
160+
__deprecated_names__: ClassVar[Mapping[str, str]]
161+
__deprecated_value_map__: ClassVar[Mapping[Enum, str]]
162+
163+
else:
164+
165+
class Enum(enum.Enum, metaclass=DeprecatingEnumType):
166+
"""Base class for enums that support DeprecatedMember.
167+
168+
This class extends the standard library's [`enum.Enum`][] to support marking
169+
certain members as deprecated. Deprecated members can be accessed, but doing so
170+
will emit a [`DeprecationWarning`][], specifically
171+
a [`DeprecatedMemberWarning`][frequenz.core.enum.DeprecatedMemberWarning].
172+
173+
To declare a deprecated member, use the
174+
[`DeprecatedMember`][frequenz.core.enum.DeprecatedMember] wrapper in the class body.
175+
176+
When using the enum constructor (i.e. `MyEnum(value)`), a warning is only emitted if
177+
the resolved member has no non-deprecated aliases. If there is at least one
178+
non-deprecated alias for the member, no warning is emitted.
179+
180+
Example:
181+
```python
182+
from frequenz.core.enum import Enum, DeprecatedMember
183+
184+
class TaskStatus(Enum):
185+
OPEN = 1
186+
IN_PROGRESS = 2
187+
PENDING = DeprecatedMember(1, "PENDING is deprecated, use OPEN instead")
188+
DONE = DeprecatedMember(3, "DONE is deprecated, use FINISHED instead")
189+
FINISHED = 4
190+
191+
# Accessing deprecated members:
192+
status1 = TaskStatus.PENDING # Warns: "PENDING is deprecated, use OPEN instead"
193+
assert status1 is TaskStatus.OPEN
194+
195+
status2 = TaskStatus["DONE"] # Warns: "DONE is deprecated, use FINISHED instead"
196+
assert status2 is TaskStatus.FINISHED
197+
198+
status3 = TaskStatus(1) # No warning, resolves to OPEN which has a non-deprecated alias
199+
assert status3 is TaskStatus.OPEN
200+
201+
status4 = TaskStatus(3) # Warns: "DONE is deprecated, use FINISHED instead"
202+
assert status4 is TaskStatus.FINISHED
203+
```
204+
"""
205+
206+
__deprecated_names__: ClassVar[Mapping[str, str]]
207+
__deprecated_value_map__: ClassVar[Mapping[Self, str]]

tests/test_enum.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
# License: MIT
2+
# Copyright © 2025 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Tests for the `frequenz.core.enum` module."""
5+
6+
import pytest
7+
8+
from frequenz.core.enum import (
9+
DeprecatedMember,
10+
DeprecatedMemberWarning,
11+
Enum,
12+
)
13+
14+
15+
class _TestEnum(Enum):
16+
"""A test enum with some deprecated members."""
17+
18+
OPEN = 1
19+
IN_PROGRESS = 2
20+
PENDING = DeprecatedMember(1, "Use OPEN instead")
21+
DONE = DeprecatedMember(3, "Use FINISHED instead")
22+
FINISHED = 4
23+
24+
25+
def _assert_deprecated_member(
26+
recorder: pytest.WarningsRecorder, expected_msg: str
27+
) -> None:
28+
"""Assert that a single deprecation warning was recorded with the expected message."""
29+
assert len(recorder.list) == 1
30+
warning = recorder.pop().message
31+
assert str(warning) == expected_msg
32+
assert isinstance(warning, DeprecatedMemberWarning)
33+
34+
35+
def test_mypy_detects_deprecated_members() -> None:
36+
"""Test that mypy detects missing members as expected.
37+
38+
If mypy wouldn't detect this, it should complain about an unused type: ignore.
39+
"""
40+
with pytest.raises(AttributeError):
41+
_ = _TestEnum.I_DONT_EXIST # type: ignore[attr-defined]
42+
43+
44+
def test_attribute_access_warns() -> None:
45+
"""Test accessing deprecated members as attributes triggers a deprecation warning."""
46+
with pytest.deprecated_call() as recorder:
47+
_ = _TestEnum.PENDING
48+
_assert_deprecated_member(recorder, "Use OPEN instead")
49+
50+
with pytest.deprecated_call() as recorder:
51+
_ = _TestEnum.DONE
52+
_assert_deprecated_member(recorder, "Use FINISHED instead")
53+
54+
55+
def test_name_lookup_warns() -> None:
56+
"""Test accessing deprecated members by name triggers a deprecation warning."""
57+
with pytest.deprecated_call() as recorder:
58+
_ = _TestEnum["PENDING"]
59+
_assert_deprecated_member(recorder, "Use OPEN instead")
60+
61+
with pytest.deprecated_call() as recorder:
62+
_ = _TestEnum["DONE"]
63+
_assert_deprecated_member(recorder, "Use FINISHED instead")
64+
65+
66+
def test_value_lookup_behavior_non_deprecated_alias() -> None:
67+
"""Test accessing members by value triggers no warnings when a non-deprecated alias exists."""
68+
member = _TestEnum(1)
69+
assert member is _TestEnum.OPEN
70+
71+
72+
def test_value_lookup_behavior_purely_deprecated() -> None:
73+
"""Test accessing members by value triggers warnings when there is no non-deprecated alias."""
74+
with pytest.deprecated_call() as recorder:
75+
member = _TestEnum(3)
76+
_assert_deprecated_member(recorder, "Use FINISHED instead")
77+
with pytest.deprecated_call(): # Avoid pytest showing the deprecation in the output
78+
assert member is _TestEnum.DONE
79+
80+
81+
def test_members_integrity() -> None:
82+
"""Test that all enum members are present in __members__."""
83+
names = list(_TestEnum.__members__.keys())
84+
assert {"OPEN", "IN_PROGRESS", "PENDING", "DONE", "FINISHED"} <= set(names)

0 commit comments

Comments
 (0)