Skip to content

Commit e438ddd

Browse files
authored
Add @unique decorator that ignores deprecated enum members (#96)
Introduce a new `@unique` decorator in the `frequenz.core.enum` module that ensures uniqueness among enum members, excluding those marked as deprecated. This allows deprecated members to serve as aliases for non-deprecated members without causing a `ValueError`.
2 parents 13a3aa1 + 2532fa3 commit e438ddd

File tree

3 files changed

+182
-18
lines changed

3 files changed

+182
-18
lines changed

RELEASE_NOTES.md

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

3+
## Summary
4+
35
## New Features
46

5-
- A new `frequenz.core.enum` module was added, providing a drop-in replacement `Enum` that supports deprecating members.
7+
* `frequenz.core.enum` now provides a `@unique` decorator that is aware of deprecations, and will only check for uniqueness among non-deprecated enum members.
68

7-
Example:
9+
For example this works:
810

9-
```python
10-
from frequenz.core.enum import Enum, DeprecatedMember
11+
```py
12+
>>> from frequenz.core.enum import DeprecatedMember, Enum, unique
13+
>>>
14+
>>> @unique
15+
... class Status(Enum):
16+
... ACTIVE = 1
17+
... INACTIVE = 2
18+
... PENDING = DeprecatedMember(1, "PENDING is deprecated, use ACTIVE instead")
19+
...
20+
>>>
21+
```
1122

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
23+
While using the standard library's `enum.unique` decorator raises a `ValueError`:
1824

19-
status1 = TaskStatus.PENDING # Warns: "PENDING is deprecated, use OPEN instead"
20-
assert status1 is TaskStatus.OPEN
21-
```
25+
```py
26+
>>> from enum import unique
27+
>>> from frequenz.core.enum import DeprecatedMember, Enum
28+
>>>
29+
>>> @unique
30+
... class Status(Enum):
31+
... ACTIVE = 1
32+
... INACTIVE = 2
33+
... PENDING = DeprecatedMember(1, "PENDING is deprecated, use ACTIVE instead")
34+
...
35+
Traceback (most recent call last):
36+
File "<stdin>", line 1, in <module>
37+
File "/usr/lib/python3.12/enum.py", line 1617, in unique
38+
raise ValueError('duplicate values found in %r: %s' %
39+
ValueError: duplicate values found in <enum 'Status'>: PENDING -> ACTIVE
40+
>>>
41+
```

src/frequenz/core/enum.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,3 +205,61 @@ class TaskStatus(Enum):
205205

206206
__deprecated_names__: ClassVar[Mapping[str, str]]
207207
__deprecated_value_map__: ClassVar[Mapping[Self, str]]
208+
209+
210+
def unique(enumeration: type[EnumT]) -> type[EnumT]:
211+
"""Class decorator for enums that ensures unique non-deprecated values.
212+
213+
This works similarly to [`@enum.unique`][enum.unique], but it only enforces
214+
uniqueness for members that are not deprecated. This allows deprecated members to
215+
be aliases for non-deprecated members without causing a `ValueError`.
216+
217+
If you need strict uniqueness for all deprecated and non-deprecated members, use
218+
[`@enum.unique`][enum.unique] instead.
219+
220+
Example:
221+
```python
222+
from frequenz.core.enum import Enum, DeprecatedMember, unique
223+
224+
@unique
225+
class TaskStatus(Enum):
226+
OPEN = 1
227+
IN_PROGRESS = 2
228+
# This is okay, as PENDING is a deprecated alias.
229+
PENDING = DeprecatedMember(1, "Use OPEN instead")
230+
```
231+
232+
Args:
233+
enumeration: The enum class to decorate.
234+
235+
Returns:
236+
The decorated enum class.
237+
238+
Raises:
239+
ValueError: If duplicate values are found among non-deprecated members.
240+
"""
241+
# Retrieve the map of deprecated names created by the metaclass.
242+
deprecated_names = enumeration.__dict__.get("__deprecated_names__", {})
243+
244+
duplicates = []
245+
seen_values: dict[Any, str] = {}
246+
for member_name, member in enumeration.__members__.items():
247+
# Ignore members that are marked as deprecated.
248+
if member_name in deprecated_names:
249+
continue
250+
251+
value = member.value
252+
if value in seen_values:
253+
duplicates.append((member_name, seen_values[value]))
254+
else:
255+
seen_values[value] = member_name
256+
257+
if duplicates:
258+
alias_details = ", ".join(
259+
f"{name!r} -> {alias!r}" for name, alias in duplicates
260+
)
261+
raise ValueError(
262+
f"duplicate values found in {enumeration.__name__}: {alias_details}"
263+
)
264+
265+
return enumeration

tests/test_enum.py

Lines changed: 91 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,7 @@
55

66
import pytest
77

8-
from frequenz.core.enum import (
9-
DeprecatedMember,
10-
DeprecatedMemberWarning,
11-
Enum,
12-
)
8+
from frequenz.core.enum import DeprecatedMember, DeprecatedMemberWarning, Enum, unique
139

1410

1511
class _TestEnum(Enum):
@@ -82,3 +78,93 @@ def test_members_integrity() -> None:
8278
"""Test that all enum members are present in __members__."""
8379
names = list(_TestEnum.__members__.keys())
8480
assert {"OPEN", "IN_PROGRESS", "PENDING", "DONE", "FINISHED"} <= set(names)
81+
82+
83+
def test_unique_decorator_success_with_deprecated_alias() -> None:
84+
"""Test that `unique` allows deprecated members to be aliases."""
85+
86+
@unique
87+
class _Status(Enum):
88+
"""An enum with a deprecated alias that should pass the unique check."""
89+
90+
ACTIVE = 1
91+
INACTIVE = 2
92+
PENDING = DeprecatedMember(1, "Use ACTIVE instead")
93+
94+
with pytest.deprecated_call():
95+
assert _Status.PENDING is _Status.ACTIVE # type: ignore[comparison-overlap]
96+
97+
98+
def test_unique_decorator_fail_on_non_deprecated_duplicates() -> None:
99+
"""Test that `unique` raises ValueError for duplicates among non-deprecated members."""
100+
with pytest.raises(ValueError) as execinfo:
101+
102+
@unique
103+
class _Status(Enum):
104+
"""An enum with a non-deprecated duplicate value."""
105+
106+
ACTIVE = 1
107+
INACTIVE = 2
108+
DUPLICATE_ACTIVE = 1
109+
110+
error_msg = str(execinfo.value)
111+
assert "duplicate values found" in error_msg
112+
assert "'DUPLICATE_ACTIVE' -> 'ACTIVE'" in error_msg
113+
114+
115+
def test_unique_decorator_fail_on_multiple_duplicates() -> None:
116+
"""Test that `unique` reports all non-deprecated duplicate values."""
117+
with pytest.raises(ValueError) as execinfo:
118+
119+
@unique
120+
class _Status(Enum):
121+
"""An enum with multiple non-deprecated duplicate values."""
122+
123+
A = 1
124+
B = 2
125+
C = 1 # Duplicate of A
126+
D = 2 # Duplicate of B
127+
E = 3
128+
129+
error_msg = str(execinfo.value)
130+
assert "duplicate values found" in error_msg
131+
assert "'C' -> 'A'" in error_msg
132+
assert "'D' -> 'B'" in error_msg
133+
134+
135+
def test_unique_decorator_success_simple() -> None:
136+
"""Test that `unique` works correctly on a simple enum with no duplicates."""
137+
138+
@unique
139+
class _Status(Enum):
140+
"""A simple unique enum."""
141+
142+
A = 1
143+
B = 2
144+
145+
# The test passes if no ValueError is raised.
146+
assert len(_Status) == 2
147+
148+
149+
def test_unique_decorator_success_empty_enum() -> None:
150+
"""Test that `unique` works correctly on an empty enum."""
151+
152+
@unique
153+
class _EmptyStatus(Enum):
154+
"""An empty enum."""
155+
156+
assert len(_EmptyStatus) == 0
157+
158+
159+
def test_unique_decorator_success_all_deprecated() -> None:
160+
"""Test `unique` when all members are deprecated, with some being aliases."""
161+
162+
@unique
163+
class _AllDeprecated(Enum):
164+
"""An enum where all members are deprecated."""
165+
166+
OLD_A = DeprecatedMember(1, "Use something else")
167+
OLD_B = DeprecatedMember(1, "Also use something else")
168+
169+
with pytest.deprecated_call():
170+
assert _AllDeprecated.OLD_A is _AllDeprecated.OLD_B # type: ignore[comparison-overlap]

0 commit comments

Comments
 (0)