From c36db943837967233a535926068efd5fcd40eeea Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Tue, 26 Aug 2025 12:10:25 +0200 Subject: [PATCH 1/6] Clear release notes Signed-off-by: Leandro Lucarella --- RELEASE_NOTES.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index b553771..67f33e6 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -14,4 +14,4 @@ ## Bug Fixes -* `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. + From cbee88e3682a9811c0d6cdcaed92a104c14d2131 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Tue, 26 Aug 2025 12:13:03 +0200 Subject: [PATCH 2/6] Fix `datetime` module docstring Signed-off-by: Leandro Lucarella --- src/frequenz/core/datetime.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/frequenz/core/datetime.py b/src/frequenz/core/datetime.py index 2fcdd47..c64d310 100644 --- a/src/frequenz/core/datetime.py +++ b/src/frequenz/core/datetime.py @@ -1,7 +1,7 @@ # License: MIT # Copyright © 2024 Frequenz Energy-as-a-Service GmbH -"""Timeseries basic types.""" +"""Date and time utilities.""" from datetime import datetime, timezone from typing import Final From b37f3a2be05d263910d3e11a7bd7a2953b8e907f Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Tue, 26 Aug 2025 12:17:11 +0200 Subject: [PATCH 3/6] Fix typo in the bug report template Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Leandro Lucarella --- .github/ISSUE_TEMPLATE/bug.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml index 4d91d88..79e8cc1 100644 --- a/.github/ISSUE_TEMPLATE/bug.yml +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -53,7 +53,7 @@ body: - Date and time utilities (part:datetime) - Documentation (part:docs) - IDs (part:id) - - Mathemathics utilities (part:math) + - Mathematics utilities (part:math) - Module utilities (part:module) - Logging utilities (part:logging) - Type hints and typing (part:typing) From 9d42e5951e2be5f05a7d522ef115953597428d4a Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Tue, 26 Aug 2025 12:08:11 +0200 Subject: [PATCH 4/6] Add `Enum` class that supports deprecated members 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. Signed-off-by: Leandro Lucarella --- README.md | 19 ++++ RELEASE_NOTES.md | 18 +++- src/frequenz/core/enum.py | 207 ++++++++++++++++++++++++++++++++++++++ tests/test_enum.py | 84 ++++++++++++++++ 4 files changed, 327 insertions(+), 1 deletion(-) create mode 100644 src/frequenz/core/enum.py create mode 100644 tests/test_enum.py diff --git a/README.md b/README.md index bafe1e6..a1537e8 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,25 @@ positive = Interval(0, None) # [0, ∞] assert 1000 in positive # True ``` +### `Enum` with deprecated members + +Define enums with deprecated members that raise deprecation warnings when +accessed: + +```python +from frequenz.core.enum import Enum, DeprecatedMember + +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 + +status1 = TaskStatus.PENDING # Warns: "PENDING is deprecated, use OPEN instead" +assert status1 is TaskStatus.OPEN +``` + ### Typing Utilities Disable class constructors to enforce factory pattern usage: diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 67f33e6..1ed9c14 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -10,7 +10,23 @@ ## New Features - +- A new `frequenz.core.enum` module was added, providing a drop-in replacement `Enum` that supports deprecating members. + + Example: + + ```python + from frequenz.core.enum import Enum, DeprecatedMember + + 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 + + status1 = TaskStatus.PENDING # Warns: "PENDING is deprecated, use OPEN instead" + assert status1 is TaskStatus.OPEN + ``` ## Bug Fixes diff --git a/src/frequenz/core/enum.py b/src/frequenz/core/enum.py new file mode 100644 index 0000000..c631c98 --- /dev/null +++ b/src/frequenz/core/enum.py @@ -0,0 +1,207 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Enum utilities with support for deprecated members. + +This module provides an [`Enum`][frequenz.core.enum.Enum] base class that extends the +standard library's [`enum.Enum`][] to support marking certain members as deprecated. + +See the [class documentation][frequenz.core.enum.Enum] for details and examples. +""" + +from __future__ import annotations + +import enum +import warnings +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, ClassVar, Self, TypeVar, cast + +# Note: This module contains more casts and uses of Any than what's typically +# ideal. This is because type hinting EnumType and Enum subclasses is quite +# challenging, as there is a lot of special behavior in `mypy` for these classes. +# +# The resulting enum should be treated as a regular enum by mypy, so hopefully everthing +# still works as expected. + +EnumT = TypeVar("EnumT", bound=enum.Enum) +"""Type variable for enum types.""" + + +class DeprecatedMemberWarning(DeprecationWarning): + """Warning category for deprecated enum members.""" + + +class DeprecatedMember: + """Marker used in enum class bodies to declare deprecated members. + + Please read the [`Enum`][frequenz.core.enum.Enum] documentation for details and + examples. + """ + + # Using slots is just an optimization to make the class more lightweight and avoid + # the creation of a `__dict__` for each instance and its corresponding lookup. + __slots__ = ("value", "message") + + def __init__(self, value: Any, message: str) -> None: + """Initialize this instance.""" + self.value = value + self.message = message + + +class DeprecatingEnumType(enum.EnumType): + """Enum metaclass that supports `DeprecatedMember` wrappers. + + Tip: + Normally it is not necessary to use this class directly, use + [`Enum`][frequenz.core.enum.Enum] instead. + + Behavior: + + - In the class body, members may be declared as `NAME = DeprecatedMember(value, msg)`. + - During class creation, these wrappers are replaced with `value` so that + a normal enum member or alias is created by [`EnumType`][enum.EnumType]. + - The deprecated names are recorded so that: + + * `MyEnum.NAME` warns (attribute access by name) + * `MyEnum["NAME"]` warns (lookup by name) + * `MyEnum(value)` warns **only if** the resolved member has **no** + non-deprecated aliases (all names for that member are deprecated). + """ + + def __new__( # pylint: disable=too-many-locals + mcs, + name: str, + bases: tuple[type[EnumT], ...], + classdict: Mapping[str, Any], + **kw: Any, + ) -> type[EnumT]: + """Create the new enum class, rewriting `DeprecatedMember` instances.""" + deprecated_names: dict[str, str] = {} + prepared = super().__prepare__(name, bases, **kw) + + # Unwrap DeprecatedMembers and record them as deprecated + for key, value in classdict.items(): + if isinstance(value, DeprecatedMember): + deprecated_names[key] = value.message + prepared[key] = value.value + else: + prepared[key] = value + + cls = cast(type[EnumT], super().__new__(mcs, name, bases, prepared, **kw)) + + # Build alias groups: member -> list of names + member_to_names: dict[EnumT, list[str]] = {} + member: EnumT + for member_name, member in cls.__members__.items(): + member_to_names.setdefault(member, []).append(member_name) + + warned_by_member: dict[EnumT, str] = {} + for member, names in member_to_names.items(): + # warn on value only if all alias names are deprecated + deprecated_aliases = [n for n in names if n in deprecated_names] + if deprecated_aliases and len(deprecated_aliases) == len(names): + warned_by_member[member] = deprecated_names[deprecated_aliases[0]] + + # Inject maps quietly + type.__setattr__(cls, "__deprecated_names__", deprecated_names) + type.__setattr__(cls, "__deprecated_value_map__", warned_by_member) + + return cls + + @staticmethod + def _name_map(cls_: type[Any]) -> Mapping[str, str]: + """Map from member names to deprecation messages.""" + return cast( + Mapping[str, str], + type.__getattribute__(cls_, "__dict__").get("__deprecated_names__", {}), + ) + + @staticmethod + def _value_map(cls_: type[Any]) -> Mapping[Any, str]: + """Map from enum members to deprecation messages.""" + return cast( + Mapping[Any, str], + type.__getattribute__(cls_, "__dict__").get("__deprecated_value_map__", {}), + ) + + def __getattribute__(cls, name: str) -> Any: + """Resolve `name` to a member, warning if the member is deprecated.""" + if name in ("__deprecated_names__", "__deprecated_value_map__"): + return type.__getattribute__(cls, name) + deprecated = DeprecatingEnumType._name_map(cls) + if name in deprecated: + warnings.warn(deprecated[name], DeprecatedMemberWarning, stacklevel=2) + return super().__getattribute__(name) + + def __getitem__(cls, name: str) -> Any: + """Resolve `name` to a member, warning if the member is deprecated.""" + deprecated = DeprecatingEnumType._name_map(cls) + if name in deprecated: + warnings.warn(deprecated[name], DeprecatedMemberWarning, stacklevel=2) + return super().__getitem__(name) + + def __call__(cls, value: Any, *args: Any, **kwargs: Any) -> Any: + """Resolve `value` to a member, warning if the member is purely deprecated.""" + member = super().__call__(value, *args, **kwargs) + value_map: Mapping[Any, str] = DeprecatingEnumType._value_map(cls) + msg = value_map.get(member) + if msg is not None: + warnings.warn(msg, DeprecatedMemberWarning, stacklevel=2) + return member + + +if TYPE_CHECKING: + # Make type checkers treat it as a plain Enum (so member checks work), if we don't + # do this, mypy will consider the resulting enum completely dynamic and never + # complain if an unexisting member is accessed. + + # pylint: disable-next=missing-class-docstring + class Enum(enum.Enum): # noqa + __deprecated_names__: ClassVar[Mapping[str, str]] + __deprecated_value_map__: ClassVar[Mapping[Enum, str]] + +else: + + class Enum(enum.Enum, metaclass=DeprecatingEnumType): + """Base class for enums that support DeprecatedMember. + + This class extends the standard library's [`enum.Enum`][] to support marking + certain members as deprecated. Deprecated members can be accessed, but doing so + will emit a [`DeprecationWarning`][], specifically + a [`DeprecatedMemberWarning`][frequenz.core.enum.DeprecatedMemberWarning]. + + To declare a deprecated member, use the + [`DeprecatedMember`][frequenz.core.enum.DeprecatedMember] wrapper in the class body. + + When using the enum constructor (i.e. `MyEnum(value)`), a warning is only emitted if + the resolved member has no non-deprecated aliases. If there is at least one + non-deprecated alias for the member, no warning is emitted. + + Example: + ```python + from frequenz.core.enum import Enum, DeprecatedMember + + 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 + + # Accessing deprecated members: + status1 = TaskStatus.PENDING # Warns: "PENDING is deprecated, use OPEN instead" + assert status1 is TaskStatus.OPEN + + status2 = TaskStatus["DONE"] # Warns: "DONE is deprecated, use FINISHED instead" + assert status2 is TaskStatus.FINISHED + + status3 = TaskStatus(1) # No warning, resolves to OPEN which has a non-deprecated alias + assert status3 is TaskStatus.OPEN + + status4 = TaskStatus(3) # Warns: "DONE is deprecated, use FINISHED instead" + assert status4 is TaskStatus.FINISHED + ``` + """ + + __deprecated_names__: ClassVar[Mapping[str, str]] + __deprecated_value_map__: ClassVar[Mapping[Self, str]] diff --git a/tests/test_enum.py b/tests/test_enum.py new file mode 100644 index 0000000..81123a1 --- /dev/null +++ b/tests/test_enum.py @@ -0,0 +1,84 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Tests for the `frequenz.core.enum` module.""" + +import pytest + +from frequenz.core.enum import ( + DeprecatedMember, + DeprecatedMemberWarning, + Enum, +) + + +class _TestEnum(Enum): + """A test enum with some deprecated members.""" + + OPEN = 1 + IN_PROGRESS = 2 + PENDING = DeprecatedMember(1, "Use OPEN instead") + DONE = DeprecatedMember(3, "Use FINISHED instead") + FINISHED = 4 + + +def _assert_deprecated_member( + recorder: pytest.WarningsRecorder, expected_msg: str +) -> None: + """Assert that a single deprecation warning was recorded with the expected message.""" + assert len(recorder.list) == 1 + warning = recorder.pop().message + assert str(warning) == expected_msg + assert isinstance(warning, DeprecatedMemberWarning) + + +def test_mypy_detects_deprecated_members() -> None: + """Test that mypy detects missing members as expected. + + If mypy wouldn't detect this, it should complain about an unused type: ignore. + """ + with pytest.raises(AttributeError): + _ = _TestEnum.I_DONT_EXIST # type: ignore[attr-defined] + + +def test_attribute_access_warns() -> None: + """Test accessing deprecated members as attributes triggers a deprecation warning.""" + with pytest.deprecated_call() as recorder: + _ = _TestEnum.PENDING + _assert_deprecated_member(recorder, "Use OPEN instead") + + with pytest.deprecated_call() as recorder: + _ = _TestEnum.DONE + _assert_deprecated_member(recorder, "Use FINISHED instead") + + +def test_name_lookup_warns() -> None: + """Test accessing deprecated members by name triggers a deprecation warning.""" + with pytest.deprecated_call() as recorder: + _ = _TestEnum["PENDING"] + _assert_deprecated_member(recorder, "Use OPEN instead") + + with pytest.deprecated_call() as recorder: + _ = _TestEnum["DONE"] + _assert_deprecated_member(recorder, "Use FINISHED instead") + + +def test_value_lookup_behavior_non_deprecated_alias() -> None: + """Test accessing members by value triggers no warnings when a non-deprecated alias exists.""" + member = _TestEnum(1) + assert member is _TestEnum.OPEN + + +def test_value_lookup_behavior_purely_deprecated() -> None: + """Test accessing members by value triggers warnings when there is no non-deprecated alias.""" + with pytest.deprecated_call() as recorder: + member = _TestEnum(3) + _assert_deprecated_member(recorder, "Use FINISHED instead") + with pytest.deprecated_call(): # Avoid pytest showing the deprecation in the output + assert member is _TestEnum.DONE + + +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) From 0def6fa41bc52d49207a57e941852710082c7b5d Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Tue, 26 Aug 2025 12:14:24 +0200 Subject: [PATCH 5/6] Add label for the new enum module Signed-off-by: Leandro Lucarella --- .github/ISSUE_TEMPLATE/bug.yml | 1 + .github/keylabeler.yml | 1 + .github/labeler.yml | 8 ++++++++ 3 files changed, 10 insertions(+) diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml index 79e8cc1..aa0b267 100644 --- a/.github/ISSUE_TEMPLATE/bug.yml +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -52,6 +52,7 @@ body: - Collections (part:collections) - Date and time utilities (part:datetime) - Documentation (part:docs) + - Enum utilities (part:enum) - IDs (part:id) - Mathematics utilities (part:math) - Module utilities (part:module) diff --git a/.github/keylabeler.yml b/.github/keylabeler.yml index e3ed72a..66fb334 100644 --- a/.github/keylabeler.yml +++ b/.github/keylabeler.yml @@ -16,6 +16,7 @@ labelMappings: "part:collections": "part:collections" "part:datetime": "part:datetime" "part:docs": "part:docs" + "part:enum": "part:enum" "part:id": "part:id" "part:logging": "part:logging" "part:math": "part:math" diff --git a/.github/labeler.yml b/.github/labeler.yml index ea43bb1..9ce001c 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -63,6 +63,14 @@ - "examples/**" - LICENSE +"part:enum": + - changed-files: + - any-glob-to-any-file: + - "src/frequenz/core/enum.py" + - "src/frequenz/core/enum/**" + - "tests/test_enum.py" + - "tests/enum/**" + "part:id": - changed-files: - any-glob-to-any-file: From 0d8fdb5c70e42a196bd9ad4da57387426d2cee4d Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Tue, 26 Aug 2025 12:11:24 +0200 Subject: [PATCH 6/6] Prepare release notes for a release Signed-off-by: Leandro Lucarella --- RELEASE_NOTES.md | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 1ed9c14..2e1d750 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,13 +1,5 @@ # Frequenz Core Library Release Notes -## Summary - - - -## Upgrading - - - ## New Features - A new `frequenz.core.enum` module was added, providing a drop-in replacement `Enum` that supports deprecating members. @@ -27,7 +19,3 @@ status1 = TaskStatus.PENDING # Warns: "PENDING is deprecated, use OPEN instead" assert status1 is TaskStatus.OPEN ``` - -## Bug Fixes - -