diff --git a/README.md b/README.md index a1537e8..a922c38 100644 --- a/README.md +++ b/README.md @@ -98,13 +98,15 @@ Define enums with deprecated members that raise deprecation warnings when accessed: ```python -from frequenz.core.enum import Enum, DeprecatedMember +from frequenz.core.enum import Enum, deprecated_member, unique +@unique 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") + # Duplicate values are fine with `@unique` as long as they are deprecated + PENDING = deprecated_member(1, "PENDING is deprecated, use OPEN instead") + DONE = deprecated_member(3, "DONE is deprecated, use FINISHED instead") FINISHED = 4 status1 = TaskStatus.PENDING # Warns: "PENDING is deprecated, use OPEN instead" diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 67f33e6..ed2fa1c 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,17 +1,9 @@ # Frequenz Core Library Release Notes -## Summary - - - ## Upgrading - +- If you used `enum.DeprecatedMember` directly anywhere, you should probably switch to using `enum.deprecated_member` instead, which will tag the member value with the appropriate type. ## New Features - - -## Bug Fixes - - +- A new `enum.deprecated_member` function has been added to create deprecated enum members with proper typing. diff --git a/src/frequenz/core/enum.py b/src/frequenz/core/enum.py index 2a28ddb..76a9094 100644 --- a/src/frequenz/core/enum.py +++ b/src/frequenz/core/enum.py @@ -27,12 +27,19 @@ """Type variable for enum types.""" +ValueT = TypeVar("ValueT") +"""Type variable for enum member values.""" + + class DeprecatedMemberWarning(DeprecationWarning): """Warning category for deprecated enum members.""" class DeprecatedMember: - """Marker used in enum class bodies to declare deprecated members. + """Class to mark members as deprecated. + + This class should not be used directly, use + [`deprecated_member`][frequenz.core.enum.deprecated_member] instead. Please read the [`Enum`][frequenz.core.enum.Enum] documentation for details and examples. @@ -48,8 +55,24 @@ def __init__(self, value: Any, message: str) -> None: self.message = message +def deprecated_member(value: ValueT, message: str) -> ValueT: + """Mark an enum member as deprecated. + + Please read the [`Enum`][frequenz.core.enum.Enum] documentation for details and + examples. + + Args: + value: The value of the enum member to mark as deprecated. + message: The deprecation message to be shown when the member is accessed. + + Returns: + The wrapped value, to mark the enum member as deprecated. + """ + return cast(ValueT, DeprecatedMember(value, message)) + + class DeprecatingEnumType(enum.EnumType): - """Enum metaclass that supports `DeprecatedMember` wrappers. + """Enum metaclass that supports deprecated members. Tip: Normally it is not necessary to use this class directly, use @@ -163,7 +186,7 @@ class Enum(enum.Enum): # noqa else: class Enum(enum.Enum, metaclass=DeprecatingEnumType): - """Base class for enums that support DeprecatedMember. + """Base class for enums that support deprecated members. This class extends the standard library's [`enum.Enum`][] to support marking certain members as deprecated. Deprecated members can be accessed, but doing so @@ -171,7 +194,7 @@ class Enum(enum.Enum, metaclass=DeprecatingEnumType): a [`DeprecatedMemberWarning`][frequenz.core.enum.DeprecatedMemberWarning]. To declare a deprecated member, use the - [`DeprecatedMember`][frequenz.core.enum.DeprecatedMember] wrapper in the class body. + [`deprecated_member()`][frequenz.core.enum.deprecated_member] function. 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 @@ -179,13 +202,13 @@ class Enum(enum.Enum, metaclass=DeprecatingEnumType): Example: ```python - from frequenz.core.enum import Enum, DeprecatedMember + from frequenz.core.enum import Enum, deprecated_member 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") + PENDING = deprecated_member(1, "PENDING is deprecated, use OPEN instead") + DONE = deprecated_member(3, "DONE is deprecated, use FINISHED instead") FINISHED = 4 # Accessing deprecated members: @@ -219,14 +242,14 @@ def unique(enumeration: type[EnumT]) -> type[EnumT]: Example: ```python - from frequenz.core.enum import Enum, DeprecatedMember, unique + from frequenz.core.enum import Enum, deprecated_member, 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") + PENDING = deprecated_member(1, "Use OPEN instead") ``` Args: diff --git a/tests/test_enum.py b/tests/test_enum.py index f4ff833..99cd048 100644 --- a/tests/test_enum.py +++ b/tests/test_enum.py @@ -5,7 +5,13 @@ import pytest -from frequenz.core.enum import DeprecatedMember, DeprecatedMemberWarning, Enum, unique +from frequenz.core.enum import ( + DeprecatedMember, + DeprecatedMemberWarning, + Enum, + deprecated_member, + unique, +) class _TestEnum(Enum): @@ -13,8 +19,8 @@ class _TestEnum(Enum): OPEN = 1 IN_PROGRESS = 2 - PENDING = DeprecatedMember(1, "Use OPEN instead") - DONE = DeprecatedMember(3, "Use FINISHED instead") + PENDING = deprecated_member(1, "Use OPEN instead") + DONE = deprecated_member(3, "Use FINISHED instead") FINISHED = 4 @@ -37,6 +43,51 @@ def test_mypy_detects_deprecated_members() -> None: _ = _TestEnum.I_DONT_EXIST # type: ignore[attr-defined] +def test_mypy_deprecated_member_correct_value_type() -> None: + """Test that mypy sees the correct value type for deprecated members. + + If mypy wouldn't detect this, it should complain about value not having the + correct type. + """ + _: int = _TestEnum.PENDING.value + + +def test_deprecated_member_helper_returns_wrapper() -> None: + """Test the helper returns a DeprecatedMember wrapper preserving metadata.""" + wrapper = deprecated_member(123, "Value 123 is deprecated") + assert isinstance(wrapper, DeprecatedMember) + assert wrapper.value == 123 + assert wrapper.message == "Value 123 is deprecated" + + +def test_deprecated_member_helper_enum_usage() -> None: + """Test that enums using the helper behave like those using DeprecatedMember directly.""" + + class _HelperEnum(Enum): + """Enum that uses deprecated_member for deprecated aliases.""" + + ACTIVE = 1 + LEGACY_ACTIVE = deprecated_member(1, "Use ACTIVE instead") + LEGACY_ONLY = deprecated_member(2, "Use something else") + + with pytest.deprecated_call() as recorder: + _ = _HelperEnum.LEGACY_ACTIVE + _assert_deprecated_member(recorder, "Use ACTIVE instead") + + with pytest.deprecated_call() as recorder: + _ = _HelperEnum["LEGACY_ACTIVE"] + _assert_deprecated_member(recorder, "Use ACTIVE instead") + + assert _HelperEnum(1) is _HelperEnum.ACTIVE + + with pytest.deprecated_call() as recorder: + member = _HelperEnum(2) + _assert_deprecated_member(recorder, "Use something else") + + with pytest.deprecated_call(): + assert member is _HelperEnum.LEGACY_ONLY + + def test_attribute_access_warns() -> None: """Test accessing deprecated members as attributes triggers a deprecation warning.""" with pytest.deprecated_call() as recorder: @@ -89,7 +140,7 @@ class _Status(Enum): ACTIVE = 1 INACTIVE = 2 - PENDING = DeprecatedMember(1, "Use ACTIVE instead") + PENDING = deprecated_member(1, "Use ACTIVE instead") with pytest.deprecated_call(): assert _Status.PENDING is _Status.ACTIVE # type: ignore[comparison-overlap] @@ -163,8 +214,8 @@ def test_unique_decorator_success_all_deprecated() -> None: 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") + OLD_A = deprecated_member(1, "Use something else") + OLD_B = deprecated_member(1, "Also use something else") with pytest.deprecated_call(): assert _AllDeprecated.OLD_A is _AllDeprecated.OLD_B # type: ignore[comparison-overlap]