diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 2e1d750..61998a7 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -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 "", line 1, in + File "/usr/lib/python3.12/enum.py", line 1617, in unique + raise ValueError('duplicate values found in %r: %s' % + ValueError: duplicate values found in : PENDING -> ACTIVE + >>> + ``` diff --git a/src/frequenz/core/enum.py b/src/frequenz/core/enum.py index c631c98..2a28ddb 100644 --- a/src/frequenz/core/enum.py +++ b/src/frequenz/core/enum.py @@ -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 diff --git a/tests/test_enum.py b/tests/test_enum.py index 81123a1..f4ff833 100644 --- a/tests/test_enum.py +++ b/tests/test_enum.py @@ -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): @@ -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]