From 68183b6430900bb81cbcc81337afdc8b699899ec Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Mon, 22 Jun 2026 15:14:13 +0000 Subject: [PATCH 1/9] Add `UnrecognizedValueError` Semantic accessors need to tell apart two distinct situations: a value the server left unspecified, and a value the server set but this client version does not recognize yet. The latter must carry the raw integer so resilient callers can forward it. Add `UnrecognizedValueError(ClientCommonError, ValueError)` storing the raw value on `.value`, and narrow `UnspecifiedValueError`'s docstring to the unspecified-only case, cross-referencing the new error. Signed-off-by: Leandro Lucarella --- src/frequenz/client/common/__init__.py | 7 ++++- src/frequenz/client/common/_exception.py | 30 +++++++++++++++++++- tests/test_exception.py | 35 +++++++++++++++++++++++- 3 files changed, 69 insertions(+), 3 deletions(-) diff --git a/src/frequenz/client/common/__init__.py b/src/frequenz/client/common/__init__.py index 045cda0..a927a07 100644 --- a/src/frequenz/client/common/__init__.py +++ b/src/frequenz/client/common/__init__.py @@ -3,9 +3,14 @@ """Common code and utilities for Frequenz API clients.""" -from ._exception import ClientCommonError, UnspecifiedValueError +from ._exception import ( + ClientCommonError, + UnrecognizedValueError, + UnspecifiedValueError, +) __all__ = [ "ClientCommonError", + "UnrecognizedValueError", "UnspecifiedValueError", ] diff --git a/src/frequenz/client/common/_exception.py b/src/frequenz/client/common/_exception.py index 740ab14..13766dd 100644 --- a/src/frequenz/client/common/_exception.py +++ b/src/frequenz/client/common/_exception.py @@ -8,8 +8,36 @@ class ClientCommonError(Exception): """Base class for all errors raised by frequenz-client-common.""" +class UnrecognizedValueError(ClientCommonError, ValueError): + """Raised when a semantic accessor sees an unrecognized protobuf value. + + This happens when the server sets an enum value that this version of the + client does not recognize, as opposed to an unspecified value (see + [`UnspecifiedValueError`][..UnspecifiedValueError]). The raw + unrecognized value is available as `value`. + + This is also a ``ValueError`` for convenience. + """ + + def __init__(self, value: int, message: str | None = None) -> None: + """Initialize this error. + + Args: + value: The raw protobuf value that was not recognized. + message: A custom error message. If `None`, a default message + mentioning the unrecognized value is used. + """ + self.value: int = value + super().__init__( + message if message is not None else f"unrecognized enum value: {value!r}" + ) + + class UnspecifiedValueError(ClientCommonError, ValueError): - """Raised when a semantic accessor sees an unspecified or unknown protobuf value. + """Raised when a semantic accessor sees an unspecified protobuf value. + + For a value that is set but not recognized by this client, see + [`UnrecognizedValueError`][..UnrecognizedValueError]. This is also a [`ValueError`][] for convenience. """ diff --git a/tests/test_exception.py b/tests/test_exception.py index b0ae703..51f7d00 100644 --- a/tests/test_exception.py +++ b/tests/test_exception.py @@ -1,11 +1,44 @@ """Tests for common exceptions.""" -from frequenz.client.common import ClientCommonError, UnspecifiedValueError +import frequenz.client.common +from frequenz.client.common import ( + ClientCommonError, + UnrecognizedValueError, + UnspecifiedValueError, +) def test_exceptions_exported_and_related() -> None: """Given exception exports, then their hierarchy and string form are correct.""" assert issubclass(UnspecifiedValueError, ClientCommonError) assert issubclass(UnspecifiedValueError, ValueError) + assert issubclass(UnrecognizedValueError, ClientCommonError) + assert issubclass(UnrecognizedValueError, ValueError) assert not issubclass(ClientCommonError, ValueError) assert str(UnspecifiedValueError("msg")) == "msg" + + +def test_all_is_sorted_and_exports_unrecognized() -> None: + """Given the package exports, then __all__ includes the new error and stays sorted.""" + assert "UnrecognizedValueError" in frequenz.client.common.__all__ + assert list(frequenz.client.common.__all__) == sorted( + frequenz.client.common.__all__ + ) + + +def test_unrecognized_value_error_carries_value() -> None: + """Given an unrecognized value, then it is stored and catchable as both base types.""" + error = UnrecognizedValueError(999) + assert error.value == 999 + assert isinstance(error, ValueError) + assert isinstance(error, ClientCommonError) + + +def test_unrecognized_value_error_default_message() -> None: + """Given no explicit message, then the default string contains the raw value.""" + assert "7" in str(UnrecognizedValueError(7)) + + +def test_unrecognized_value_error_custom_message() -> None: + """Given a custom message, then the string form is exactly that message.""" + assert str(UnrecognizedValueError(7, "msg")) == "msg" From 344fcd195282563a184715c79e542d6c14ced5d0 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Mon, 22 Jun 2026 15:25:10 +0000 Subject: [PATCH 2/9] Add deprecation support to `EnumParityTest` Give the shared enum-parity scaffold two opt-in class variables, `deprecated_members` and `absent_members`, both defaulting to an empty `frozenset` so every existing subclass keeps its current behaviour. When a member name is listed in `deprecated_members`, the parity checks that merely resolve it suppress the `DeprecationWarning` (via a small `_maybe_ignore_deprecation` context manager), `test_from_proto` asserts the enum-level converter returns the still-present member and warns, and a new `test_deprecated_members_warn` asserts the warning directly. A new `test_absent_members` covers the future case where a member is removed from the Python enum while the protobuf enum still defines its value. This unblocks deprecating the three `UNSPECIFIED` members in later commit without touching their parity subclasses yet. Signed-off-by: Leandro Lucarella --- .../client/common/test/enum_parity.py | 111 +++++++++++++++--- 1 file changed, 96 insertions(+), 15 deletions(-) diff --git a/src/frequenz/client/common/test/enum_parity.py b/src/frequenz/client/common/test/enum_parity.py index 83a49fc..1e1a719 100644 --- a/src/frequenz/client/common/test/enum_parity.py +++ b/src/frequenz/client/common/test/enum_parity.py @@ -21,7 +21,9 @@ from __future__ import annotations -from collections.abc import Callable +import contextlib +import warnings +from collections.abc import Callable, Iterator from enum import Enum from typing import Any, ClassVar @@ -45,6 +47,18 @@ class EnumParityTest: and the raw [`int`][] for unknown values; * [`to_proto`][.to_proto] returns the numeric protobuf value. + Info: Deprecation-aware mode + Subclasses may pin `deprecated_members` and/or `absent_members` to make + the parity checks aware of enum members that are deprecated or have been + removed. Both default to an empty `frozenset`, so subclasses that leave + them unset keep the exact behaviour described above. + + * A name listed in `deprecated_members` is expected to emit a + `DeprecationWarning` when accessed; the inherited parity checks assert + that warning and otherwise treat the member like any known value. + * A name listed in `absent_members` is expected to be missing from the + Python enum while the protobuf enum still defines it. + Subclasses are free to add further `test_*` methods. Example: @@ -95,6 +109,19 @@ class TestEventParity(EnumParityTest): Bind with `staticmethod(...)` in the subclass. """ + deprecated_members: ClassVar[frozenset[str]] = frozenset() + """The names of members (without [`name_prefix`][.name_prefix]) expected to be deprecated. + + Accessing them must emit a [`DeprecationWarning`][]. + """ + + absent_members: ClassVar[frozenset[str]] = frozenset() + """The names of members (without [`name_prefix`][.name_prefix]) expected to be absent. + + These members are expected to be absent from the Python enum while still + defined in the protobuf enum. + """ + def pytest_generate_tests(self, metafunc: pytest.Metafunc) -> None: """Parametrize `pb_name` and `member` from the configured enums. @@ -108,6 +135,27 @@ def pytest_generate_tests(self, metafunc: pytest.Metafunc) -> None: members = list(self.python_enum) metafunc.parametrize("member", members, ids=lambda m: m.name) + @contextlib.contextmanager + def _maybe_ignore_deprecation(self, name: str) -> Iterator[None]: + """Suppress deprecation warnings while accessing a deprecated member. + + Parity checks that merely resolve a member must stay warning-clean; the + warning itself is asserted by `test_deprecated_members_warn`. + + Args: + name: The member name (without ``name_prefix``) being accessed. + + Yields: + Control to the wrapped block, with `DeprecationWarning` suppressed + when ``name`` is in `deprecated_members`. + """ + if name in self.deprecated_members: + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + yield + else: + yield + def test_proto_enum_matches_enum_name(self, pb_name: str) -> None: """Test that all known protobuf enum names match a Python member. @@ -115,13 +163,15 @@ def test_proto_enum_matches_enum_name(self, pb_name: str) -> None: pb_name: The protobuf enum value name to check. """ pb_value = self.proto_enum.Value(pb_name) - try: - member = self.python_enum[pb_name.removeprefix(self.name_prefix)] - except KeyError: - # It is OK to have new protobuf enum values not yet in the Python - # enum. - return - assert member.value == pb_value + stripped = pb_name.removeprefix(self.name_prefix) + with self._maybe_ignore_deprecation(stripped): + try: + member = self.python_enum[stripped] + except KeyError: + # It is OK to have new protobuf enum values not yet in the Python + # enum. + return + assert member.value == pb_value def test_proto_enum_matches_enum_value(self, pb_name: str) -> None: """Test that all known protobuf enum values match a Python member. @@ -130,13 +180,15 @@ def test_proto_enum_matches_enum_value(self, pb_name: str) -> None: pb_name: The protobuf enum value name to check. """ pb_value = self.proto_enum.Value(pb_name) - try: - member = self.python_enum(pb_value) - except ValueError: - # It is OK to have new protobuf enum values not yet in the Python - # enum. - return - assert member.value == pb_value + stripped = pb_name.removeprefix(self.name_prefix) + with self._maybe_ignore_deprecation(stripped): + try: + member = self.python_enum(pb_value) + except ValueError: + # It is OK to have new protobuf enum values not yet in the Python + # enum. + return + assert member.value == pb_value def test_enum_matches_proto_enum_name(self, member: Enum) -> None: """Test that all Python enum members have a matching protobuf name. @@ -164,6 +216,16 @@ def test_from_proto(self, pb_name: str) -> None: pb_name: The protobuf enum value name to convert. """ pb_value = self.proto_enum.Value(pb_name) + stripped = pb_name.removeprefix(self.name_prefix) + if stripped in self.deprecated_members: + # Enum-level converter resolves the deprecated member and warns here + # (not a raw int): the dataclass-level converter is what stores int 0. + with pytest.warns(DeprecationWarning): + result = self.from_proto(pb_value) + assert isinstance(result, self.python_enum) + assert result.value == pb_value + assert result.name == stripped + return result = self.from_proto(pb_value) if pb_value in [m.value for m in self.python_enum]: assert result is self.python_enum(pb_value) @@ -186,3 +248,22 @@ def test_to_proto(self, member: Enum) -> None: """ pb_value = self.to_proto(member) assert pb_value == member.value + + def test_deprecated_members_warn(self) -> None: + """Test that accessing every `deprecated_members` name warns.""" + for name in self.deprecated_members: + with pytest.warns(DeprecationWarning): + member = self.python_enum[name] + assert member in self.python_enum + + def test_absent_members(self) -> None: + """Test that every `absent_members` name is gone from the Python enum. + + The protobuf enum is still expected to define the former value, which no + longer resolves to a Python member. + """ + for name in self.absent_members: + assert name not in self.python_enum.__members__ + pb_value = self.proto_enum.Value(f"{self.name_prefix}{name}") + with pytest.raises(ValueError): + self.python_enum(pb_value) From 2410ef18a74c0e9be3f0a2d4c2fbcba601ee2889 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Mon, 22 Jun 2026 15:52:59 +0000 Subject: [PATCH 3/9] Deprecate `EnergyMarketCodeType.UNSPECIFIED` Mark `EnergyMarketCodeType.UNSPECIFIED` as a deprecated member. The dataclass-level `delivery_area_from_proto` now stores the raw int `0` for an unspecified value instead of materializing the deprecated member. The enum-level `energy_market_code_type_from_proto`/`_to_proto` converters are left untouched and still return the member for known values. These will automatically stop handling `UNSPECIFIED` once it's completely removed. `DeliveryArea.get_code_type()` will be introduced in a follow-up commit. Bump the `frequenz-core` dependency to be able to use the `deprecated_member()` helper. Signed-off-by: Leandro Lucarella --- pyproject.toml | 2 +- .../client/common/grid/_delivery_area.py | 16 ++++++++++++---- .../common/grid/proto/v1alpha8/_delivery_area.py | 9 +++++++-- tests/grid/proto/v1alpha8/test_delivery_area.py | 3 ++- tests/grid/test_delivery_area.py | 7 +++++++ 5 files changed, 29 insertions(+), 8 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 96ce6be..56632d1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ requires-python = ">= 3.11, < 4" dependencies = [ "typing-extensions >= 4.13.0, < 5", "frequenz-api-common >= 0.8.4, < 1", - "frequenz-core >= 1.0.2, < 2", + "frequenz-core >= 1.3.0, < 2", "protobuf >= 6.33.6, < 8", ] dynamic = ["version"] diff --git a/src/frequenz/client/common/grid/_delivery_area.py b/src/frequenz/client/common/grid/_delivery_area.py index d702f65..49e8d81 100644 --- a/src/frequenz/client/common/grid/_delivery_area.py +++ b/src/frequenz/client/common/grid/_delivery_area.py @@ -3,12 +3,13 @@ """Delivery area information for the energy market.""" -import enum from dataclasses import dataclass +from frequenz.core.enum import Enum, deprecated_member, unique -@enum.unique -class EnergyMarketCodeType(enum.Enum): + +@unique +class EnergyMarketCodeType(Enum): """The identification code types used in the energy market. CodeType specifies the type of identification code used for uniquely @@ -35,7 +36,11 @@ class EnergyMarketCodeType(enum.Enum): processing errors. """ - UNSPECIFIED = 0 + UNSPECIFIED = deprecated_member( + 0, + "EnergyMarketCodeType.UNSPECIFIED is deprecated; use the `int` value `0` " + "instead if you really need to check for this low-level value.", + ) """Unspecified type. This value is a placeholder and should not be used.""" EUROPE_EIC = 1 @@ -74,6 +79,9 @@ class DeliveryArea: This code could be extended in the future, in case an unknown code type is encountered, a plain integer value is used to represent it. + + This is the lower-level, forward-compatible accessor; prefer + `DeliveryArea.get_code_type()` to obtain a known member or a clear error. """ def __str__(self) -> str: diff --git a/src/frequenz/client/common/grid/proto/v1alpha8/_delivery_area.py b/src/frequenz/client/common/grid/proto/v1alpha8/_delivery_area.py index acbe4e7..3b886d6 100644 --- a/src/frequenz/client/common/grid/proto/v1alpha8/_delivery_area.py +++ b/src/frequenz/client/common/grid/proto/v1alpha8/_delivery_area.py @@ -57,8 +57,13 @@ def delivery_area_from_proto(message: delivery_area_pb2.DeliveryArea) -> Deliver if code is None: issues.append("code is empty") - code_type = energy_market_code_type_from_proto(message.code_type) - if code_type is EnergyMarketCodeType.UNSPECIFIED: + raw_code_type = message.code_type + code_type: EnergyMarketCodeType | int = ( + raw_code_type + if raw_code_type == 0 + else energy_market_code_type_from_proto(raw_code_type) + ) + if raw_code_type == 0: issues.append("code_type is unspecified") elif isinstance(code_type, int): issues.append("code_type is unrecognized") diff --git a/tests/grid/proto/v1alpha8/test_delivery_area.py b/tests/grid/proto/v1alpha8/test_delivery_area.py index b5f5ee1..e61df92 100644 --- a/tests/grid/proto/v1alpha8/test_delivery_area.py +++ b/tests/grid/proto/v1alpha8/test_delivery_area.py @@ -25,6 +25,7 @@ class TestEnergyMarketCodeTypeParity(EnumParityTest): name_prefix = "ENERGY_MARKET_CODE_TYPE_" from_proto = staticmethod(energy_market_code_type_from_proto) to_proto = staticmethod(energy_market_code_type_to_proto) + deprecated_members = frozenset({"UNSPECIFIED"}) @dataclass(frozen=True, kw_only=True) @@ -82,7 +83,7 @@ class _DeliveryAreaProtoConversionTestCase: code="TEST", code_type=delivery_area_pb2.EnergyMarketCodeType.ENERGY_MARKET_CODE_TYPE_UNSPECIFIED, expected_code="TEST", - expected_code_type=EnergyMarketCodeType.UNSPECIFIED, + expected_code_type=0, expect_warning=True, ), _DeliveryAreaProtoConversionTestCase( diff --git a/tests/grid/test_delivery_area.py b/tests/grid/test_delivery_area.py index 36d2c13..a8c484b 100644 --- a/tests/grid/test_delivery_area.py +++ b/tests/grid/test_delivery_area.py @@ -101,3 +101,10 @@ def test_hash() -> None: area_set = {area1, area2, area3} assert len(area_set) == 2 # area1 and area2 are equal + + +def test_unspecified_member_is_deprecated() -> None: + """The UNSPECIFIED member is deprecated; the known members are not.""" + with pytest.deprecated_call(): + deprecated = EnergyMarketCodeType.UNSPECIFIED + assert deprecated in EnergyMarketCodeType From e44d7792cf527bc1eed46d1d338965c79e4ff0e2 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Mon, 22 Jun 2026 16:04:09 +0000 Subject: [PATCH 4/9] Deprecate `Metric.UNSPECIFIED` The dataclass-level converters now store the raw int `0` for an unspecified value instead of materializing the deprecated member: `metric_sample_from_proto_with_issues` reads the raw proto value and keep `0` as a plain `int`, and the enum-level converters are left untouched. `MetricSample.get_metric()` will be introduced in a follow-up commit. Signed-off-by: Leandro Lucarella --- src/frequenz/client/common/metrics/_metric.py | 12 +++++--- src/frequenz/client/common/metrics/_sample.py | 8 ++++- .../common/metrics/proto/v1alpha8/_sample.py | 5 +++- .../_electrical_component.py | 6 ++-- .../proto/v1alpha8/_electrical_component.py | 22 +++++++------- tests/metrics/proto/v1alpha8/test_metric.py | 1 + .../v1alpha8/test_sample_metric_sample.py | 30 +++++++++++++++++++ 7 files changed, 64 insertions(+), 20 deletions(-) diff --git a/src/frequenz/client/common/metrics/_metric.py b/src/frequenz/client/common/metrics/_metric.py index 02dc0a4..53773d6 100644 --- a/src/frequenz/client/common/metrics/_metric.py +++ b/src/frequenz/client/common/metrics/_metric.py @@ -3,11 +3,11 @@ """Supported metrics for microgrid components.""" -import enum +from frequenz.core import enum as core_enum -@enum.unique -class Metric(enum.Enum): +@core_enum.unique +class Metric(core_enum.Enum): """List of supported metrics. Metric units are as follows: @@ -39,7 +39,11 @@ class Metric(enum.Enum): period, and therefore can be inconsistent. """ - UNSPECIFIED = 0 + UNSPECIFIED = core_enum.deprecated_member( + 0, + "Metric.UNSPECIFIED is deprecated; use the `int` value `0` " + "instead if you really need to check for this low-level value.", + ) """The metric is unspecified (this should not be used).""" DC_VOLTAGE = 1 diff --git a/src/frequenz/client/common/metrics/_sample.py b/src/frequenz/client/common/metrics/_sample.py index 5ca439b..dd6531d 100644 --- a/src/frequenz/client/common/metrics/_sample.py +++ b/src/frequenz/client/common/metrics/_sample.py @@ -141,7 +141,13 @@ class MetricSample: """The moment when the metric was sampled.""" metric: Metric | int - """The metric that was sampled.""" + """The metric that was sampled. + + This is the lower-level, forward-compatible accessor: it may hold a known + `Metric` member, the raw `int` `0` when the metric is unspecified, or any + other raw `int` not yet known to this client. Prefer + `MetricSample.get_metric()` to obtain a known member or a clear error. + """ value: float | AggregatedMetricValue | None """The value of the sampled metric.""" diff --git a/src/frequenz/client/common/metrics/proto/v1alpha8/_sample.py b/src/frequenz/client/common/metrics/proto/v1alpha8/_sample.py index b6548d8..b254af4 100644 --- a/src/frequenz/client/common/metrics/proto/v1alpha8/_sample.py +++ b/src/frequenz/client/common/metrics/proto/v1alpha8/_sample.py @@ -88,7 +88,10 @@ def metric_sample_from_proto_with_issues( """ sample_time = datetime_from_proto(message.sample_time) - metric = metric_from_proto(message.metric) + raw_metric = message.metric + metric: Metric | int = ( + raw_metric if raw_metric == 0 else metric_from_proto(raw_metric) + ) value: float | AggregatedMetricValue | None = None if message.HasField("value"): diff --git a/src/frequenz/client/common/microgrid/electrical_components/_electrical_component.py b/src/frequenz/client/common/microgrid/electrical_components/_electrical_component.py index 12c189c..c9cc17c 100644 --- a/src/frequenz/client/common/microgrid/electrical_components/_electrical_component.py +++ b/src/frequenz/client/common/microgrid/electrical_components/_electrical_component.py @@ -75,9 +75,9 @@ class ElectricalComponent: # pylint: disable=too-many-instance-attributes These bounds may be derived from the component configuration, manufacturer limits, or limits of other devices. - The keys never include `Metric.UNSPECIFIED`: such entries are dropped when - loading from protobuf. Metrics unknown to this client version may still appear - as plain `int` keys for forward-compatibility. + The keys never include the unspecified metric (proto value `0`): such entries + are dropped when loading from protobuf. Metrics unknown to this client version + may still appear as plain `int` keys for forward-compatibility. """ category_specific_metadata: Mapping[str, Any] = dataclasses.field( diff --git a/src/frequenz/client/common/microgrid/electrical_components/proto/v1alpha8/_electrical_component.py b/src/frequenz/client/common/microgrid/electrical_components/proto/v1alpha8/_electrical_component.py index cadae95..72fb0b9 100644 --- a/src/frequenz/client/common/microgrid/electrical_components/proto/v1alpha8/_electrical_component.py +++ b/src/frequenz/client/common/microgrid/electrical_components/proto/v1alpha8/_electrical_component.py @@ -585,17 +585,17 @@ def _metric_config_bounds_from_proto( """ bounds: dict[Metric | int, Bounds] = {} for metric_bound in message: - metric = enum_from_proto(metric_bound.metric, Metric) - match metric: - case Metric.UNSPECIFIED: - major_issues.append( - "metric_config_bounds has an UNSPECIFIED metric, dropping it" - ) - continue - case int(): - minor_issues.append( - f"metric_config_bounds has an unrecognized metric {metric}" - ) + raw_metric = metric_bound.metric + if raw_metric == 0: + major_issues.append( + "metric_config_bounds has an UNSPECIFIED metric, dropping it" + ) + continue + metric = enum_from_proto(raw_metric, Metric) + if isinstance(metric, int): + minor_issues.append( + f"metric_config_bounds has an unrecognized metric {metric}" + ) if not metric_bound.HasField("config_bounds"): major_issues.append( diff --git a/tests/metrics/proto/v1alpha8/test_metric.py b/tests/metrics/proto/v1alpha8/test_metric.py index a8e9380..af70d37 100644 --- a/tests/metrics/proto/v1alpha8/test_metric.py +++ b/tests/metrics/proto/v1alpha8/test_metric.py @@ -21,3 +21,4 @@ class TestMetricParity(EnumParityTest): name_prefix = "METRIC_" from_proto = staticmethod(metric_from_proto) to_proto = staticmethod(metric_to_proto) + deprecated_members = frozenset({"UNSPECIFIED"}) diff --git a/tests/metrics/proto/v1alpha8/test_sample_metric_sample.py b/tests/metrics/proto/v1alpha8/test_sample_metric_sample.py index 15307d3..54b8252 100644 --- a/tests/metrics/proto/v1alpha8/test_sample_metric_sample.py +++ b/tests/metrics/proto/v1alpha8/test_sample_metric_sample.py @@ -3,6 +3,7 @@ """Tests for MetricSample protobuf conversion.""" +import warnings from dataclasses import dataclass, field from datetime import datetime, timezone from typing import Final @@ -206,3 +207,32 @@ def test_from_proto_with_issues(case: _TestCase) -> None: assert sample == case.expected_sample assert major_issues == case.expected_major_issues assert minor_issues == case.expected_minor_issues + + +def test_with_unspecified_metric() -> None: + """Test an unspecified metric is stored as int 0 without warning. + + The dataclass-level converter stores the raw int ``0`` for an unspecified + metric (never the deprecated member) and emits no ``DeprecationWarning``. + """ + proto = metrics_pb2.MetricSample( + sample_time=TIMESTAMP, + metric=metrics_pb2.Metric.METRIC_UNSPECIFIED, + value=metrics_pb2.MetricValueVariant( + simple_metric=metrics_pb2.SimpleMetricValue(value=5.0) + ), + ) + + major_issues: list[str] = [] + minor_issues: list[str] = [] + + with warnings.catch_warnings(): + warnings.simplefilter("error", DeprecationWarning) + sample = metric_sample_from_proto_with_issues( + proto, major_issues=major_issues, minor_issues=minor_issues + ) + + assert sample.metric == 0 + assert not isinstance(sample.metric, Metric) + assert not major_issues + assert not minor_issues From 24cbe1e11922dbb7d590085243d7c6a102d08d66 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Tue, 23 Jun 2026 13:21:05 +0200 Subject: [PATCH 5/9] Deprecate `MetricConnectionCategory.UNSPECIFIED` The dataclass-level converters now store the raw int `0` for an unspecified value instead of materializing the deprecated member: `metric_connection_from_proto_with_issues` reads the raw proto value and keep `0` as a plain `int`. `MetricConnection.get_category()` will be added in a follow-up commit. Signed-off-by: Leandro Lucarella --- src/frequenz/client/common/metrics/_sample.py | 20 +++++++++++++---- .../common/metrics/proto/v1alpha8/_sample.py | 14 +++++++----- .../test_metric_connection_category.py | 1 + .../v1alpha8/test_sample_metric_connection.py | 22 ++++++++++++------- 4 files changed, 39 insertions(+), 18 deletions(-) diff --git a/src/frequenz/client/common/metrics/_sample.py b/src/frequenz/client/common/metrics/_sample.py index dd6531d..6afe8ee 100644 --- a/src/frequenz/client/common/metrics/_sample.py +++ b/src/frequenz/client/common/metrics/_sample.py @@ -9,6 +9,8 @@ from datetime import datetime from typing import assert_never +from frequenz.core import enum as core_enum + from ._bounds import Bounds from ._metric import Metric @@ -66,11 +68,15 @@ def __str__(self) -> str: return f"avg:{self.avg}{extra_str}" -@enum.unique -class MetricConnectionCategory(enum.Enum): +@core_enum.unique +class MetricConnectionCategory(core_enum.Enum): """The categories of connections from which metrics can be obtained.""" - UNSPECIFIED = 0 + UNSPECIFIED = core_enum.deprecated_member( + 0, + "MetricConnectionCategory.UNSPECIFIED is deprecated; use the `int` value `0` " + "instead if you really need to check for this low-level value.", + ) """The connection category was not specified (do not use).""" OTHER = 1 @@ -100,7 +106,13 @@ class MetricConnection: """A connection from which a metric was obtained.""" category: MetricConnectionCategory | int - """The category of the connection from which the metric was obtained.""" + """The category of the connection from which the metric was obtained. + + This is the lower-level, forward-compatible accessor: it may hold a known + `MetricConnectionCategory` member, the raw `int` `0` when the category is + unspecified, or any other raw `int` not yet known to this client. Prefer + `MetricConnection.get_category()` to obtain a known member or a clear error. + """ name: str | None = None """The name of the specific connection from which the metric was obtained. diff --git a/src/frequenz/client/common/metrics/proto/v1alpha8/_sample.py b/src/frequenz/client/common/metrics/proto/v1alpha8/_sample.py index b254af4..6e804ab 100644 --- a/src/frequenz/client/common/metrics/proto/v1alpha8/_sample.py +++ b/src/frequenz/client/common/metrics/proto/v1alpha8/_sample.py @@ -56,13 +56,15 @@ def metric_connection_from_proto_with_issues( Returns: The resulting [`MetricConnection`][....MetricConnection] object. """ - category = metric_connection_category_from_proto(message.category) + raw = message.category + category: MetricConnectionCategory | int = ( + raw if raw == 0 else metric_connection_category_from_proto(raw) + ) - match category: - case MetricConnectionCategory.UNSPECIFIED: - major_issues.append("unspecified category") - case int(): - minor_issues.append(f"unrecognized category {category}") + if raw == 0: + major_issues.append("unspecified category") + elif isinstance(category, int): + minor_issues.append(f"unrecognized category {category}") return MetricConnection( category=category, diff --git a/tests/metrics/proto/v1alpha8/test_metric_connection_category.py b/tests/metrics/proto/v1alpha8/test_metric_connection_category.py index 81385c5..b9ecdd7 100644 --- a/tests/metrics/proto/v1alpha8/test_metric_connection_category.py +++ b/tests/metrics/proto/v1alpha8/test_metric_connection_category.py @@ -21,3 +21,4 @@ class TestMetricConnectionCategoryParity(EnumParityTest): name_prefix = "METRIC_CONNECTION_CATEGORY_" from_proto = staticmethod(metric_connection_category_from_proto) to_proto = staticmethod(metric_connection_category_to_proto) + deprecated_members = frozenset({"UNSPECIFIED"}) diff --git a/tests/metrics/proto/v1alpha8/test_sample_metric_connection.py b/tests/metrics/proto/v1alpha8/test_sample_metric_connection.py index cc9878a..1df6e09 100644 --- a/tests/metrics/proto/v1alpha8/test_sample_metric_connection.py +++ b/tests/metrics/proto/v1alpha8/test_sample_metric_connection.py @@ -3,6 +3,8 @@ """Tests for MetricConnection protobuf conversion.""" +import warnings + from frequenz.api.common.v1alpha8.metrics import metrics_pb2 from frequenz.client.common.metrics import MetricConnectionCategory @@ -13,22 +15,26 @@ def test_with_unspecified_category() -> None: - """Test conversion with UNSPECIFIED category reports major issue.""" + """Test conversion with unspecified category stores int 0 and reports it. + + The conversion must store the raw int ``0`` (not the deprecated member) and + must not emit any ``DeprecationWarning`` of its own. + """ proto = metrics_pb2.MetricConnection( - category=metric_connection_category_to_proto( - MetricConnectionCategory.UNSPECIFIED - ), + category=metrics_pb2.MetricConnectionCategory.METRIC_CONNECTION_CATEGORY_UNSPECIFIED, name="some_connection", ) major_issues: list[str] = [] minor_issues: list[str] = [] - connection = metric_connection_from_proto_with_issues( - proto, major_issues=major_issues, minor_issues=minor_issues - ) + with warnings.catch_warnings(): + warnings.simplefilter("error", DeprecationWarning) + connection = metric_connection_from_proto_with_issues( + proto, major_issues=major_issues, minor_issues=minor_issues + ) - assert connection.category == MetricConnectionCategory.UNSPECIFIED + assert connection.category == 0 assert connection.name == "some_connection" assert major_issues == ["unspecified category"] assert not minor_issues From 9f378033aa8469ee2b20f4db0477972a9209087f Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Tue, 23 Jun 2026 13:32:35 +0200 Subject: [PATCH 6/9] Add `DeliveryArea.get_code_type()` This is the higher-level accessor for the `code_type` attribute: it resolves the value to a known `EnergyMarketCodeType` member or raises a clear, catchable error. Signed-off-by: Leandro Lucarella --- .../client/common/grid/_delivery_area.py | 37 ++++++++++++++++++ .../grid/proto/v1alpha8/test_delivery_area.py | 18 +++++++++ tests/grid/test_delivery_area.py | 38 +++++++++++++++++++ 3 files changed, 93 insertions(+) diff --git a/src/frequenz/client/common/grid/_delivery_area.py b/src/frequenz/client/common/grid/_delivery_area.py index 49e8d81..08e2a48 100644 --- a/src/frequenz/client/common/grid/_delivery_area.py +++ b/src/frequenz/client/common/grid/_delivery_area.py @@ -3,10 +3,14 @@ """Delivery area information for the energy market.""" +import warnings from dataclasses import dataclass +from typing import assert_never from frequenz.core.enum import Enum, deprecated_member, unique +from .._exception import UnrecognizedValueError, UnspecifiedValueError + @unique class EnergyMarketCodeType(Enum): @@ -93,3 +97,36 @@ def __str__(self) -> str: else self.code_type.name ) return f"{code}[{code_type}]" + + def get_code_type(self) -> EnergyMarketCodeType: + """Return the code type as a known enum member. + + This is the higher-level accessor for the `code_type` attribute: it + resolves the value to a known `EnergyMarketCodeType` member or raises a + clear, catchable error. + + Returns: + The code type, when it is a known `EnergyMarketCodeType` member. + + Raises: + UnspecifiedValueError: If the code type is unspecified. + UnrecognizedValueError: If the code type is a value not recognized by + this version of the client. The raw value is available on the + exception's `value` attribute. + """ + # Suppressing the deprecation warning can be removed when UNSPECIFIED is removed + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=DeprecationWarning) + match self.code_type: + case 0 | EnergyMarketCodeType.UNSPECIFIED: + raise UnspecifiedValueError(f"code type of {self} is unspecified") + case EnergyMarketCodeType() as code_type: + return code_type + case int() as code_type: + raise UnrecognizedValueError( + code_type, + f"code type {code_type!r} of {self} is not a recognized " + "EnergyMarketCodeType", + ) + case unknown: + assert_never(unknown) diff --git a/tests/grid/proto/v1alpha8/test_delivery_area.py b/tests/grid/proto/v1alpha8/test_delivery_area.py index e61df92..608e852 100644 --- a/tests/grid/proto/v1alpha8/test_delivery_area.py +++ b/tests/grid/proto/v1alpha8/test_delivery_area.py @@ -3,11 +3,13 @@ """Tests for DeliveryArea and related to/from protobuf v1alpha8 conversion.""" +import warnings from dataclasses import dataclass import pytest from frequenz.api.common.v1alpha8.grid import delivery_area_pb2 +from frequenz.client.common import UnspecifiedValueError from frequenz.client.common.grid import EnergyMarketCodeType from frequenz.client.common.grid.proto.v1alpha8 import ( delivery_area_from_proto, @@ -117,3 +119,19 @@ def test_from_proto( assert "Found issues in delivery area" in caplog.records[0].message else: assert len(caplog.records) == 0 + + +def test_get_code_type_from_proto_unspecified_raises() -> None: + """A proto-loaded unspecified code type raises UnspecifiedValueError.""" + proto = delivery_area_pb2.DeliveryArea( + code="TEST", + code_type=( + delivery_area_pb2.EnergyMarketCodeType.ENERGY_MARKET_CODE_TYPE_UNSPECIFIED + ), + ) + with warnings.catch_warnings(): + warnings.simplefilter("error", DeprecationWarning) + area = delivery_area_from_proto(proto) + assert area.code_type == 0 + with pytest.raises(UnspecifiedValueError): + area.get_code_type() diff --git a/tests/grid/test_delivery_area.py b/tests/grid/test_delivery_area.py index a8c484b..4c7fb6f 100644 --- a/tests/grid/test_delivery_area.py +++ b/tests/grid/test_delivery_area.py @@ -3,10 +3,12 @@ """Tests for the DeliveryArea class.""" +import warnings from dataclasses import dataclass import pytest +from frequenz.client.common import UnrecognizedValueError, UnspecifiedValueError from frequenz.client.common.grid import DeliveryArea, EnergyMarketCodeType @@ -108,3 +110,39 @@ def test_unspecified_member_is_deprecated() -> None: with pytest.deprecated_call(): deprecated = EnergyMarketCodeType.UNSPECIFIED assert deprecated in EnergyMarketCodeType + + +@pytest.mark.parametrize( + "member", + [EnergyMarketCodeType.EUROPE_EIC, EnergyMarketCodeType.US_NERC], + ids=lambda member: member.name, +) +def test_get_code_type_returns_known_member(member: EnergyMarketCodeType) -> None: + """get_code_type() returns a known member unchanged.""" + area = DeliveryArea(code="10Y1001A1001A450", code_type=member) + assert area.get_code_type() is member + + +def test_get_code_type_raises_unspecified_for_int_zero() -> None: + """get_code_type() raises UnspecifiedValueError for a raw int 0 code type.""" + area = DeliveryArea(code="TEST", code_type=0) + with pytest.raises(UnspecifiedValueError): + area.get_code_type() + + +def test_get_code_type_raises_unspecified_for_value_zero_member() -> None: + """get_code_type() raises UnspecifiedValueError for the value-0 member.""" + with pytest.deprecated_call(): + area = DeliveryArea(code="TEST", code_type=EnergyMarketCodeType.UNSPECIFIED) + with warnings.catch_warnings(): + warnings.simplefilter("error", DeprecationWarning) + with pytest.raises(UnspecifiedValueError): + area.get_code_type() + + +def test_get_code_type_raises_unrecognized_for_unknown_int() -> None: + """get_code_type() raises UnrecognizedValueError carrying the raw value.""" + area = DeliveryArea(code="TEST", code_type=999) + with pytest.raises(UnrecognizedValueError) as exc_info: + area.get_code_type() + assert exc_info.value.value == 999 From d8a7a4003d7170ae1766a34127f04339e2439da4 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Mon, 22 Jun 2026 20:06:42 +0000 Subject: [PATCH 7/9] Add `MetricConnection.get_category()` Reading the low-level `category` fields can yield the raw int `0` for an unspecified value or an unknown `int` for a value this client does not recognize. These convenience methods resolve the field to a known enum member or raise a clear, catchable error. Signed-off-by: Leandro Lucarella --- src/frequenz/client/common/metrics/_sample.py | 36 +++++++++++++++++++ .../metrics/test_sample_metric_connection.py | 30 ++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/src/frequenz/client/common/metrics/_sample.py b/src/frequenz/client/common/metrics/_sample.py index 6afe8ee..7950249 100644 --- a/src/frequenz/client/common/metrics/_sample.py +++ b/src/frequenz/client/common/metrics/_sample.py @@ -4,6 +4,7 @@ """Definition to work with metric sample values.""" import enum +import warnings from collections.abc import Sequence from dataclasses import dataclass from datetime import datetime @@ -11,6 +12,7 @@ from frequenz.core import enum as core_enum +from .._exception import UnrecognizedValueError, UnspecifiedValueError from ._bounds import Bounds from ._metric import Metric @@ -134,6 +136,40 @@ def __str__(self) -> str: return f"{category_name}({self.name})" return category_name + def get_category(self) -> MetricConnectionCategory: + """Return the connection category as a known enum member. + + This is the higher-level accessor for the lower-level + [`category`][frequenz.client.common.metrics.MetricConnection.category] + field: it returns a known member or raises instead of exposing the raw + sentinel `0` or an unknown `int`. + + Returns: + The category when it is a known `MetricConnectionCategory` member. + + Raises: + UnspecifiedValueError: If the category is unspecified (the raw value + `0` or a member whose value is `0`). + UnrecognizedValueError: If the category is an `int` this client does + not recognize. The raw value is available on the error's `value` + attribute. + """ + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=DeprecationWarning) + match self.category: + case 0 | MetricConnectionCategory.UNSPECIFIED: + raise UnspecifiedValueError("connection category is unspecified") + case MetricConnectionCategory(): + return self.category + case int(): + raise UnrecognizedValueError( + self.category, + f"connection category {self.category!r} is not a recognized " + "MetricConnectionCategory", + ) + case unexpected: + assert_never(unexpected) + @dataclass(frozen=True, kw_only=True) class MetricSample: diff --git a/tests/metrics/test_sample_metric_connection.py b/tests/metrics/test_sample_metric_connection.py index d13f5b6..0c118a6 100644 --- a/tests/metrics/test_sample_metric_connection.py +++ b/tests/metrics/test_sample_metric_connection.py @@ -5,6 +5,7 @@ import pytest +from frequenz.client.common import UnrecognizedValueError, UnspecifiedValueError from frequenz.client.common.metrics import MetricConnection, MetricConnectionCategory @@ -98,3 +99,32 @@ def test_hash() -> None: conn3 = MetricConnection(category=MetricConnectionCategory.PV, name="dc_pv_0") conn_set = {conn1, conn2, conn3} assert len(conn_set) == 2 # conn1 and conn2 are equal + + +def test_get_category_returns_known_member() -> None: + """get_category returns the category when it is a known member.""" + connection = MetricConnection(category=MetricConnectionCategory.BATTERY) + assert connection.get_category() is MetricConnectionCategory.BATTERY + + +def test_get_category_unspecified_int_raises() -> None: + """get_category raises UnspecifiedValueError for the raw int 0.""" + connection = MetricConnection(category=0) + with pytest.raises(UnspecifiedValueError): + connection.get_category() + + +def test_get_category_unspecified_member_raises() -> None: + """get_category raises UnspecifiedValueError for the value-0 member.""" + with pytest.deprecated_call(): + connection = MetricConnection(category=MetricConnectionCategory.UNSPECIFIED) + with pytest.raises(UnspecifiedValueError): + connection.get_category() + + +def test_get_category_unrecognized_int_raises() -> None: + """get_category raises UnrecognizedValueError carrying the raw int value.""" + connection = MetricConnection(category=99999) + with pytest.raises(UnrecognizedValueError) as exc_info: + connection.get_category() + assert exc_info.value.value == 99999 From 268e50cc25e357119c1b8cad8755bce61ec4ecc9 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Tue, 23 Jun 2026 13:40:45 +0200 Subject: [PATCH 8/9] Add `MetricSample.get_metric()` Reading the low-level `metric` fields can yield the raw int `0` for an unspecified value or an unknown `int` for a value this client does not recognize. These convenience methods resolve the field to a known enum member or raise a clear, catchable error. Signed-off-by: Leandro Lucarella --- src/frequenz/client/common/metrics/_sample.py | 33 ++++++++++++++++++ tests/metrics/test_sample_metric_sample.py | 34 +++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/src/frequenz/client/common/metrics/_sample.py b/src/frequenz/client/common/metrics/_sample.py index 7950249..c58b3ab 100644 --- a/src/frequenz/client/common/metrics/_sample.py +++ b/src/frequenz/client/common/metrics/_sample.py @@ -281,3 +281,36 @@ def as_single_value( return None case unexpected: assert_never(unexpected) + + def get_metric(self) -> Metric: + """Return the sampled metric as a known enum member. + + This is the higher-level accessor for the lower-level + [`metric`][frequenz.client.common.metrics.MetricSample.metric] field: it + returns a known member or raises instead of exposing the raw sentinel + `0` or an unknown `int`. + + Returns: + The metric when it is a known `Metric` member. + + Raises: + UnspecifiedValueError: If the metric is unspecified (the raw value + `0` or a member whose value is `0`). + UnrecognizedValueError: If the metric is an `int` this client does + not recognize. The raw value is available on the error's `value` + attribute. + """ + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=DeprecationWarning) + match self.metric: + case 0 | Metric.UNSPECIFIED: + raise UnspecifiedValueError("sampled metric is unspecified") + case Metric(): + return self.metric + case int(): + raise UnrecognizedValueError( + self.metric, + f"sampled metric {self.metric!r} is not a recognized Metric", + ) + case unexpected: + assert_never(unexpected) diff --git a/tests/metrics/test_sample_metric_sample.py b/tests/metrics/test_sample_metric_sample.py index c3be654..1cc225e 100644 --- a/tests/metrics/test_sample_metric_sample.py +++ b/tests/metrics/test_sample_metric_sample.py @@ -7,6 +7,7 @@ import pytest +from frequenz.client.common import UnrecognizedValueError, UnspecifiedValueError from frequenz.client.common.metrics import ( AggregatedMetricValue, AggregationMethod, @@ -138,3 +139,36 @@ def test_multiple_bounds(now: datetime) -> None: bounds=bounds, ) assert sample.bounds == bounds + + +def test_get_metric_returns_known_member(now: datetime) -> None: + """get_metric returns the metric when it is a known member.""" + sample = MetricSample( + sample_time=now, metric=Metric.AC_POWER_ACTIVE, value=None, bounds=[] + ) + assert sample.get_metric() is Metric.AC_POWER_ACTIVE + + +def test_get_metric_unspecified_int_raises(now: datetime) -> None: + """get_metric raises UnspecifiedValueError for the raw int 0.""" + sample = MetricSample(sample_time=now, metric=0, value=None, bounds=[]) + with pytest.raises(UnspecifiedValueError): + sample.get_metric() + + +def test_get_metric_unspecified_member_raises(now: datetime) -> None: + """get_metric raises UnspecifiedValueError for the value-0 member.""" + with pytest.deprecated_call(): + sample = MetricSample( + sample_time=now, metric=Metric.UNSPECIFIED, value=None, bounds=[] + ) + with pytest.raises(UnspecifiedValueError): + sample.get_metric() + + +def test_get_metric_unrecognized_int_raises(now: datetime) -> None: + """get_metric raises UnrecognizedValueError carrying the raw int value.""" + sample = MetricSample(sample_time=now, metric=99999, value=None, bounds=[]) + with pytest.raises(UnrecognizedValueError) as exc_info: + sample.get_metric() + assert exc_info.value.value == 99999 From 3cdc627053d4b185a91864644273e64bbdb82c92 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Mon, 22 Jun 2026 20:16:23 +0000 Subject: [PATCH 9/9] Update release notes Signed-off-by: Leandro Lucarella --- RELEASE_NOTES.md | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index f57cb03..e04eef5 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -6,14 +6,38 @@ ## Upgrading - +* The `UNSPECIFIED` members in the following enums are now deprecated: + + * `frequenz.client.common.grid.EnergyMarketCodeType` + * `frequenz.client.common.metrics.Metric` + * `frequenz.client.common.metrics.MetricConnectionCategory` + + When loading these types from protobuf using dataclass-level converters (e.g., `delivery_area_from_proto`, `metric_sample_from_proto`), the low-level fields (`code_type`, `category`, `metric`) now store the raw integer `0` for unspecified values instead of the deprecated member. Unspecified values should be rare errors, so it is better to expose them only via the low-level interface. + + Lower-level enum-level converters still return the deprecated member. + + Users are encouraged to switch from direct field access to the new `get_*()` methods (see New Features), which provide a safer way to handle unspecified or unrecognized values. ## New Features +* Added new exceptions: + + * `frequenz.client.common.ClientCommonError` as a base exception for the package. + * `frequenz.client.common.UnspecifiedValueError` for unspecified values (raw `0` or the deprecated member). + * `frequenz.client.common.UnrecognizedValueError` for enum members not yet recognized by the library. Carries the raw integer value in its `value` attribute. + +* Added safe convenience getters that raise the new exceptions for unspecified or unrecognized values: + + * `frequenz.client.common.grid.DeliveryArea.get_code_type()` + * `frequenz.client.common.metrics.MetricConnection.get_category()` + * `frequenz.client.common.metrics.MetricSample.get_metric()` + * Added a new `frequenz.client.common.types.Lifetime` type together with the `frequenz.client.common.types.proto.v1alpha8.lifetime_from_proto` conversion function. + * Added a new `frequenz.client.common.types.Location` type together with the `frequenz.client.common.types.proto.v1alpha8.location_from_proto` conversion function. + * Added a new `frequenz.client.common.microgrid.Microgrid` type, together with the `frequenz.client.common.microgrid.proto.v1alpha8.microgrid_from_proto` conversion function. -* Added a new `frequenz.client.common.ClientCommonError` base exception and `UnspecifiedValueError` at the package root. + * Added a new `frequenz.client.common.microgrid.electrical_components` package, featuring a `ElectricalComponent` class hierarchy and its families (battery, inverter, EV charger, etc.), and `ElectricalComponentConnection`, including `v1alpha8` proto conversion functions. ## Bug Fixes