diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 3cb6866..4b55cbd 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -6,11 +6,16 @@ ## Upgrading -- The `typing-extensions` dependency minimum version was bumped to 4.6 to support Python 3.12. +- The metrics and components enums `.from_proto()` are deprecated, please use the new `enum_from_proto()` instead. +- Some minimum dependencies have been bumped, you might need to update your minimum dependencies too: + + * `frequenz-api-common` to 0.6.1 + * `frequenz-core` to 1.0.2 ## New Features - +- A new module `frequenz.client.common.enum_proto` has been added, which provides a generic `enum_from_proto()` function to convert protobuf enums to Python enums. +- The `frequenz.client.common.microgrid.ComponentCategory` was extended to include the missing categories. ## Bug Fixes diff --git a/pyproject.toml b/pyproject.toml index 9d1a52f..8cabaca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,8 +26,9 @@ classifiers = [ ] requires-python = ">= 3.11, < 4" dependencies = [ - "typing-extensions >= 4.6.0, < 5", - "frequenz-api-common >= 0.6.0, < 7", + "typing-extensions >= 4.13.0, < 5", + "frequenz-api-common >= 0.6.1, < 7", + "frequenz-core >= 1.0.2, < 2", ] dynamic = ["version"] @@ -144,7 +145,18 @@ disable = [ ] [tool.pytest.ini_options] -addopts = "-W=all -Werror -Wdefault::DeprecationWarning -Wdefault::PendingDeprecationWarning '-Wdefault:Protobuf gencode version 5.27.2 is exactly one major version older than the runtime version:UserWarning' -vv" +addopts = "-vv" +filterwarnings = [ + "error", + "once::DeprecationWarning", + "once::PendingDeprecationWarning", + # We ignore warnings about protobuf gencode version being one version older + # than the current version, as this is supported by protobuf, and we expect to + # have such cases. If we go too far, we will get a proper error anyways. + # We use a raw string (single quotes) to avoid the need to escape special + # characters as this is a regex. + 'ignore:Protobuf gencode version .*exactly one major version older.*:UserWarning', +] testpaths = ["tests", "src"] asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" diff --git a/src/frequenz/client/common/enum_proto.py b/src/frequenz/client/common/enum_proto.py new file mode 100644 index 0000000..789f185 --- /dev/null +++ b/src/frequenz/client/common/enum_proto.py @@ -0,0 +1,76 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Conversion of protobuf int enums to Python enums.""" + +import enum +from typing import Literal, TypeVar, overload + +EnumT = TypeVar("EnumT", bound=enum.Enum) +"""A type variable that is bound to an enum.""" + + +@overload +def enum_from_proto( + value: int, enum_type: type[EnumT], *, allow_invalid: Literal[False] +) -> EnumT: ... + + +@overload +def enum_from_proto( + value: int, enum_type: type[EnumT], *, allow_invalid: Literal[True] = True +) -> EnumT | int: ... + + +def enum_from_proto( + value: int, enum_type: type[EnumT], *, allow_invalid: bool = True +) -> EnumT | int: + """Convert a protobuf int enum value to a python enum. + + Example: + ```python + import enum + + from proto import proto_pb2 # Just an example. pylint: disable=import-error + + @enum.unique + class SomeEnum(enum.Enum): + # These values should match the protobuf enum values. + UNSPECIFIED = 0 + SOME_VALUE = 1 + + enum_value = enum_from_proto(proto_pb2.SomeEnum.SOME_ENUM_SOME_VALUE, SomeEnum) + # -> SomeEnum.SOME_VALUE + + enum_value = enum_from_proto(42, SomeEnum) + # -> 42 + + enum_value = enum_from_proto( + proto_pb2.SomeEnum.SOME_ENUM_UNKNOWN_VALUE, SomeEnum, allow_invalid=False + ) + # -> ValueError + ``` + + Args: + value: The protobuf int enum value. + enum_type: The python enum type to convert to. + allow_invalid: If `True`, return the value as an `int` if the value is not + a valid member of the enum (this allows for forward-compatibility with new + enum values defined in the protocol but not added to the Python enum yet). + If `False`, raise a `ValueError` if the value is not a valid member of the + enum. + + Returns: + The resulting python enum value if the protobuf value is known, otherwise + the input value converted to a plain `int`. + + Raises: + ValueError: If `allow_invalid` is `False` and the value is not a valid member + of the enum. + """ + try: + return enum_type(value) + except ValueError: + if allow_invalid: + return value + raise diff --git a/src/frequenz/client/common/metric/__init__.py b/src/frequenz/client/common/metric/__init__.py index ba276c3..168ec9f 100644 --- a/src/frequenz/client/common/metric/__init__.py +++ b/src/frequenz/client/common/metric/__init__.py @@ -3,16 +3,18 @@ """Module to define the metrics used with the common client.""" -from enum import Enum +import enum from typing import Self # pylint: disable=no-name-in-module from frequenz.api.common.v1.metrics.metric_sample_pb2 import Metric as PBMetric +from typing_extensions import deprecated # pylint: enable=no-name-in-module -class Metric(Enum): +@enum.unique +class Metric(enum.Enum): """List of supported metrics. AC energy metrics information: @@ -140,6 +142,7 @@ class Metric(Enum): SENSOR_IRRADIANCE = PBMetric.METRIC_SENSOR_IRRADIANCE @classmethod + @deprecated("Use `frequenz.client.common.enum_proto.enum_from_proto` instead.") def from_proto(cls, metric: PBMetric.ValueType) -> Self: """Convert a protobuf Metric value to Metric enum. diff --git a/src/frequenz/client/common/microgrid/__init__.py b/src/frequenz/client/common/microgrid/__init__.py index d24d3f3..0787943 100644 --- a/src/frequenz/client/common/microgrid/__init__.py +++ b/src/frequenz/client/common/microgrid/__init__.py @@ -2,3 +2,17 @@ # Copyright © 2023 Frequenz Energy-as-a-Service GmbH """Frequenz microgrid definition.""" + +from typing import final + +from frequenz.core.id import BaseId + + +@final +class EnterpriseId(BaseId, str_prefix="EID"): + """A unique identifier for an enterprise account.""" + + +@final +class MicrogridId(BaseId, str_prefix="MID"): + """A unique identifier for a microgrid.""" diff --git a/src/frequenz/client/common/microgrid/components/__init__.py b/src/frequenz/client/common/microgrid/components/__init__.py index 013c26a..346edcc 100644 --- a/src/frequenz/client/common/microgrid/components/__init__.py +++ b/src/frequenz/client/common/microgrid/components/__init__.py @@ -2,9 +2,11 @@ # Copyright © 2022 Frequenz Energy-as-a-Service GmbH """Defines the components that can be used in a microgrid.""" + from __future__ import annotations -from enum import Enum +import enum +from typing import final # pylint: disable=no-name-in-module from frequenz.api.common.v1.microgrid.components.components_pb2 import ( @@ -16,11 +18,19 @@ from frequenz.api.common.v1.microgrid.components.components_pb2 import ( ComponentStateCode as PBComponentStateCode, ) +from frequenz.core.id import BaseId +from typing_extensions import deprecated # pylint: enable=no-name-in-module -class ComponentCategory(Enum): +@final +class ComponentId(BaseId, str_prefix="CID"): + """A unique identifier for a microgrid component.""" + + +@enum.unique +class ComponentCategory(enum.Enum): """Possible types of microgrid component.""" UNSPECIFIED = PBComponentCategory.COMPONENT_CATEGORY_UNSPECIFIED @@ -39,16 +49,63 @@ class ComponentCategory(Enum): INVERTER = PBComponentCategory.COMPONENT_CATEGORY_INVERTER """An electricity generator, with batteries or solar energy.""" + CONVERTER = PBComponentCategory.COMPONENT_CATEGORY_CONVERTER + """A DC-DC converter.""" + BATTERY = PBComponentCategory.COMPONENT_CATEGORY_BATTERY """A storage system for electrical energy, used by inverters.""" EV_CHARGER = PBComponentCategory.COMPONENT_CATEGORY_EV_CHARGER """A station for charging electrical vehicles.""" + CRYPTO_MINER = PBComponentCategory.COMPONENT_CATEGORY_CRYPTO_MINER + """A crypto miner.""" + + ELECTROLYZER = PBComponentCategory.COMPONENT_CATEGORY_ELECTROLYZER + """An electrolyzer for converting water into hydrogen and oxygen.""" + CHP = PBComponentCategory.COMPONENT_CATEGORY_CHP """A heat and power combustion plant (CHP stands for combined heat and power).""" + RELAY = PBComponentCategory.COMPONENT_CATEGORY_RELAY + """A relay. + + Relays generally have two states: open (connected) and closed (disconnected). + They are generally placed in front of a component, e.g., an inverter, to + control whether the component is connected to the grid or not. + """ + + PRECHARGER = PBComponentCategory.COMPONENT_CATEGORY_PRECHARGER + """A precharge module. + + Precharging involves gradually ramping up the DC voltage to prevent any + potential damage to sensitive electrical components like capacitors. + + While many inverters and batteries come equipped with in-built precharging + mechanisms, some may lack this feature. In such cases, we need to use + external precharging modules. + """ + + FUSE = PBComponentCategory.COMPONENT_CATEGORY_FUSE + """A fuse.""" + + VOLTAGE_TRANSFORMER = PBComponentCategory.COMPONENT_CATEGORY_VOLTAGE_TRANSFORMER + """A voltage transformer. + + Voltage transformers are used to step up or step down the voltage, keeping + the power somewhat constant by increasing or decreasing the current. If voltage is + stepped up, current is stepped down, and vice versa. + + Note: + Voltage transformers have efficiency losses, so the output power is + always less than the input power. + """ + + HVAC = PBComponentCategory.COMPONENT_CATEGORY_HVAC + """A Heating, Ventilation, and Air Conditioning (HVAC) system.""" + @classmethod + @deprecated("Use `frequenz.client.common.enum_proto.enum_from_proto` instead.") def from_proto( cls, component_category: PBComponentCategory.ValueType ) -> ComponentCategory: @@ -73,7 +130,8 @@ def to_proto(self) -> PBComponentCategory.ValueType: return self.value -class ComponentStateCode(Enum): +@enum.unique +class ComponentStateCode(enum.Enum): """All possible states of a microgrid component.""" UNSPECIFIED = PBComponentStateCode.COMPONENT_STATE_CODE_UNSPECIFIED @@ -153,6 +211,7 @@ class ComponentStateCode(Enum): """The precharger circuit is closed, allowing full current to flow to the main circuit.""" @classmethod + @deprecated("Use `frequenz.client.common.enum_proto.enum_from_proto` instead.") def from_proto( cls, component_state: PBComponentStateCode.ValueType ) -> ComponentStateCode: @@ -177,7 +236,8 @@ def to_proto(self) -> PBComponentStateCode.ValueType: return self.value -class ComponentErrorCode(Enum): +@enum.unique +class ComponentErrorCode(enum.Enum): """All possible errors that can occur across all microgrid component categories.""" UNSPECIFIED = PBComponentErrorCode.COMPONENT_ERROR_CODE_UNSPECIFIED @@ -330,6 +390,7 @@ class ComponentErrorCode(Enum): times.""" @classmethod + @deprecated("Use `frequenz.client.common.enum_proto.enum_from_proto` instead.") def from_proto( cls, component_error_code: PBComponentErrorCode.ValueType ) -> ComponentErrorCode: diff --git a/src/frequenz/client/common/microgrid/sensors.py b/src/frequenz/client/common/microgrid/sensors.py new file mode 100644 index 0000000..0fca6a2 --- /dev/null +++ b/src/frequenz/client/common/microgrid/sensors.py @@ -0,0 +1,13 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Microgrid sensors.""" + +from typing import final + +from frequenz.core.id import BaseId + + +@final +class SensorId(BaseId, str_prefix="SID"): + """A unique identifier for a microgrid sensor.""" diff --git a/tests/microgrid/test_ids.py b/tests/microgrid/test_ids.py new file mode 100644 index 0000000..482bd2b --- /dev/null +++ b/tests/microgrid/test_ids.py @@ -0,0 +1,28 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Tests for microgrid-related IDs.""" + +import pytest +from frequenz.core.id import BaseId + +from frequenz.client.common.microgrid import EnterpriseId, MicrogridId +from frequenz.client.common.microgrid.components import ComponentId +from frequenz.client.common.microgrid.sensors import SensorId + + +@pytest.mark.parametrize( + "id_class, prefix", + [ + (EnterpriseId, "EID"), + (MicrogridId, "MID"), + (ComponentId, "CID"), + (SensorId, "SID"), + ], +) +def test_string_representation(id_class: type[BaseId], prefix: str) -> None: + """Test string representation of IDs.""" + _id = id_class(123) + + assert str(_id) == f"{prefix}123" + assert repr(_id) == f"{id_class.__name__}(123)" diff --git a/tests/test_enum_proto.py b/tests/test_enum_proto.py new file mode 100644 index 0000000..a6ef59e --- /dev/null +++ b/tests/test_enum_proto.py @@ -0,0 +1,50 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Tests for enum_from_proto utility.""" + +import enum + +import pytest + +from frequenz.client.common.enum_proto import enum_from_proto + + +class _TestEnum(enum.Enum): + """A test enum for enum_from_proto tests.""" + + ZERO = 0 + ONE = 1 + TWO = 2 + + +@pytest.mark.parametrize("enum_member", _TestEnum) +def test_valid_allow_invalid(enum_member: _TestEnum) -> None: + """Test conversion of valid enum values.""" + assert enum_from_proto(enum_member.value, _TestEnum) == enum_member + assert ( + enum_from_proto(enum_member.value, _TestEnum, allow_invalid=True) == enum_member + ) + + +@pytest.mark.parametrize("value", [42, -1]) +def test_invalid_allow_invalid(value: int) -> None: + """Test unknown values with allow_invalid=True (default).""" + assert enum_from_proto(value, _TestEnum) == value + assert enum_from_proto(value, _TestEnum, allow_invalid=True) == value + + +@pytest.mark.parametrize("enum_member", _TestEnum) +def test_valid_disallow_invalid(enum_member: _TestEnum) -> None: + """Test unknown values with allow_invalid=False (should raise ValueError).""" + assert ( + enum_from_proto(enum_member.value, _TestEnum, allow_invalid=False) + == enum_member + ) + + +@pytest.mark.parametrize("value", [42, -1]) +def test_invalid_disallow(value: int) -> None: + """Test unknown values with allow_invalid=False (should raise ValueError).""" + with pytest.raises(ValueError, match=rf"^{value} is not a valid _TestEnum$"): + enum_from_proto(value, _TestEnum, allow_invalid=False)