Skip to content

Commit 2e59182

Browse files
authored
Improve enums and add a generic enum_from_proto() (#82)
2 parents 78548f5 + 31a2ea8 commit 2e59182

File tree

6 files changed

+197
-10
lines changed

6 files changed

+197
-10
lines changed

RELEASE_NOTES.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,16 @@
66

77
## Upgrading
88

9-
<!-- Here goes notes on how to upgrade from previous versions, including deprecations and what they should be replaced with -->
9+
- The metrics and components enums `.from_proto()` are deprecated, please use the new `enum_from_proto()` instead.
10+
- Some minimum dependencies have been bumped, you might need to update your minimum dependencies too:
11+
12+
* `frequenz-api-common` to 0.6.1
13+
* `frequenz-core` to 1.0.2
1014

1115
## New Features
1216

13-
<!-- Here goes the main new features and examples or instructions on how to use them -->
17+
- 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.
18+
- The `frequenz.client.common.microgrid.ComponentCategory` was extended to include the missing categories.
1419

1520
## Bug Fixes
1621

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,8 @@ classifiers = [
2727
requires-python = ">= 3.11, < 4"
2828
dependencies = [
2929
"typing-extensions >= 4.13.0, < 5",
30-
"frequenz-api-common >= 0.6.0, < 7",
31-
"frequenz-core >= 1.0.0, < 2",
30+
"frequenz-api-common >= 0.6.1, < 7",
31+
"frequenz-core >= 1.0.2, < 2",
3232
]
3333
dynamic = ["version"]
3434

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
# License: MIT
2+
# Copyright © 2025 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Conversion of protobuf int enums to Python enums."""
5+
6+
import enum
7+
from typing import Literal, TypeVar, overload
8+
9+
EnumT = TypeVar("EnumT", bound=enum.Enum)
10+
"""A type variable that is bound to an enum."""
11+
12+
13+
@overload
14+
def enum_from_proto(
15+
value: int, enum_type: type[EnumT], *, allow_invalid: Literal[False]
16+
) -> EnumT: ...
17+
18+
19+
@overload
20+
def enum_from_proto(
21+
value: int, enum_type: type[EnumT], *, allow_invalid: Literal[True] = True
22+
) -> EnumT | int: ...
23+
24+
25+
def enum_from_proto(
26+
value: int, enum_type: type[EnumT], *, allow_invalid: bool = True
27+
) -> EnumT | int:
28+
"""Convert a protobuf int enum value to a python enum.
29+
30+
Example:
31+
```python
32+
import enum
33+
34+
from proto import proto_pb2 # Just an example. pylint: disable=import-error
35+
36+
@enum.unique
37+
class SomeEnum(enum.Enum):
38+
# These values should match the protobuf enum values.
39+
UNSPECIFIED = 0
40+
SOME_VALUE = 1
41+
42+
enum_value = enum_from_proto(proto_pb2.SomeEnum.SOME_ENUM_SOME_VALUE, SomeEnum)
43+
# -> SomeEnum.SOME_VALUE
44+
45+
enum_value = enum_from_proto(42, SomeEnum)
46+
# -> 42
47+
48+
enum_value = enum_from_proto(
49+
proto_pb2.SomeEnum.SOME_ENUM_UNKNOWN_VALUE, SomeEnum, allow_invalid=False
50+
)
51+
# -> ValueError
52+
```
53+
54+
Args:
55+
value: The protobuf int enum value.
56+
enum_type: The python enum type to convert to.
57+
allow_invalid: If `True`, return the value as an `int` if the value is not
58+
a valid member of the enum (this allows for forward-compatibility with new
59+
enum values defined in the protocol but not added to the Python enum yet).
60+
If `False`, raise a `ValueError` if the value is not a valid member of the
61+
enum.
62+
63+
Returns:
64+
The resulting python enum value if the protobuf value is known, otherwise
65+
the input value converted to a plain `int`.
66+
67+
Raises:
68+
ValueError: If `allow_invalid` is `False` and the value is not a valid member
69+
of the enum.
70+
"""
71+
try:
72+
return enum_type(value)
73+
except ValueError:
74+
if allow_invalid:
75+
return value
76+
raise

src/frequenz/client/common/metric/__init__.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,18 @@
33

44
"""Module to define the metrics used with the common client."""
55

6-
from enum import Enum
6+
import enum
77
from typing import Self
88

99
# pylint: disable=no-name-in-module
1010
from frequenz.api.common.v1.metrics.metric_sample_pb2 import Metric as PBMetric
11+
from typing_extensions import deprecated
1112

1213
# pylint: enable=no-name-in-module
1314

1415

15-
class Metric(Enum):
16+
@enum.unique
17+
class Metric(enum.Enum):
1618
"""List of supported metrics.
1719
1820
AC energy metrics information:
@@ -140,6 +142,7 @@ class Metric(Enum):
140142
SENSOR_IRRADIANCE = PBMetric.METRIC_SENSOR_IRRADIANCE
141143

142144
@classmethod
145+
@deprecated("Use `frequenz.client.common.enum_proto.enum_from_proto` instead.")
143146
def from_proto(cls, metric: PBMetric.ValueType) -> Self:
144147
"""Convert a protobuf Metric value to Metric enum.
145148

src/frequenz/client/common/microgrid/components/__init__.py

Lines changed: 57 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
from __future__ import annotations
77

8-
from enum import Enum
8+
import enum
99
from typing import final
1010

1111
# pylint: disable=no-name-in-module
@@ -19,6 +19,7 @@
1919
ComponentStateCode as PBComponentStateCode,
2020
)
2121
from frequenz.core.id import BaseId
22+
from typing_extensions import deprecated
2223

2324
# pylint: enable=no-name-in-module
2425

@@ -28,7 +29,8 @@ class ComponentId(BaseId, str_prefix="CID"):
2829
"""A unique identifier for a microgrid component."""
2930

3031

31-
class ComponentCategory(Enum):
32+
@enum.unique
33+
class ComponentCategory(enum.Enum):
3234
"""Possible types of microgrid component."""
3335

3436
UNSPECIFIED = PBComponentCategory.COMPONENT_CATEGORY_UNSPECIFIED
@@ -47,16 +49,63 @@ class ComponentCategory(Enum):
4749
INVERTER = PBComponentCategory.COMPONENT_CATEGORY_INVERTER
4850
"""An electricity generator, with batteries or solar energy."""
4951

52+
CONVERTER = PBComponentCategory.COMPONENT_CATEGORY_CONVERTER
53+
"""A DC-DC converter."""
54+
5055
BATTERY = PBComponentCategory.COMPONENT_CATEGORY_BATTERY
5156
"""A storage system for electrical energy, used by inverters."""
5257

5358
EV_CHARGER = PBComponentCategory.COMPONENT_CATEGORY_EV_CHARGER
5459
"""A station for charging electrical vehicles."""
5560

61+
CRYPTO_MINER = PBComponentCategory.COMPONENT_CATEGORY_CRYPTO_MINER
62+
"""A crypto miner."""
63+
64+
ELECTROLYZER = PBComponentCategory.COMPONENT_CATEGORY_ELECTROLYZER
65+
"""An electrolyzer for converting water into hydrogen and oxygen."""
66+
5667
CHP = PBComponentCategory.COMPONENT_CATEGORY_CHP
5768
"""A heat and power combustion plant (CHP stands for combined heat and power)."""
5869

70+
RELAY = PBComponentCategory.COMPONENT_CATEGORY_RELAY
71+
"""A relay.
72+
73+
Relays generally have two states: open (connected) and closed (disconnected).
74+
They are generally placed in front of a component, e.g., an inverter, to
75+
control whether the component is connected to the grid or not.
76+
"""
77+
78+
PRECHARGER = PBComponentCategory.COMPONENT_CATEGORY_PRECHARGER
79+
"""A precharge module.
80+
81+
Precharging involves gradually ramping up the DC voltage to prevent any
82+
potential damage to sensitive electrical components like capacitors.
83+
84+
While many inverters and batteries come equipped with in-built precharging
85+
mechanisms, some may lack this feature. In such cases, we need to use
86+
external precharging modules.
87+
"""
88+
89+
FUSE = PBComponentCategory.COMPONENT_CATEGORY_FUSE
90+
"""A fuse."""
91+
92+
VOLTAGE_TRANSFORMER = PBComponentCategory.COMPONENT_CATEGORY_VOLTAGE_TRANSFORMER
93+
"""A voltage transformer.
94+
95+
Voltage transformers are used to step up or step down the voltage, keeping
96+
the power somewhat constant by increasing or decreasing the current. If voltage is
97+
stepped up, current is stepped down, and vice versa.
98+
99+
Note:
100+
Voltage transformers have efficiency losses, so the output power is
101+
always less than the input power.
102+
"""
103+
104+
HVAC = PBComponentCategory.COMPONENT_CATEGORY_HVAC
105+
"""A Heating, Ventilation, and Air Conditioning (HVAC) system."""
106+
59107
@classmethod
108+
@deprecated("Use `frequenz.client.common.enum_proto.enum_from_proto` instead.")
60109
def from_proto(
61110
cls, component_category: PBComponentCategory.ValueType
62111
) -> ComponentCategory:
@@ -81,7 +130,8 @@ def to_proto(self) -> PBComponentCategory.ValueType:
81130
return self.value
82131

83132

84-
class ComponentStateCode(Enum):
133+
@enum.unique
134+
class ComponentStateCode(enum.Enum):
85135
"""All possible states of a microgrid component."""
86136

87137
UNSPECIFIED = PBComponentStateCode.COMPONENT_STATE_CODE_UNSPECIFIED
@@ -161,6 +211,7 @@ class ComponentStateCode(Enum):
161211
"""The precharger circuit is closed, allowing full current to flow to the main circuit."""
162212

163213
@classmethod
214+
@deprecated("Use `frequenz.client.common.enum_proto.enum_from_proto` instead.")
164215
def from_proto(
165216
cls, component_state: PBComponentStateCode.ValueType
166217
) -> ComponentStateCode:
@@ -185,7 +236,8 @@ def to_proto(self) -> PBComponentStateCode.ValueType:
185236
return self.value
186237

187238

188-
class ComponentErrorCode(Enum):
239+
@enum.unique
240+
class ComponentErrorCode(enum.Enum):
189241
"""All possible errors that can occur across all microgrid component categories."""
190242

191243
UNSPECIFIED = PBComponentErrorCode.COMPONENT_ERROR_CODE_UNSPECIFIED
@@ -338,6 +390,7 @@ class ComponentErrorCode(Enum):
338390
times."""
339391

340392
@classmethod
393+
@deprecated("Use `frequenz.client.common.enum_proto.enum_from_proto` instead.")
341394
def from_proto(
342395
cls, component_error_code: PBComponentErrorCode.ValueType
343396
) -> ComponentErrorCode:

tests/test_enum_proto.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# License: MIT
2+
# Copyright © 2025 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Tests for enum_from_proto utility."""
5+
6+
import enum
7+
8+
import pytest
9+
10+
from frequenz.client.common.enum_proto import enum_from_proto
11+
12+
13+
class _TestEnum(enum.Enum):
14+
"""A test enum for enum_from_proto tests."""
15+
16+
ZERO = 0
17+
ONE = 1
18+
TWO = 2
19+
20+
21+
@pytest.mark.parametrize("enum_member", _TestEnum)
22+
def test_valid_allow_invalid(enum_member: _TestEnum) -> None:
23+
"""Test conversion of valid enum values."""
24+
assert enum_from_proto(enum_member.value, _TestEnum) == enum_member
25+
assert (
26+
enum_from_proto(enum_member.value, _TestEnum, allow_invalid=True) == enum_member
27+
)
28+
29+
30+
@pytest.mark.parametrize("value", [42, -1])
31+
def test_invalid_allow_invalid(value: int) -> None:
32+
"""Test unknown values with allow_invalid=True (default)."""
33+
assert enum_from_proto(value, _TestEnum) == value
34+
assert enum_from_proto(value, _TestEnum, allow_invalid=True) == value
35+
36+
37+
@pytest.mark.parametrize("enum_member", _TestEnum)
38+
def test_valid_disallow_invalid(enum_member: _TestEnum) -> None:
39+
"""Test unknown values with allow_invalid=False (should raise ValueError)."""
40+
assert (
41+
enum_from_proto(enum_member.value, _TestEnum, allow_invalid=False)
42+
== enum_member
43+
)
44+
45+
46+
@pytest.mark.parametrize("value", [42, -1])
47+
def test_invalid_disallow(value: int) -> None:
48+
"""Test unknown values with allow_invalid=False (should raise ValueError)."""
49+
with pytest.raises(ValueError, match=rf"^{value} is not a valid _TestEnum$"):
50+
enum_from_proto(value, _TestEnum, allow_invalid=False)

0 commit comments

Comments
 (0)