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
3 changes: 2 additions & 1 deletion .github/ISSUE_TEMPLATE/bug.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,9 @@ body:
- Collections (part:collections)
- Date and time utilities (part:datetime)
- Documentation (part:docs)
- Enum utilities (part:enum)
- 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)
Expand Down
1 change: 1 addition & 0 deletions .github/keylabeler.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
8 changes: 8 additions & 0 deletions .github/labeler.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
24 changes: 14 additions & 10 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
# Frequenz Core Library Release Notes

## Summary

<!-- Here goes a general summary of what this release is about -->

## Upgrading
## New Features

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

## New Features
Example:

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

## Bug Fixes
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

* `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.
status1 = TaskStatus.PENDING # Warns: "PENDING is deprecated, use OPEN instead"
assert status1 is TaskStatus.OPEN
```
2 changes: 1 addition & 1 deletion src/frequenz/core/datetime.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
207 changes: 207 additions & 0 deletions src/frequenz/core/enum.py
Original file line number Diff line number Diff line change
@@ -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]]
84 changes: 84 additions & 0 deletions tests/test_enum.py
Original file line number Diff line number Diff line change
@@ -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)