Skip to content
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
46 changes: 33 additions & 13 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -1,21 +1,41 @@
# Frequenz Core Library Release Notes

## Summary

## New Features

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

Example:
For example this works:

```python
from frequenz.core.enum import Enum, DeprecatedMember
```py
>>> from frequenz.core.enum import DeprecatedMember, Enum, unique
>>>
>>> @unique
... class Status(Enum):
... ACTIVE = 1
... INACTIVE = 2
... PENDING = DeprecatedMember(1, "PENDING is deprecated, use ACTIVE instead")
...
>>>
```

class TaskStatus(Enum):
OPEN = 1
IN_PROGRESS = 2
PENDING = DeprecatedMember(1, "PENDING is deprecated, use OPEN instead")
DONE = DeprecatedMember(3, "DONE is deprecated, use FINISHED instead")
FINISHED = 4
While using the standard library's `enum.unique` decorator raises a `ValueError`:

status1 = TaskStatus.PENDING # Warns: "PENDING is deprecated, use OPEN instead"
assert status1 is TaskStatus.OPEN
```
```py
>>> from enum import unique
>>> from frequenz.core.enum import DeprecatedMember, Enum
>>>
>>> @unique
... class Status(Enum):
... ACTIVE = 1
... INACTIVE = 2
... PENDING = DeprecatedMember(1, "PENDING is deprecated, use ACTIVE instead")
...
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "/usr/lib/python3.12/enum.py", line 1617, in unique
raise ValueError('duplicate values found in %r: %s' %
ValueError: duplicate values found in <enum 'Status'>: PENDING -> ACTIVE
>>>
```
58 changes: 58 additions & 0 deletions src/frequenz/core/enum.py
Original file line number Diff line number Diff line change
Expand Up @@ -205,3 +205,61 @@ class TaskStatus(Enum):

__deprecated_names__: ClassVar[Mapping[str, str]]
__deprecated_value_map__: ClassVar[Mapping[Self, str]]


def unique(enumeration: type[EnumT]) -> type[EnumT]:
"""Class decorator for enums that ensures unique non-deprecated values.

This works similarly to [`@enum.unique`][enum.unique], but it only enforces
uniqueness for members that are not deprecated. This allows deprecated members to
be aliases for non-deprecated members without causing a `ValueError`.

If you need strict uniqueness for all deprecated and non-deprecated members, use
[`@enum.unique`][enum.unique] instead.

Example:
```python
from frequenz.core.enum import Enum, DeprecatedMember, unique

@unique
class TaskStatus(Enum):
OPEN = 1
IN_PROGRESS = 2
# This is okay, as PENDING is a deprecated alias.
PENDING = DeprecatedMember(1, "Use OPEN instead")
```

Args:
enumeration: The enum class to decorate.

Returns:
The decorated enum class.

Raises:
ValueError: If duplicate values are found among non-deprecated members.
"""
# Retrieve the map of deprecated names created by the metaclass.
deprecated_names = enumeration.__dict__.get("__deprecated_names__", {})

duplicates = []
seen_values: dict[Any, str] = {}
for member_name, member in enumeration.__members__.items():
# Ignore members that are marked as deprecated.
if member_name in deprecated_names:
continue

value = member.value
if value in seen_values:
duplicates.append((member_name, seen_values[value]))
else:
seen_values[value] = member_name

if duplicates:
alias_details = ", ".join(
f"{name!r} -> {alias!r}" for name, alias in duplicates
)
raise ValueError(
f"duplicate values found in {enumeration.__name__}: {alias_details}"
)

return enumeration
96 changes: 91 additions & 5 deletions tests/test_enum.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,7 @@

import pytest

from frequenz.core.enum import (
DeprecatedMember,
DeprecatedMemberWarning,
Enum,
)
from frequenz.core.enum import DeprecatedMember, DeprecatedMemberWarning, Enum, unique


class _TestEnum(Enum):
Expand Down Expand Up @@ -82,3 +78,93 @@ def test_members_integrity() -> None:
"""Test that all enum members are present in __members__."""
names = list(_TestEnum.__members__.keys())
assert {"OPEN", "IN_PROGRESS", "PENDING", "DONE", "FINISHED"} <= set(names)


def test_unique_decorator_success_with_deprecated_alias() -> None:
"""Test that `unique` allows deprecated members to be aliases."""

@unique
class _Status(Enum):
"""An enum with a deprecated alias that should pass the unique check."""

ACTIVE = 1
INACTIVE = 2
PENDING = DeprecatedMember(1, "Use ACTIVE instead")

with pytest.deprecated_call():
assert _Status.PENDING is _Status.ACTIVE # type: ignore[comparison-overlap]


def test_unique_decorator_fail_on_non_deprecated_duplicates() -> None:
"""Test that `unique` raises ValueError for duplicates among non-deprecated members."""
with pytest.raises(ValueError) as execinfo:

@unique
class _Status(Enum):
"""An enum with a non-deprecated duplicate value."""

ACTIVE = 1
INACTIVE = 2
DUPLICATE_ACTIVE = 1

error_msg = str(execinfo.value)
assert "duplicate values found" in error_msg
assert "'DUPLICATE_ACTIVE' -> 'ACTIVE'" in error_msg


def test_unique_decorator_fail_on_multiple_duplicates() -> None:
"""Test that `unique` reports all non-deprecated duplicate values."""
with pytest.raises(ValueError) as execinfo:

@unique
class _Status(Enum):
"""An enum with multiple non-deprecated duplicate values."""

A = 1
B = 2
C = 1 # Duplicate of A
D = 2 # Duplicate of B
E = 3

error_msg = str(execinfo.value)
assert "duplicate values found" in error_msg
assert "'C' -> 'A'" in error_msg
assert "'D' -> 'B'" in error_msg


def test_unique_decorator_success_simple() -> None:
"""Test that `unique` works correctly on a simple enum with no duplicates."""

@unique
class _Status(Enum):
"""A simple unique enum."""

A = 1
B = 2

# The test passes if no ValueError is raised.
assert len(_Status) == 2


def test_unique_decorator_success_empty_enum() -> None:
"""Test that `unique` works correctly on an empty enum."""

@unique
class _EmptyStatus(Enum):
"""An empty enum."""

assert len(_EmptyStatus) == 0


def test_unique_decorator_success_all_deprecated() -> None:
"""Test `unique` when all members are deprecated, with some being aliases."""

@unique
class _AllDeprecated(Enum):
"""An enum where all members are deprecated."""

OLD_A = DeprecatedMember(1, "Use something else")
OLD_B = DeprecatedMember(1, "Also use something else")

with pytest.deprecated_call():
assert _AllDeprecated.OLD_A is _AllDeprecated.OLD_B # type: ignore[comparison-overlap]