From 27d03c90c3a602023446aac8487bf2bd887b53ea Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Tue, 7 Jul 2026 10:03:10 +0200 Subject: [PATCH 01/13] Add `InvalidAttributeError` exception This exception will be the base for any exception raised when an attribute that holds invalid data is accessed through a semantic accessor. Signed-off-by: Leandro Lucarella --- src/frequenz/client/common/__init__.py | 2 ++ src/frequenz/client/common/_exception.py | 30 +++++++++++++++++ .../exception/test_invalid_attribute_error.py | 33 +++++++++++++++++++ 3 files changed, 65 insertions(+) create mode 100644 tests/exception/test_invalid_attribute_error.py diff --git a/src/frequenz/client/common/__init__.py b/src/frequenz/client/common/__init__.py index 302376ce..8752297e 100644 --- a/src/frequenz/client/common/__init__.py +++ b/src/frequenz/client/common/__init__.py @@ -5,12 +5,14 @@ from ._exception import ( ClientCommonError, + InvalidAttributeError, UnrecognizedEnumValueError, UnspecifiedEnumValueError, ) __all__ = [ "ClientCommonError", + "InvalidAttributeError", "UnrecognizedEnumValueError", "UnspecifiedEnumValueError", ] diff --git a/src/frequenz/client/common/_exception.py b/src/frequenz/client/common/_exception.py index d7fb21b0..67f8b8a9 100644 --- a/src/frequenz/client/common/_exception.py +++ b/src/frequenz/client/common/_exception.py @@ -8,6 +8,36 @@ class ClientCommonError(Exception): """Base class for all errors raised by frequenz-client-common.""" +class InvalidAttributeError(ClientCommonError, ValueError): + """Raised when a semantic accessor sees an invalid value for a field. + + This is also a [`ValueError`][] for convenience. + """ + + def __init__( + self, instance: object, attr_name: str, message: str | None = None + ) -> None: + """Initialize this error. + + Args: + instance: The object instance that had an invalid value. + attr_name: The name of the attribute that had an invalid value. + message: A custom error message. If `None`, a default message + mentioning the instance and attribute is used. + """ + self.instance: object = instance + """The object instance that had an invalid value.""" + + self.attr_name: str = attr_name + """The name of the attribute that had an invalid value.""" + + super().__init__( + message + if message is not None + else f"invalid value for attribute {attr_name!r} in {instance}" + ) + + class UnrecognizedEnumValueError(ClientCommonError, ValueError): """Raised when a semantic accessor sees an unrecognized protobuf enum value. diff --git a/tests/exception/test_invalid_attribute_error.py b/tests/exception/test_invalid_attribute_error.py new file mode 100644 index 00000000..0a6b56ba --- /dev/null +++ b/tests/exception/test_invalid_attribute_error.py @@ -0,0 +1,33 @@ +# License: MIT +# Copyright © 2026 Frequenz Energy-as-a-Service GmbH + +"""Tests for `InvalidAttributeError`.""" + +from frequenz.client.common import ClientCommonError, InvalidAttributeError + + +def test_is_client_common_and_value_error() -> None: + """`InvalidAttributeError` is both a `ClientCommonError` and a `ValueError`.""" + assert issubclass(InvalidAttributeError, ClientCommonError) + assert issubclass(InvalidAttributeError, ValueError) + + +def test_stores_instance_and_attr_name() -> None: + """`InvalidAttributeError` stores its `instance` and `attr_name` args as attributes.""" + instance = object() + error = InvalidAttributeError(instance, "attr_x") + assert error.instance is instance + assert error.attr_name == "attr_x" + + +def test_default_message() -> None: + """The default message follows the `invalid value for attribute ...` template.""" + assert ( + str(InvalidAttributeError("some-instance", "attr_x")) + == "invalid value for attribute 'attr_x' in some-instance" + ) + + +def test_custom_message_replaces_the_default() -> None: + """A custom message replaces the default entirely.""" + assert str(InvalidAttributeError("i", "a", "explicit msg")) == "explicit msg" From ece1be7853a8962f18533acfb65e24e65a07e38c Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Tue, 7 Jul 2026 10:42:38 +0200 Subject: [PATCH 02/13] Make value errors inherit from `InvalidAttributeError` Make `UnrecognizedEnumValueError` and `UnspecifiedEnumValueError` inherit from `InvalidAttributeError`. Also split exception tests as the file grew too much when adding the new tests. Signed-off-by: Leandro Lucarella --- src/frequenz/client/common/_exception.py | 41 +++++++++++++++-- .../client/common/grid/_delivery_area.py | 10 +---- src/frequenz/client/common/metrics/_sample.py | 17 ++----- .../client/common/microgrid/_microgrid.py | 7 ++- .../_electrical_component.py | 12 ++++- tests/exception/__init__.py | 4 ++ tests/exception/test_client_common_error.py | 24 ++++++++++ .../test_unrecognized_enum_value_error.py | 41 +++++++++++++++++ .../test_unspecified_enum_value_error.py | 38 ++++++++++++++++ tests/test_exception.py | 44 ------------------- 10 files changed, 165 insertions(+), 73 deletions(-) create mode 100644 tests/exception/__init__.py create mode 100644 tests/exception/test_client_common_error.py create mode 100644 tests/exception/test_unrecognized_enum_value_error.py create mode 100644 tests/exception/test_unspecified_enum_value_error.py delete mode 100644 tests/test_exception.py diff --git a/src/frequenz/client/common/_exception.py b/src/frequenz/client/common/_exception.py index 67f8b8a9..6c971eb7 100644 --- a/src/frequenz/client/common/_exception.py +++ b/src/frequenz/client/common/_exception.py @@ -38,7 +38,7 @@ def __init__( ) -class UnrecognizedEnumValueError(ClientCommonError, ValueError): +class UnrecognizedEnumValueError(InvalidAttributeError): """Raised when a semantic accessor sees an unrecognized protobuf enum value. This happens when the server sets an enum value that this version of the @@ -49,21 +49,33 @@ class UnrecognizedEnumValueError(ClientCommonError, ValueError): This is also a ``ValueError`` for convenience. """ - def __init__(self, value: int, message: str | None = None) -> None: + def __init__( + self, instance: object, attr_name: str, value: int, message: str | None = None + ) -> None: """Initialize this error. Args: + instance: The object instance that had the unrecognized value. + attr_name: The name of the attribute that had the unrecognized value. 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 + """The raw protobuf value that was not recognized.""" + super().__init__( - message if message is not None else f"unrecognized enum value: {value!r}" + instance, + attr_name, + ( + message + if message is not None + else f"unrecognized enum value {value!r} for attribute {attr_name!r} in {instance}" + ), ) -class UnspecifiedEnumValueError(ClientCommonError, ValueError): +class UnspecifiedEnumValueError(InvalidAttributeError): """Raised when a semantic accessor sees an unspecified protobuf enum value. For a value that is set but not recognized by this client, see @@ -71,3 +83,24 @@ class UnspecifiedEnumValueError(ClientCommonError, ValueError): This is also a [`ValueError`][] for convenience. """ + + def __init__( + self, instance: object, attr_name: str, message: str | None = None + ) -> None: + """Initialize this error. + + Args: + instance: The object instance that had the unspecified value. + attr_name: The name of the attribute that had the unspecified value. + message: A custom error message. If `None`, a default message + mentioning the unspecified value is used. + """ + super().__init__( + instance, + attr_name, + ( + message + if message is not None + else f"unspecified enum value for attribute {attr_name!r} in {instance}" + ), + ) diff --git a/src/frequenz/client/common/grid/_delivery_area.py b/src/frequenz/client/common/grid/_delivery_area.py index 4dd4e990..5cabaa4d 100644 --- a/src/frequenz/client/common/grid/_delivery_area.py +++ b/src/frequenz/client/common/grid/_delivery_area.py @@ -119,16 +119,10 @@ def get_code_type(self) -> EnergyMarketCodeType: warnings.filterwarnings("ignore", category=DeprecationWarning) match self.code_type: case 0 | EnergyMarketCodeType.UNSPECIFIED: - raise UnspecifiedEnumValueError( - f"code type of {self} is unspecified" - ) + raise UnspecifiedEnumValueError(self, "code_type") case EnergyMarketCodeType() as code_type: return code_type case int() as code_type: - raise UnrecognizedEnumValueError( - code_type, - f"code type {code_type!r} of {self} is not a recognized " - "EnergyMarketCodeType", - ) + raise UnrecognizedEnumValueError(self, "code_type", code_type) case unknown: assert_never(unknown) diff --git a/src/frequenz/client/common/metrics/_sample.py b/src/frequenz/client/common/metrics/_sample.py index 99eddfda..71dfb515 100644 --- a/src/frequenz/client/common/metrics/_sample.py +++ b/src/frequenz/client/common/metrics/_sample.py @@ -157,17 +157,11 @@ def get_category(self) -> MetricConnectionCategory: warnings.filterwarnings("ignore", category=DeprecationWarning) match self.category: case 0 | MetricConnectionCategory.UNSPECIFIED: - raise UnspecifiedEnumValueError( - "connection category is unspecified" - ) + raise UnspecifiedEnumValueError(self, "category") case MetricConnectionCategory(): return self.category case int(): - raise UnrecognizedEnumValueError( - self.category, - f"connection category {self.category!r} is not a recognized " - "MetricConnectionCategory", - ) + raise UnrecognizedEnumValueError(self, "category", self.category) case unexpected: assert_never(unexpected) @@ -305,13 +299,10 @@ def get_metric(self) -> Metric: warnings.filterwarnings("ignore", category=DeprecationWarning) match self.metric: case 0 | Metric.UNSPECIFIED: - raise UnspecifiedEnumValueError("sampled metric is unspecified") + raise UnspecifiedEnumValueError(self, "metric") case Metric(): return self.metric case int(): - raise UnrecognizedEnumValueError( - self.metric, - f"sampled metric {self.metric!r} is not a recognized Metric", - ) + raise UnrecognizedEnumValueError(self, "metric", self.metric) case unexpected: assert_never(unexpected) diff --git a/src/frequenz/client/common/microgrid/_microgrid.py b/src/frequenz/client/common/microgrid/_microgrid.py index 21d6641c..d3a0788c 100644 --- a/src/frequenz/client/common/microgrid/_microgrid.py +++ b/src/frequenz/client/common/microgrid/_microgrid.py @@ -93,11 +93,14 @@ def is_active(self) -> bool: return active case 0: raise UnspecifiedEnumValueError( - f"status of microgrid {self} is unspecified" + self, "_active", f"status of microgrid {self} is unspecified" ) case int() as value: raise UnrecognizedEnumValueError( - value, f"unrecognized status of microgrid {self}: {value!r}" + self, + "_active", + value, + f"unrecognized status of microgrid {self}: {value!r}", ) case unknown: assert_never(unknown) 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 6d458583..1c06514f 100644 --- a/src/frequenz/client/common/microgrid/electrical_components/_electrical_component.py +++ b/src/frequenz/client/common/microgrid/electrical_components/_electrical_component.py @@ -133,11 +133,15 @@ def provides_telemetry(self) -> bool: return provides_telemetry case 0: raise UnspecifiedEnumValueError( + self, + "_provides_telemetry", f"operational mode of {self} is unspecified; " - "telemetry availability is unknown" + "telemetry availability is unknown", ) case int() as value: raise UnrecognizedEnumValueError( + self, + "_provides_telemetry", value, f"operational mode {value!r} of {self} is not a recognized " "ElectricalComponentOperationalMode; telemetry availability " @@ -162,11 +166,15 @@ def accepts_control(self) -> bool: return accepts_control case 0: raise UnspecifiedEnumValueError( + self, + "_accepts_control", f"operational mode of {self} is unspecified; " - "control availability is unknown" + "control availability is unknown", ) case int() as value: raise UnrecognizedEnumValueError( + self, + "_accepts_control", value, f"operational mode {value!r} of {self} is not a recognized " "ElectricalComponentOperationalMode; control availability " diff --git a/tests/exception/__init__.py b/tests/exception/__init__.py new file mode 100644 index 00000000..691c2190 --- /dev/null +++ b/tests/exception/__init__.py @@ -0,0 +1,4 @@ +# License: MIT +# Copyright © 2026 Frequenz Energy-as-a-Service GmbH + +"""Tests for the common exception hierarchy.""" diff --git a/tests/exception/test_client_common_error.py b/tests/exception/test_client_common_error.py new file mode 100644 index 00000000..96b2f361 --- /dev/null +++ b/tests/exception/test_client_common_error.py @@ -0,0 +1,24 @@ +# License: MIT +# Copyright © 2026 Frequenz Energy-as-a-Service GmbH + +"""Tests for `ClientCommonError` and cross-cutting exception-module checks.""" + +import frequenz.client.common +from frequenz.client.common import ClientCommonError + + +def test_is_a_plain_exception() -> None: + """`ClientCommonError` is an `Exception` but deliberately not a `ValueError`.""" + assert issubclass(ClientCommonError, Exception) + assert not issubclass(ClientCommonError, ValueError) + + +def test_all_exports_every_exception_class() -> None: + """Every public exception class is re-exported through the package `__all__`.""" + for name in ( + "ClientCommonError", + "InvalidAttributeError", + "UnrecognizedEnumValueError", + "UnspecifiedEnumValueError", + ): + assert name in frequenz.client.common.__all__ diff --git a/tests/exception/test_unrecognized_enum_value_error.py b/tests/exception/test_unrecognized_enum_value_error.py new file mode 100644 index 00000000..87b17ac1 --- /dev/null +++ b/tests/exception/test_unrecognized_enum_value_error.py @@ -0,0 +1,41 @@ +# License: MIT +# Copyright © 2026 Frequenz Energy-as-a-Service GmbH + +"""Tests for `UnrecognizedEnumValueError`.""" + +from frequenz.client.common import ( + ClientCommonError, + InvalidAttributeError, + UnrecognizedEnumValueError, +) + + +def test_inherits_invalid_attribute_error() -> None: + """`UnrecognizedEnumValueError` inherits `InvalidAttributeError` (and thus `ValueError`).""" + assert issubclass(UnrecognizedEnumValueError, InvalidAttributeError) + assert issubclass(UnrecognizedEnumValueError, ClientCommonError) + assert issubclass(UnrecognizedEnumValueError, ValueError) + + +def test_stores_instance_attr_name_and_value() -> None: + """`UnrecognizedEnumValueError` stores `instance`, `attr_name`, and `value` as attributes.""" + instance = object() + error = UnrecognizedEnumValueError(instance, "attr_x", 999) + assert error.instance is instance + assert error.attr_name == "attr_x" + assert error.value == 999 + + +def test_default_message() -> None: + """The default message follows the `unrecognized enum value ...` template.""" + assert ( + str(UnrecognizedEnumValueError("some-instance", "attr_x", 7)) + == "unrecognized enum value 7 for attribute 'attr_x' in some-instance" + ) + + +def test_custom_message_replaces_the_default() -> None: + """A custom message replaces the default entirely.""" + assert ( + str(UnrecognizedEnumValueError("i", "a", 7, "explicit msg")) == "explicit msg" + ) diff --git a/tests/exception/test_unspecified_enum_value_error.py b/tests/exception/test_unspecified_enum_value_error.py new file mode 100644 index 00000000..adbf156a --- /dev/null +++ b/tests/exception/test_unspecified_enum_value_error.py @@ -0,0 +1,38 @@ +# License: MIT +# Copyright © 2026 Frequenz Energy-as-a-Service GmbH + +"""Tests for `UnspecifiedEnumValueError`.""" + +from frequenz.client.common import ( + ClientCommonError, + InvalidAttributeError, + UnspecifiedEnumValueError, +) + + +def test_inherits_invalid_attribute_error() -> None: + """`UnspecifiedEnumValueError` inherits `InvalidAttributeError` (and thus `ValueError`).""" + assert issubclass(UnspecifiedEnumValueError, InvalidAttributeError) + assert issubclass(UnspecifiedEnumValueError, ClientCommonError) + assert issubclass(UnspecifiedEnumValueError, ValueError) + + +def test_stores_instance_and_attr_name() -> None: + """`UnspecifiedEnumValueError` stores its `instance` and `attr_name` args.""" + instance = object() + error = UnspecifiedEnumValueError(instance, "attr_x") + assert error.instance is instance + assert error.attr_name == "attr_x" + + +def test_default_message() -> None: + """The default message follows the `unspecified enum value ...` template.""" + assert ( + str(UnspecifiedEnumValueError("some-instance", "attr_x")) + == "unspecified enum value for attribute 'attr_x' in some-instance" + ) + + +def test_custom_message_replaces_the_default() -> None: + """A custom message replaces the default entirely.""" + assert str(UnspecifiedEnumValueError("i", "a", "explicit msg")) == "explicit msg" diff --git a/tests/test_exception.py b/tests/test_exception.py deleted file mode 100644 index 0308cc6b..00000000 --- a/tests/test_exception.py +++ /dev/null @@ -1,44 +0,0 @@ -"""Tests for common exceptions.""" - -import frequenz.client.common -from frequenz.client.common import ( - ClientCommonError, - UnrecognizedEnumValueError, - UnspecifiedEnumValueError, -) - - -def test_exceptions_exported_and_related() -> None: - """Given exception exports, then their hierarchy and string form are correct.""" - assert issubclass(UnspecifiedEnumValueError, ClientCommonError) - assert issubclass(UnspecifiedEnumValueError, ValueError) - assert issubclass(UnrecognizedEnumValueError, ClientCommonError) - assert issubclass(UnrecognizedEnumValueError, ValueError) - assert not issubclass(ClientCommonError, ValueError) - assert str(UnspecifiedEnumValueError("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 "UnrecognizedEnumValueError" in frequenz.client.common.__all__ - assert list(frequenz.client.common.__all__) == sorted( - frequenz.client.common.__all__ - ) - - -def test_unrecognized_enum_value_error_carries_value() -> None: - """Given an unrecognized value, then it is stored and catchable as both base types.""" - error = UnrecognizedEnumValueError(999) - assert error.value == 999 - assert isinstance(error, ValueError) - assert isinstance(error, ClientCommonError) - - -def test_unrecognized_enum_value_error_default_message() -> None: - """Given no explicit message, then the default string contains the raw value.""" - assert "7" in str(UnrecognizedEnumValueError(7)) - - -def test_unrecognized_enum_value_error_custom_message() -> None: - """Given a custom message, then the string form is exactly that message.""" - assert str(UnrecognizedEnumValueError(7, "msg")) == "msg" From 9953c5b213e600fef2e4fe7ef5b1a39b236388f7 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Tue, 7 Jul 2026 14:25:15 +0200 Subject: [PATCH 03/13] Add `MissingFieldError` exception Add a new top-level exception `MissingFieldError`, sibling of `UnspecifiedEnumValueError`, to signal that a semantic accessor was called on a wrapper field that resolves to `None` because the underlying field was not set on the wire. The first user is the upcoming `Microgrid.get_delivery_area()` accessor (delivery area may be `None`, `InvalidDeliveryArea`, or `DeliveryArea`), but the pattern is expected to recur wherever an accessor unifies a `T | ... | None` field into a plain `T` return. Like `Unspecified/UnrecognizedEnumValueError`, it multi-inherits from `ClientCommonError` and `ValueError` for convenience. Signed-off-by: Leandro Lucarella --- src/frequenz/client/common/__init__.py | 2 ++ src/frequenz/client/common/_exception.py | 32 +++++++++++++++++ tests/exception/test_client_common_error.py | 1 + tests/exception/test_missing_field_error.py | 38 +++++++++++++++++++++ 4 files changed, 73 insertions(+) create mode 100644 tests/exception/test_missing_field_error.py diff --git a/src/frequenz/client/common/__init__.py b/src/frequenz/client/common/__init__.py index 8752297e..0580c58d 100644 --- a/src/frequenz/client/common/__init__.py +++ b/src/frequenz/client/common/__init__.py @@ -6,6 +6,7 @@ from ._exception import ( ClientCommonError, InvalidAttributeError, + MissingFieldError, UnrecognizedEnumValueError, UnspecifiedEnumValueError, ) @@ -13,6 +14,7 @@ __all__ = [ "ClientCommonError", "InvalidAttributeError", + "MissingFieldError", "UnrecognizedEnumValueError", "UnspecifiedEnumValueError", ] diff --git a/src/frequenz/client/common/_exception.py b/src/frequenz/client/common/_exception.py index 6c971eb7..dd7317b4 100644 --- a/src/frequenz/client/common/_exception.py +++ b/src/frequenz/client/common/_exception.py @@ -104,3 +104,35 @@ def __init__( else f"unspecified enum value for attribute {attr_name!r} in {instance}" ), ) + + +class MissingFieldError(InvalidAttributeError): + """Raised when a semantic accessor sees a missing optional field. + + This is used by accessors that resolve a wrapper field which may be + absent (typed as `T | None` or `T | ... | None`) to a concrete value, + when the underlying field was not set on the wire. + + This is also a [`ValueError`][] for convenience. + """ + + def __init__( + self, instance: object, attr_name: str, message: str | None = None + ) -> None: + """Initialize this error. + + Args: + instance: The object instance that was missing the field. + attr_name: The name of the missing field. + message: A custom error message. If `None`, a default message + mentioning the missing field is used. + """ + super().__init__( + instance, + attr_name, + ( + message + if message is not None + else f"missing protobuf field {attr_name!r} in {instance}" + ), + ) diff --git a/tests/exception/test_client_common_error.py b/tests/exception/test_client_common_error.py index 96b2f361..2c3ed4ee 100644 --- a/tests/exception/test_client_common_error.py +++ b/tests/exception/test_client_common_error.py @@ -18,6 +18,7 @@ def test_all_exports_every_exception_class() -> None: for name in ( "ClientCommonError", "InvalidAttributeError", + "MissingFieldError", "UnrecognizedEnumValueError", "UnspecifiedEnumValueError", ): diff --git a/tests/exception/test_missing_field_error.py b/tests/exception/test_missing_field_error.py new file mode 100644 index 00000000..a4146e77 --- /dev/null +++ b/tests/exception/test_missing_field_error.py @@ -0,0 +1,38 @@ +# License: MIT +# Copyright © 2026 Frequenz Energy-as-a-Service GmbH + +"""Tests for `MissingFieldError`.""" + +from frequenz.client.common import ( + ClientCommonError, + InvalidAttributeError, + MissingFieldError, +) + + +def test_inherits_invalid_attribute_error() -> None: + """`MissingFieldError` inherits `InvalidAttributeError` (and thus `ValueError`).""" + assert issubclass(MissingFieldError, InvalidAttributeError) + assert issubclass(MissingFieldError, ClientCommonError) + assert issubclass(MissingFieldError, ValueError) + + +def test_stores_instance_and_attr_name() -> None: + """`MissingFieldError` stores its `instance` and `attr_name` args.""" + instance = object() + error = MissingFieldError(instance, "field_x") + assert error.instance is instance + assert error.attr_name == "field_x" + + +def test_default_message() -> None: + """The default message follows the `missing protobuf field ...` template.""" + assert ( + str(MissingFieldError("some-instance", "field_x")) + == "missing protobuf field 'field_x' in some-instance" + ) + + +def test_custom_message_replaces_the_default() -> None: + """A custom message replaces the default entirely.""" + assert str(MissingFieldError("i", "a", "explicit msg")) == "explicit msg" From 6c2164ade185d0c92dc327948b3d7e8420c216e3 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Thu, 9 Jul 2026 15:04:11 +0200 Subject: [PATCH 04/13] Update release notes Signed-off-by: Leandro Lucarella --- RELEASE_NOTES.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 51c99511..084c6c96 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -63,6 +63,8 @@ * Added new exceptions: * `frequenz.client.common.ClientCommonError` as a base exception for the package. + * `frequenz.client.common.InvalidAttributeError` as a base for all exceptions raised when an invalid attribute is encountered. Inherits also from `ValueError` for convenience. + * `frequenz.client.common.MissingFieldError` for accessors that resolve a `T | ... | None` wrapper field to a concrete value and see `None` because the underlying field was not set on the wire. * `frequenz.client.common.UnspecifiedEnumValueError` for unspecified enum values (raw `0` or the deprecated member). * `frequenz.client.common.UnrecognizedEnumValueError` for enum members not yet recognized by the library. Carries the raw integer value in its `value` attribute. From a2b1d5e27b453d529445aac6c2158fcf0f1c92e3 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Mon, 6 Jul 2026 14:55:29 +0000 Subject: [PATCH 05/13] Add `BaseDeliveryArea` abstract base class This class will be used as the base for both valid and invalid delivery area classes. Signed-off-by: Leandro Lucarella --- src/frequenz/client/common/grid/__init__.py | 7 +++- .../client/common/grid/_delivery_area.py | 34 ++++++++++++++++++- tests/grid/test_delivery_area.py | 12 ++++++- 3 files changed, 50 insertions(+), 3 deletions(-) diff --git a/src/frequenz/client/common/grid/__init__.py b/src/frequenz/client/common/grid/__init__.py index 68c239c9..24711426 100644 --- a/src/frequenz/client/common/grid/__init__.py +++ b/src/frequenz/client/common/grid/__init__.py @@ -3,9 +3,14 @@ """Grid definitions for the energy market.""" -from ._delivery_area import DeliveryArea, EnergyMarketCodeType +from ._delivery_area import ( + BaseDeliveryArea, + DeliveryArea, + EnergyMarketCodeType, +) __all__ = [ + "BaseDeliveryArea", "DeliveryArea", "EnergyMarketCodeType", ] diff --git a/src/frequenz/client/common/grid/_delivery_area.py b/src/frequenz/client/common/grid/_delivery_area.py index 5cabaa4d..67d56f66 100644 --- a/src/frequenz/client/common/grid/_delivery_area.py +++ b/src/frequenz/client/common/grid/_delivery_area.py @@ -5,7 +5,7 @@ import warnings from dataclasses import dataclass -from typing import assert_never +from typing import Any, Self, assert_never from frequenz.core.enum import Enum, deprecated_member, unique @@ -54,6 +54,38 @@ class EnergyMarketCodeType(Enum): """North American Electric Reliability Corporation identifiers.""" +@dataclass(frozen=True, kw_only=True) +class BaseDeliveryArea: + """A base class for all delivery areas.""" + + code: str | None + """The code representing the unique identifier for the delivery area.""" + + code_type: EnergyMarketCodeType | int + """Type of code used for identifying the delivery area itself. + + 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. + """ + + # pylint: disable-next=unused-argument + def __new__(cls, *args: Any, **kwargs: Any) -> Self: + """Prevent instantiation of this class.""" + if cls is BaseDeliveryArea: + raise TypeError(f"Cannot instantiate {cls.__name__} directly") + return super().__new__(cls) + + def __str__(self) -> str: + """Return a human-readable string representation of this instance.""" + code = self.code or "" + code_type = ( + f"type={self.code_type}" + if isinstance(self.code_type, int) + else self.code_type.name + ) + return f"{code}[{code_type}]" + + @dataclass(frozen=True, kw_only=True) class DeliveryArea: """A geographical or administrative region where electricity deliveries occur. diff --git a/tests/grid/test_delivery_area.py b/tests/grid/test_delivery_area.py index 8714ac0f..bd039ede 100644 --- a/tests/grid/test_delivery_area.py +++ b/tests/grid/test_delivery_area.py @@ -12,7 +12,11 @@ UnrecognizedEnumValueError, UnspecifiedEnumValueError, ) -from frequenz.client.common.grid import DeliveryArea, EnergyMarketCodeType +from frequenz.client.common.grid import ( + BaseDeliveryArea, + DeliveryArea, + EnergyMarketCodeType, +) @dataclass(frozen=True, kw_only=True) @@ -133,6 +137,12 @@ def test_get_code_type_raises_unspecified_for_int_zero() -> None: area.get_code_type() +def test_base_delivery_area_cannot_be_instantiated_directly() -> None: + """`BaseDeliveryArea` refuses direct instantiation.""" + with pytest.raises(TypeError, match="Cannot instantiate BaseDeliveryArea"): + BaseDeliveryArea(code="TEST", code_type=EnergyMarketCodeType.EUROPE_EIC) + + def test_get_code_type_raises_unspecified_for_value_zero_member() -> None: """get_code_type() raises UnspecifiedEnumValueError for the value-0 member.""" with pytest.deprecated_call(): From 510cc3187e5276a0e1bce20104e34f3edeaa21c5 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Tue, 7 Jul 2026 12:34:57 +0200 Subject: [PATCH 06/13] Make `DeliveryArea` inherit from `BaseDeliveryArea` `DeliveryArea`, the existing type, is retroactively made a subclass of `BaseDeliveryArea`. Field shape and construction API are unchanged (`code: str | None`, `code_type: EnergyMarketCodeType | int`) for backwards compatibility, but a `__post_init__` is added with invariant checks: a well-formed delivery area must have a non-empty `code` and a specified `code_type`. Constructing one without that doesn't pass those invariants will now emit a `DeprecationWarning` (and it will raise `ValueError` in v0.5.0). Signed-off-by: Leandro Lucarella --- .../client/common/grid/_delivery_area.py | 31 +++++-------------- tests/grid/test_delivery_area.py | 5 +++ 2 files changed, 12 insertions(+), 24 deletions(-) diff --git a/src/frequenz/client/common/grid/_delivery_area.py b/src/frequenz/client/common/grid/_delivery_area.py index 67d56f66..3d17f329 100644 --- a/src/frequenz/client/common/grid/_delivery_area.py +++ b/src/frequenz/client/common/grid/_delivery_area.py @@ -66,6 +66,12 @@ class BaseDeliveryArea: 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. + + Tip: + This is the lower-level accessor; when working with a valid + [`DeliveryArea`][...DeliveryArea], prefer + [`get_code_type`][...DeliveryArea.get_code_type] to obtain a known + member or a clear error. """ # pylint: disable-next=unused-argument @@ -87,7 +93,7 @@ def __str__(self) -> str: @dataclass(frozen=True, kw_only=True) -class DeliveryArea: +class DeliveryArea(BaseDeliveryArea): """A geographical or administrative region where electricity deliveries occur. DeliveryArea represents the geographical or administrative region, usually defined @@ -107,29 +113,6 @@ class DeliveryArea: EICs](https://www.entsoe.eu/data/energy-identification-codes-eic/eic-approved-codes/). """ - code: str | None - """The code representing the unique identifier for the delivery area.""" - - code_type: EnergyMarketCodeType | int - """Type of code used for identifying the delivery area itself. - - 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: - """Return a human-readable string representation of this instance.""" - code = self.code or "" - code_type = ( - f"type={self.code_type}" - if isinstance(self.code_type, int) - 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. diff --git a/tests/grid/test_delivery_area.py b/tests/grid/test_delivery_area.py index bd039ede..800005a4 100644 --- a/tests/grid/test_delivery_area.py +++ b/tests/grid/test_delivery_area.py @@ -159,3 +159,8 @@ def test_get_code_type_raises_unrecognized_for_unknown_int() -> None: with pytest.raises(UnrecognizedEnumValueError) as exc_info: area.get_code_type() assert exc_info.value.value == 999 + + +def test_delivery_area_is_base_delivery_area_subclass() -> None: + """`DeliveryArea` is a subclass of `BaseDeliveryArea`.""" + assert issubclass(DeliveryArea, BaseDeliveryArea) From 6d9518bfbddb13d823e825365d4600b2dd1f549d Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Tue, 7 Jul 2026 12:43:16 +0200 Subject: [PATCH 07/13] Deprecate constructing invalid `DeliveryArea` A `__post_init__` is added with invariant checks: a well-formed delivery area must have a non-empty `code` and a specified `code_type`. Constructing one without that doesn't pass the invariant will now emit a `DeprecationWarning` (and it will raise `ValueError` in v0.5.0). Signed-off-by: Leandro Lucarella --- .../client/common/grid/_delivery_area.py | 42 +++++++++++++ .../grid/proto/v1alpha8/_delivery_area.py | 10 +++- tests/grid/test_delivery_area.py | 60 +++++++++++++++---- 3 files changed, 100 insertions(+), 12 deletions(-) diff --git a/src/frequenz/client/common/grid/_delivery_area.py b/src/frequenz/client/common/grid/_delivery_area.py index 3d17f329..1fb6593f 100644 --- a/src/frequenz/client/common/grid/_delivery_area.py +++ b/src/frequenz/client/common/grid/_delivery_area.py @@ -104,6 +104,12 @@ class DeliveryArea(BaseDeliveryArea): location. Delivery areas can have different codes based on the jurisdiction in which they operate. + Warning: Construction of invalid instances is deprecated + A well-formed `DeliveryArea` carries a non-empty [`code`][.code] and a known + well specified [`code_type`][.code_type]. Constructing one with data that violates + these invariants is **deprecated**, and will raise a [`ValueError`][] in a future + release. + Note: Jurisdictional Differences This is typically represented by specific codes according to local jurisdiction. @@ -113,6 +119,42 @@ class DeliveryArea(BaseDeliveryArea): EICs](https://www.entsoe.eu/data/energy-identification-codes-eic/eic-approved-codes/). """ + def __post_init__(self) -> None: + """Warn if this instance carries invalid data. + + A well-formed `DeliveryArea` has: + + * `code` non-empty; and + * `code_type` that is not raw `0` or the deprecated + [`EnergyMarketCodeType.UNSPECIFIED`][...EnergyMarketCodeType] + member. + + Constructing with any other data currently emits a + [`DeprecationWarning`][]; this will become a hard [`ValueError`][] + in a future release. + """ + issues: list[str] = [] + if not self.code: + issues.append("`code` must be non-empty, got " f"{self.code!r}") + # Suppressing the deprecation warning can be removed when UNSPECIFIED is removed + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=DeprecationWarning) + if ( + self.code_type == 0 + or self.code_type is EnergyMarketCodeType.UNSPECIFIED + ): + issues.append( + "`code_type` must not be unspecified, got " f"{self.code_type!r}" + ) + if issues: + warnings.warn( + "DeliveryArea constructed with invalid data (" + + "; ".join(issues) + + "); this will raise ValueError in a future release", + DeprecationWarning, + stacklevel=3, + ) + def get_code_type(self) -> EnergyMarketCodeType: """Return the code type as a known enum member. 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 3b886d65..dd1c64e2 100644 --- a/src/frequenz/client/common/grid/proto/v1alpha8/_delivery_area.py +++ b/src/frequenz/client/common/grid/proto/v1alpha8/_delivery_area.py @@ -4,6 +4,7 @@ """Conversion of DeliveryArea and EnergyMarketCodeType to/from protobuf v1alpha8.""" import logging +import warnings from frequenz.api.common.v1alpha8.grid import delivery_area_pb2 @@ -75,4 +76,11 @@ def delivery_area_from_proto(message: delivery_area_pb2.DeliveryArea) -> Deliver message, ) - return DeliveryArea(code=code, code_type=code_type) + # `DeliveryArea` emits a `DeprecationWarning` when constructed with + # invalid data. This function is scheduled to be marked `@deprecated` + # itself, at which point callers will see the outer notice pointing + # to `delivery_area_from_proto2`. Suppress the inner warning here so + # we don't double-warn. + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=DeprecationWarning) + return DeliveryArea(code=code, code_type=code_type) diff --git a/tests/grid/test_delivery_area.py b/tests/grid/test_delivery_area.py index 800005a4..562efe2c 100644 --- a/tests/grid/test_delivery_area.py +++ b/tests/grid/test_delivery_area.py @@ -51,6 +51,28 @@ class _DeliveryAreaTestCase: code_type=EnergyMarketCodeType.US_NERC, expected_str="PJM[US_NERC]", ), + _DeliveryAreaTestCase( + name="unknown_code_type_is_valid", + code="FR", + code_type=999, + expected_str="FR[type=999]", + ), + ], + ids=lambda case: case.name, +) +def test_creation_valid(case: _DeliveryAreaTestCase) -> None: + """Well-formed DeliveryArea construction succeeds without warnings.""" + with warnings.catch_warnings(): + warnings.simplefilter("error", DeprecationWarning) + area = DeliveryArea(code=case.code, code_type=case.code_type) + assert area.code == case.code + assert area.code_type == case.code_type + assert str(area) == case.expected_str + + +@pytest.mark.parametrize( + "case", + [ _DeliveryAreaTestCase( name="no_code", code=None, @@ -58,28 +80,44 @@ class _DeliveryAreaTestCase: expected_str="[EUROPE_EIC]", ), _DeliveryAreaTestCase( - name="unspecified_code_type", - code="TEST", - code_type=EnergyMarketCodeType.UNSPECIFIED, - expected_str="TEST[UNSPECIFIED]", + name="empty_code", + code="", + code_type=EnergyMarketCodeType.EUROPE_EIC, + expected_str="[EUROPE_EIC]", ), _DeliveryAreaTestCase( - name="unknown_code_type", - code="TEST", - code_type=999, - expected_str="TEST[type=999]", + name="unspecified_code_type_int", + code="DE", + code_type=0, + expected_str="DE[type=0]", ), ], ids=lambda case: case.name, ) -def test_creation(case: _DeliveryAreaTestCase) -> None: - """Test creating DeliveryArea instances with various parameters.""" - area = DeliveryArea(code=case.code, code_type=case.code_type) +def test_creation_invalid_emits_deprecation_warning( + case: _DeliveryAreaTestCase, +) -> None: + """Constructing DeliveryArea with invalid data emits a DeprecationWarning.""" + with pytest.warns( + DeprecationWarning, match="DeliveryArea constructed with invalid" + ): + area = DeliveryArea(code=case.code, code_type=case.code_type) assert area.code == case.code assert area.code_type == case.code_type assert str(area) == case.expected_str +def test_creation_invalid_code_type_member_emits_deprecation_warning() -> None: + """The deprecated UNSPECIFIED code_type member also triggers the warning.""" + with pytest.deprecated_call(): + unspecified = EnergyMarketCodeType.UNSPECIFIED + with pytest.warns( + DeprecationWarning, match="DeliveryArea constructed with invalid" + ): + area = DeliveryArea(code="DE", code_type=unspecified) + assert area.code == "DE" + + def test_equality() -> None: """Test equality of DeliveryArea objects.""" area1 = DeliveryArea( From 03a96a96da8c8283c5aad51511767ffb47dda6cc Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Tue, 7 Jul 2026 12:50:38 +0200 Subject: [PATCH 08/13] Add `InvalidDeliveryArea` `InvalidDeliveryArea` is added to represent malformed wire data. Same fields as `BaseDeliveryArea`, no invariants enforced, so callers can inspect whatever the server actually sent. Signed-off-by: Leandro Lucarella --- src/frequenz/client/common/grid/__init__.py | 2 + .../client/common/grid/_delivery_area.py | 12 ++++ tests/grid/test_delivery_area.py | 68 +++++++++++++++++++ 3 files changed, 82 insertions(+) diff --git a/src/frequenz/client/common/grid/__init__.py b/src/frequenz/client/common/grid/__init__.py index 24711426..0688f3ef 100644 --- a/src/frequenz/client/common/grid/__init__.py +++ b/src/frequenz/client/common/grid/__init__.py @@ -7,10 +7,12 @@ BaseDeliveryArea, DeliveryArea, EnergyMarketCodeType, + InvalidDeliveryArea, ) __all__ = [ "BaseDeliveryArea", "DeliveryArea", "EnergyMarketCodeType", + "InvalidDeliveryArea", ] diff --git a/src/frequenz/client/common/grid/_delivery_area.py b/src/frequenz/client/common/grid/_delivery_area.py index 1fb6593f..9e480f4c 100644 --- a/src/frequenz/client/common/grid/_delivery_area.py +++ b/src/frequenz/client/common/grid/_delivery_area.py @@ -183,3 +183,15 @@ def get_code_type(self) -> EnergyMarketCodeType: raise UnrecognizedEnumValueError(self, "code_type", code_type) case unknown: assert_never(unknown) + + +@dataclass(frozen=True, kw_only=True) +class InvalidDeliveryArea(BaseDeliveryArea): + """A delivery area with malformed data received from the wire. + + Represents delivery area data that fails the invariants required for a + well-formed [`DeliveryArea`][..DeliveryArea]. Callers can inspect the raw + fields to recover partial information. + + This class does not enforce any invariants on construction. + """ diff --git a/tests/grid/test_delivery_area.py b/tests/grid/test_delivery_area.py index 562efe2c..f59aa51d 100644 --- a/tests/grid/test_delivery_area.py +++ b/tests/grid/test_delivery_area.py @@ -16,6 +16,7 @@ BaseDeliveryArea, DeliveryArea, EnergyMarketCodeType, + InvalidDeliveryArea, ) @@ -202,3 +203,70 @@ def test_get_code_type_raises_unrecognized_for_unknown_int() -> None: def test_delivery_area_is_base_delivery_area_subclass() -> None: """`DeliveryArea` is a subclass of `BaseDeliveryArea`.""" assert issubclass(DeliveryArea, BaseDeliveryArea) + + +def test_invalid_delivery_area_is_base_delivery_area_subclass() -> None: + """`InvalidDeliveryArea` is a subclass of `BaseDeliveryArea`.""" + assert issubclass(InvalidDeliveryArea, BaseDeliveryArea) + + +@pytest.mark.parametrize( + "case", + [ + _DeliveryAreaTestCase( + name="empty_code", + code="", + code_type=EnergyMarketCodeType.EUROPE_EIC, + expected_str="[EUROPE_EIC]", + ), + _DeliveryAreaTestCase( + name="none_code", + code=None, + code_type=EnergyMarketCodeType.EUROPE_EIC, + expected_str="[EUROPE_EIC]", + ), + _DeliveryAreaTestCase( + name="long_code", + code="10Y1001A1001A450", + code_type=EnergyMarketCodeType.EUROPE_EIC, + expected_str="10Y1001A1001A450[EUROPE_EIC]", + ), + _DeliveryAreaTestCase( + name="unspecified_code_type_int", + code="DE", + code_type=0, + expected_str="DE[type=0]", + ), + _DeliveryAreaTestCase( + name="unknown_code_type_int", + code="DE", + code_type=999, + expected_str="DE[type=999]", + ), + ], + ids=lambda case: case.name, +) +def test_invalid_delivery_area_creation(case: _DeliveryAreaTestCase) -> None: + """`InvalidDeliveryArea` accepts any data with no invariants and no warnings.""" + with warnings.catch_warnings(): + warnings.simplefilter("error", DeprecationWarning) + area = InvalidDeliveryArea(code=case.code, code_type=case.code_type) + assert area.code == case.code + assert area.code_type == case.code_type + assert str(area) == case.expected_str + + +def test_invalid_delivery_area_equality() -> None: + """Two `InvalidDeliveryArea` instances with the same data are equal.""" + area1 = InvalidDeliveryArea(code="", code_type=0) + area2 = InvalidDeliveryArea(code="", code_type=0) + area3 = InvalidDeliveryArea(code="X", code_type=0) + assert area1 == area2 + assert area1 != area3 + + +def test_valid_and_invalid_delivery_area_are_distinct() -> None: + """A `DeliveryArea` and an `InvalidDeliveryArea` with identical fields are not equal.""" + valid = DeliveryArea(code="DE", code_type=EnergyMarketCodeType.EUROPE_EIC) + invalid = InvalidDeliveryArea(code="DE", code_type=EnergyMarketCodeType.EUROPE_EIC) + assert valid != invalid # type: ignore[comparison-overlap] From db74538a2de77be3beaab8e17ef277e735324bc7 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Tue, 7 Jul 2026 13:02:55 +0200 Subject: [PATCH 09/13] Add `InvalidDeliveryAreaError` The dedicated `InvalidDeliveryAreaError` (also a `ValueError` for convenience) carries the offending `InvalidDeliveryArea` on `.delivery_area` and will be raised by upcoming semantic accessors (next commits) instead of forcing callers to distinguish the two. Signed-off-by: Leandro Lucarella --- src/frequenz/client/common/grid/__init__.py | 2 + .../client/common/grid/_delivery_area.py | 37 ++++++++++++++++++- tests/grid/test_delivery_area.py | 36 ++++++++++++++++++ 3 files changed, 74 insertions(+), 1 deletion(-) diff --git a/src/frequenz/client/common/grid/__init__.py b/src/frequenz/client/common/grid/__init__.py index 0688f3ef..8fe18304 100644 --- a/src/frequenz/client/common/grid/__init__.py +++ b/src/frequenz/client/common/grid/__init__.py @@ -8,6 +8,7 @@ DeliveryArea, EnergyMarketCodeType, InvalidDeliveryArea, + InvalidDeliveryAreaError, ) __all__ = [ @@ -15,4 +16,5 @@ "DeliveryArea", "EnergyMarketCodeType", "InvalidDeliveryArea", + "InvalidDeliveryAreaError", ] diff --git a/src/frequenz/client/common/grid/_delivery_area.py b/src/frequenz/client/common/grid/_delivery_area.py index 9e480f4c..76f3f1e6 100644 --- a/src/frequenz/client/common/grid/_delivery_area.py +++ b/src/frequenz/client/common/grid/_delivery_area.py @@ -9,7 +9,11 @@ from frequenz.core.enum import Enum, deprecated_member, unique -from .._exception import UnrecognizedEnumValueError, UnspecifiedEnumValueError +from .._exception import ( + InvalidAttributeError, + UnrecognizedEnumValueError, + UnspecifiedEnumValueError, +) @unique @@ -195,3 +199,34 @@ class InvalidDeliveryArea(BaseDeliveryArea): This class does not enforce any invariants on construction. """ + + +class InvalidDeliveryAreaError(InvalidAttributeError): + """Raised when a semantic accessor sees an invalid delivery area. + + The offending [`InvalidDeliveryArea`][..InvalidDeliveryArea] instance + is available as the `delivery_area` attribute so callers can inspect + the raw wire data. + + This is also a [`ValueError`][] for convenience. + """ + + def __init__( + self, + delivery_area: InvalidDeliveryArea, + attr_name: str, + message: str | None = None, + ) -> None: + """Initialize this error. + + Args: + delivery_area: The invalid delivery area instance. + attr_name: The name of the attribute that was being accessed when this + error was raised. + message: A custom error message. If `None`, a default message + mentioning the invalid delivery area is used. + """ + self.delivery_area: InvalidDeliveryArea = delivery_area + """The invalid delivery area instance that caused this error.""" + + super().__init__(delivery_area, attr_name, message) diff --git a/tests/grid/test_delivery_area.py b/tests/grid/test_delivery_area.py index f59aa51d..25efa1f6 100644 --- a/tests/grid/test_delivery_area.py +++ b/tests/grid/test_delivery_area.py @@ -9,6 +9,7 @@ import pytest from frequenz.client.common import ( + InvalidAttributeError, UnrecognizedEnumValueError, UnspecifiedEnumValueError, ) @@ -17,6 +18,7 @@ DeliveryArea, EnergyMarketCodeType, InvalidDeliveryArea, + InvalidDeliveryAreaError, ) @@ -270,3 +272,37 @@ def test_valid_and_invalid_delivery_area_are_distinct() -> None: valid = DeliveryArea(code="DE", code_type=EnergyMarketCodeType.EUROPE_EIC) invalid = InvalidDeliveryArea(code="DE", code_type=EnergyMarketCodeType.EUROPE_EIC) assert valid != invalid # type: ignore[comparison-overlap] + + +def test_invalid_delivery_area_error_default_message() -> None: + """`InvalidDeliveryAreaError` builds a default message from the invalid area.""" + invalid = InvalidDeliveryArea(code="", code_type=0) + error = InvalidDeliveryAreaError(invalid, "delivery_area") + assert error.delivery_area is invalid + assert "invalid value for attribute 'delivery_area' in [type=0]" == str( + error + ) + + +def test_invalid_delivery_area_error_custom_message() -> None: + """`InvalidDeliveryAreaError` accepts a custom message.""" + invalid = InvalidDeliveryArea(code="X", code_type=0) + error = InvalidDeliveryAreaError( + invalid, "attr", message="bad delivery area from server" + ) + assert error.delivery_area is invalid + assert str(error) == "bad delivery area from server" + + +def test_invalid_delivery_area_error_is_invalid_attribute_error() -> None: + """`InvalidDeliveryAreaError` is also a `InvalidAttributeError` for convenience.""" + invalid = InvalidDeliveryArea(code="", code_type=0) + with pytest.raises(InvalidAttributeError): + raise InvalidDeliveryAreaError(invalid, "delivery_area") + + +def test_invalid_delivery_area_error_is_value_error() -> None: + """`InvalidDeliveryAreaError` is also a `ValueError` for convenience.""" + invalid = InvalidDeliveryArea(code="", code_type=0) + with pytest.raises(ValueError): + raise InvalidDeliveryAreaError(invalid, "delivery_area") From bfe5d4edcf9b1786e5bfff5bd879cd193024ec34 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Mon, 6 Jul 2026 14:57:10 +0000 Subject: [PATCH 10/13] Add `delivery_area_from_proto2` conversion function MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce `delivery_area_from_proto2`, sibling of the existing `delivery_area_from_proto`, returning `DeliveryArea | InvalidDeliveryArea` instead of a plain `DeliveryArea`. The new converter enforces at the boundary the invariant that `DeliveryArea.__post_init__` now warns about: * `code` must be non-empty. * `code_type` must not be raw `0` (i.e. unspecified). A wire message that fails either check becomes an `InvalidDeliveryArea` carrying the raw data verbatim, so callers can inspect or report it via `InvalidDeliveryAreaError`. Unknown non-zero `int` `code_type` values are still treated as valid to preserve forward compatibility with new protobuf enum values. Because the valid path constructs a `DeliveryArea` only when the invariants already hold, `delivery_area_from_proto2` never triggers the inner `DeprecationWarning` — and, unlike the old converter, it doesn't log any "found issues" warning either: the return type itself now signals whether the wire data was well-formed. The existing `delivery_area_from_proto` remains unchanged and will be marked `@deprecated` in a follow-up commit. Signed-off-by: Leandro Lucarella --- .../client/common/grid/_delivery_area.py | 19 +++- .../common/grid/proto/v1alpha8/__init__.py | 2 + .../grid/proto/v1alpha8/_delivery_area.py | 34 +++++- .../grid/proto/v1alpha8/test_delivery_area.py | 103 +++++++++++++++++- 4 files changed, 154 insertions(+), 4 deletions(-) diff --git a/src/frequenz/client/common/grid/_delivery_area.py b/src/frequenz/client/common/grid/_delivery_area.py index 76f3f1e6..c647d1d2 100644 --- a/src/frequenz/client/common/grid/_delivery_area.py +++ b/src/frequenz/client/common/grid/_delivery_area.py @@ -60,7 +60,15 @@ class EnergyMarketCodeType(Enum): @dataclass(frozen=True, kw_only=True) class BaseDeliveryArea: - """A base class for all delivery areas.""" + """A base class for all delivery areas. + + This is the common supertype of both well-formed + [`DeliveryArea`][..DeliveryArea] instances and + [`InvalidDeliveryArea`][..InvalidDeliveryArea] instances that carry + malformed wire data. It cannot be instantiated directly; use one of + its concrete subclasses instead, or obtain instances via + [`delivery_area_from_proto2`][..proto.v1alpha8.delivery_area_from_proto2]. + """ code: str | None """The code representing the unique identifier for the delivery area.""" @@ -114,6 +122,11 @@ class DeliveryArea(BaseDeliveryArea): these invariants is **deprecated**, and will raise a [`ValueError`][] in a future release. + Prefer + [`delivery_area_from_proto2`][..proto.v1alpha8.delivery_area_from_proto2] + to load delivery areas from the wire — malformed messages become + [`InvalidDeliveryArea`][..InvalidDeliveryArea] instances instead. + Note: Jurisdictional Differences This is typically represented by specific codes according to local jurisdiction. @@ -154,7 +167,9 @@ def __post_init__(self) -> None: warnings.warn( "DeliveryArea constructed with invalid data (" + "; ".join(issues) - + "); this will raise ValueError in a future release", + + "); this will raise ValueError in a future release. " + "Use `delivery_area_from_proto2` to obtain an " + "`InvalidDeliveryArea` for malformed wire data.", DeprecationWarning, stacklevel=3, ) diff --git a/src/frequenz/client/common/grid/proto/v1alpha8/__init__.py b/src/frequenz/client/common/grid/proto/v1alpha8/__init__.py index 9ab980d9..27b29281 100644 --- a/src/frequenz/client/common/grid/proto/v1alpha8/__init__.py +++ b/src/frequenz/client/common/grid/proto/v1alpha8/__init__.py @@ -5,12 +5,14 @@ from ._delivery_area import ( delivery_area_from_proto, + delivery_area_from_proto2, energy_market_code_type_from_proto, energy_market_code_type_to_proto, ) __all__ = [ "delivery_area_from_proto", + "delivery_area_from_proto2", "energy_market_code_type_from_proto", "energy_market_code_type_to_proto", ] 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 dd1c64e2..87b19e94 100644 --- a/src/frequenz/client/common/grid/proto/v1alpha8/_delivery_area.py +++ b/src/frequenz/client/common/grid/proto/v1alpha8/_delivery_area.py @@ -9,7 +9,7 @@ from frequenz.api.common.v1alpha8.grid import delivery_area_pb2 from ....proto import enum_from_proto -from ..._delivery_area import DeliveryArea, EnergyMarketCodeType +from ..._delivery_area import DeliveryArea, EnergyMarketCodeType, InvalidDeliveryArea _logger = logging.getLogger(__name__) @@ -84,3 +84,35 @@ def delivery_area_from_proto(message: delivery_area_pb2.DeliveryArea) -> Deliver with warnings.catch_warnings(): warnings.filterwarnings("ignore", category=DeprecationWarning) return DeliveryArea(code=code, code_type=code_type) + + +def delivery_area_from_proto2( + message: delivery_area_pb2.DeliveryArea, +) -> DeliveryArea | InvalidDeliveryArea: + """Convert a protobuf message to a delivery area object. + + A well-formed message becomes a [`DeliveryArea`][....DeliveryArea]; a + message that fails the `DeliveryArea` invariant becomes an + [`InvalidDeliveryArea`][....InvalidDeliveryArea] carrying the raw wire + data so callers can inspect or report it. + + Unknown non-zero `int` `code_type` values are treated as valid to + preserve forward compatibility with new protobuf enum values. + + Args: + message: The protobuf message to convert. + + Returns: + A [`DeliveryArea`][....DeliveryArea] when the wire data is + well-formed, an [`InvalidDeliveryArea`][....InvalidDeliveryArea] + otherwise. + """ + 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 not message.code or raw_code_type == 0: + return InvalidDeliveryArea(code=message.code, code_type=code_type) + return DeliveryArea(code=message.code, code_type=code_type) diff --git a/tests/grid/proto/v1alpha8/test_delivery_area.py b/tests/grid/proto/v1alpha8/test_delivery_area.py index f821d1e5..531d9395 100644 --- a/tests/grid/proto/v1alpha8/test_delivery_area.py +++ b/tests/grid/proto/v1alpha8/test_delivery_area.py @@ -10,9 +10,14 @@ from frequenz.api.common.v1alpha8.grid import delivery_area_pb2 from frequenz.client.common import UnspecifiedEnumValueError -from frequenz.client.common.grid import EnergyMarketCodeType +from frequenz.client.common.grid import ( + DeliveryArea, + EnergyMarketCodeType, + InvalidDeliveryArea, +) from frequenz.client.common.grid.proto.v1alpha8 import ( delivery_area_from_proto, + delivery_area_from_proto2, energy_market_code_type_from_proto, energy_market_code_type_to_proto, ) @@ -135,3 +140,99 @@ def test_get_code_type_from_proto_unspecified_raises() -> None: assert area.code_type == 0 with pytest.raises(UnspecifiedEnumValueError): area.get_code_type() + + +@dataclass(frozen=True, kw_only=True) +class _FromProto2TestCase: + """Test case for `delivery_area_from_proto2` conversion.""" + + name: str + """Description of the test case.""" + + code: str + """The code to set in the protobuf message.""" + + code_type: int + """The code type to set in the protobuf message.""" + + expected_code: str + """Expected code in the resulting delivery area.""" + + expected_code_type: EnergyMarketCodeType | int + """Expected code type in the resulting delivery area.""" + + expected_type: type + """Expected concrete type returned by the converter.""" + + +@pytest.mark.parametrize( + "case", + [ + _FromProto2TestCase( + name="valid_EIC_code", + code="10Y1001A1001A450", + code_type=delivery_area_pb2.EnergyMarketCodeType.ENERGY_MARKET_CODE_TYPE_EUROPE_EIC, + expected_code="10Y1001A1001A450", + expected_code_type=EnergyMarketCodeType.EUROPE_EIC, + expected_type=DeliveryArea, + ), + _FromProto2TestCase( + name="valid_NERC_code", + code="PJM", + code_type=delivery_area_pb2.EnergyMarketCodeType.ENERGY_MARKET_CODE_TYPE_US_NERC, + expected_code="PJM", + expected_code_type=EnergyMarketCodeType.US_NERC, + expected_type=DeliveryArea, + ), + _FromProto2TestCase( + name="unknown_code_type_is_valid", + code="FR", + code_type=999, + expected_code="FR", + expected_code_type=999, + expected_type=DeliveryArea, + ), + _FromProto2TestCase( + name="no_code_is_invalid", + code="", + code_type=delivery_area_pb2.EnergyMarketCodeType.ENERGY_MARKET_CODE_TYPE_EUROPE_EIC, + expected_code="", + expected_code_type=EnergyMarketCodeType.EUROPE_EIC, + expected_type=InvalidDeliveryArea, + ), + _FromProto2TestCase( + name="unspecified_code_type_is_invalid", + code="DE", + code_type=delivery_area_pb2.EnergyMarketCodeType.ENERGY_MARKET_CODE_TYPE_UNSPECIFIED, + expected_code="DE", + expected_code_type=0, + expected_type=InvalidDeliveryArea, + ), + _FromProto2TestCase( + name="both_invalid", + code="", + code_type=delivery_area_pb2.EnergyMarketCodeType.ENERGY_MARKET_CODE_TYPE_UNSPECIFIED, + expected_code="", + expected_code_type=0, + expected_type=InvalidDeliveryArea, + ), + ], + ids=lambda case: case.name, +) +def test_from_proto2( + caplog: pytest.LogCaptureFixture, case: _FromProto2TestCase +) -> None: + """`delivery_area_from_proto2` returns a `DeliveryArea` or `InvalidDeliveryArea`.""" + proto = delivery_area_pb2.DeliveryArea( + code=case.code, code_type=case.code_type # type: ignore[arg-type] + ) + with caplog.at_level("WARNING"): + with warnings.catch_warnings(): + warnings.simplefilter("error", DeprecationWarning) + area = delivery_area_from_proto2(proto) + + assert isinstance(area, case.expected_type) + assert area.code == case.expected_code + assert area.code_type == case.expected_code_type + # The new converter never logs issues. + assert len(caplog.records) == 0 From bf6d55950dc25971b1d32e1a6795750bc2d78738 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Mon, 6 Jul 2026 14:59:29 +0000 Subject: [PATCH 11/13] Add `Microgrid.get_delivery_area()` accessor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Loosen `Microgrid.delivery_area` from `DeliveryArea | None` to `DeliveryArea | InvalidDeliveryArea | None` to reflect the three possible wire states: absent, present-but-malformed, and well-formed. `microgrid_from_proto` is switched to `delivery_area_from_proto2`, so proto-loaded microgrids already carry the right variant on the union. Add the semantic accessor `Microgrid.get_delivery_area() -> DeliveryArea` so callers that need a valid delivery area don't have to check for either failure mode by hand: * `None` -> `MissingFieldError` (the field wasn't set on the wire). * `InvalidDeliveryArea` -> `InvalidDeliveryAreaError`, with the offending instance available on `.delivery_area` for inspection. * `DeliveryArea` -> returned unchanged. Microgrid is unreleased so this widened field and the new accessor are free additions — no backwards compatibility needed. Signed-off-by: Leandro Lucarella --- .../client/common/microgrid/_microgrid.py | 53 +++++++++++++++-- .../microgrid/proto/v1alpha8/_microgrid.py | 8 +-- .../proto/v1alpha8/test_microgrid.py | 2 +- tests/microgrid/test_microgrid.py | 57 ++++++++++++++++++- 4 files changed, 110 insertions(+), 10 deletions(-) diff --git a/src/frequenz/client/common/microgrid/_microgrid.py b/src/frequenz/client/common/microgrid/_microgrid.py index d3a0788c..22cf26ca 100644 --- a/src/frequenz/client/common/microgrid/_microgrid.py +++ b/src/frequenz/client/common/microgrid/_microgrid.py @@ -7,8 +7,16 @@ from dataclasses import dataclass, field from typing import assert_never -from .._exception import UnrecognizedEnumValueError, UnspecifiedEnumValueError -from ..grid._delivery_area import DeliveryArea +from .._exception import ( + MissingFieldError, + UnrecognizedEnumValueError, + UnspecifiedEnumValueError, +) +from ..grid._delivery_area import ( + DeliveryArea, + InvalidDeliveryArea, + InvalidDeliveryAreaError, +) from ..types._location import Location from ._ids import EnterpriseId, MicrogridId @@ -40,8 +48,17 @@ class Microgrid: # pylint: disable=too-many-instance-attributes name: str """The name of the microgrid.""" - delivery_area: DeliveryArea | None - """The delivery area where the microgrid is located, as identified by a specific code.""" + delivery_area: DeliveryArea | InvalidDeliveryArea | None + """The delivery area where the microgrid is located. + + `None` means the field was not set on the wire. An + [`InvalidDeliveryArea`][....grid.InvalidDeliveryArea] means the wire + carried a delivery area that fails its invariants. + + Tip: + This is the lower-level field; prefer [`get_delivery_area()`][..get_delivery_area] + to obtain a valid [`DeliveryArea`][....grid.DeliveryArea] or a clear error. + """ location: Location | None """The physical location of the microgrid, in geographical co-ordinates.""" @@ -105,6 +122,34 @@ def is_active(self) -> bool: case unknown: assert_never(unknown) + def get_delivery_area(self) -> DeliveryArea: + """Return the delivery area as a well-formed `DeliveryArea`. + + This is the higher-level accessor for the [`delivery_area`][..delivery_area] + attribute: it resolves the field to a valid + [`DeliveryArea`][....grid.DeliveryArea] or raises a clear, catchable error. + + Returns: + The delivery area, when it is a well-formed + [`DeliveryArea`][....grid.DeliveryArea]. + + Raises: + MissingFieldError: If the delivery area is not set (`None`). + InvalidDeliveryAreaError: If the delivery area is an + [`InvalidDeliveryArea`][....grid.InvalidDeliveryArea]. The + offending instance is available on the exception's + `delivery_area` attribute. + """ + match self.delivery_area: + case None: + raise MissingFieldError(self, "delivery_area") + case InvalidDeliveryArea() as invalid: + raise InvalidDeliveryAreaError(invalid, "delivery_area") + case DeliveryArea() as valid: + return valid + case unknown: + assert_never(unknown) + def __str__(self) -> str: """Return the ID of this microgrid as a string.""" name = f":{self.name}" if self.name else "" diff --git a/src/frequenz/client/common/microgrid/proto/v1alpha8/_microgrid.py b/src/frequenz/client/common/microgrid/proto/v1alpha8/_microgrid.py index cca25f10..ff220472 100644 --- a/src/frequenz/client/common/microgrid/proto/v1alpha8/_microgrid.py +++ b/src/frequenz/client/common/microgrid/proto/v1alpha8/_microgrid.py @@ -7,8 +7,8 @@ from frequenz.api.common.v1alpha8.microgrid import microgrid_pb2 -from ....grid import DeliveryArea -from ....grid.proto.v1alpha8 import delivery_area_from_proto +from ....grid import DeliveryArea, InvalidDeliveryArea +from ....grid.proto.v1alpha8 import delivery_area_from_proto2 from ....proto import datetime_from_proto from ....types import Location from ....types.proto.v1alpha8 import location_from_proto @@ -51,9 +51,9 @@ def microgrid_from_proto(message: microgrid_pb2.Microgrid) -> Microgrid: """ major_issues: list[str] = [] - delivery_area: DeliveryArea | None = None + delivery_area: DeliveryArea | InvalidDeliveryArea | None = None if message.HasField("delivery_area"): - delivery_area = delivery_area_from_proto(message.delivery_area) + delivery_area = delivery_area_from_proto2(message.delivery_area) else: major_issues.append("delivery_area is missing") diff --git a/tests/microgrid/proto/v1alpha8/test_microgrid.py b/tests/microgrid/proto/v1alpha8/test_microgrid.py index 9c60c1e1..49487044 100644 --- a/tests/microgrid/proto/v1alpha8/test_microgrid.py +++ b/tests/microgrid/proto/v1alpha8/test_microgrid.py @@ -144,7 +144,7 @@ def _assert_active(info: Microgrid, expected_active: bool | int) -> None: ids=lambda case: case.name, ) @patch( - "frequenz.client.common.microgrid.proto.v1alpha8._microgrid.delivery_area_from_proto" + "frequenz.client.common.microgrid.proto.v1alpha8._microgrid.delivery_area_from_proto2" ) @patch("frequenz.client.common.microgrid.proto.v1alpha8._microgrid.location_from_proto") @patch("frequenz.client.common.microgrid.proto.v1alpha8._microgrid.datetime_from_proto") diff --git a/tests/microgrid/test_microgrid.py b/tests/microgrid/test_microgrid.py index f8d7eec8..c0cf25d6 100644 --- a/tests/microgrid/test_microgrid.py +++ b/tests/microgrid/test_microgrid.py @@ -9,10 +9,16 @@ import pytest from frequenz.client.common import ( + MissingFieldError, UnrecognizedEnumValueError, UnspecifiedEnumValueError, ) -from frequenz.client.common.grid import DeliveryArea, EnergyMarketCodeType +from frequenz.client.common.grid import ( + DeliveryArea, + EnergyMarketCodeType, + InvalidDeliveryArea, + InvalidDeliveryAreaError, +) from frequenz.client.common.microgrid import EnterpriseId, Microgrid, MicrogridId from frequenz.client.common.types import Location @@ -184,3 +190,52 @@ def test_replace_preserves_construction() -> None: replaced = dataclasses.replace(info, name="renamed") assert replaced.name == "renamed" assert replaced.is_active() is True + + +def _make_microgrid( + delivery_area: DeliveryArea | InvalidDeliveryArea | None, +) -> Microgrid: + """Build a Microgrid with the given delivery area for accessor tests.""" + return Microgrid( + id=MicrogridId(1234), + enterprise_id=EnterpriseId(5678), + name=None, + delivery_area=delivery_area, + location=None, + create_time=datetime.now(timezone.utc), + _active=True, + _allow_construction=True, + ) + + +def test_get_delivery_area_returns_valid() -> None: + """`get_delivery_area()` returns the valid `DeliveryArea` unchanged.""" + area = DeliveryArea(code="DE", code_type=EnergyMarketCodeType.EUROPE_EIC) + info = _make_microgrid(area) + assert info.get_delivery_area() is area + + +def test_get_delivery_area_raises_missing_for_none() -> None: + """`get_delivery_area()` raises `MissingFieldError` when the field is `None`.""" + info = _make_microgrid(None) + with pytest.raises( + MissingFieldError, + match=r"missing protobuf field 'delivery_area' in MID1234", + ): + info.get_delivery_area() + + +def test_get_delivery_area_raises_invalid_for_invalid_delivery_area() -> None: + """`get_delivery_area()` raises `InvalidDeliveryAreaError` carrying the invalid instance.""" + invalid = InvalidDeliveryArea(code="", code_type=0) + info = _make_microgrid(invalid) + with pytest.raises(InvalidDeliveryAreaError) as exc_info: + info.get_delivery_area() + assert exc_info.value.delivery_area is invalid + + +def test_get_delivery_area_error_is_value_error() -> None: + """The `InvalidDeliveryAreaError` raised by the accessor is also a `ValueError`.""" + info = _make_microgrid(InvalidDeliveryArea(code="", code_type=0)) + with pytest.raises(ValueError): + info.get_delivery_area() From dcb3513110e0982633b396e3495cc38b8a815969 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Mon, 6 Jul 2026 15:01:10 +0000 Subject: [PATCH 12/13] Deprecate `delivery_area_from_proto` Mark `delivery_area_from_proto` as `@deprecated`, pointing callers at `delivery_area_from_proto2`, which returns `DeliveryArea | InvalidDeliveryArea` and surfaces malformed wire data at the type level rather than as an ad-hoc log warning. The internal `DeliveryArea(...)` construction inside `delivery_area_from_proto` was already wrapped in `warnings.catch_warnings()` when `DeliveryArea.__post_init__` gained its soft invariant check, so the two suppressions compose correctly: callers of the deprecated entry point see exactly one `DeprecationWarning`, identifying `delivery_area_from_proto2` as the replacement. Signed-off-by: Leandro Lucarella --- .../grid/proto/v1alpha8/_delivery_area.py | 17 ++++++++++++++++- .../grid/proto/v1alpha8/test_delivery_area.py | 18 +++++++++++++++--- 2 files changed, 31 insertions(+), 4 deletions(-) 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 87b19e94..2da4ddbc 100644 --- a/src/frequenz/client/common/grid/proto/v1alpha8/_delivery_area.py +++ b/src/frequenz/client/common/grid/proto/v1alpha8/_delivery_area.py @@ -7,6 +7,7 @@ import warnings from frequenz.api.common.v1alpha8.grid import delivery_area_pb2 +from typing_extensions import deprecated from ....proto import enum_from_proto from ..._delivery_area import DeliveryArea, EnergyMarketCodeType, InvalidDeliveryArea @@ -43,9 +44,23 @@ def energy_market_code_type_to_proto( return delivery_area_pb2.EnergyMarketCodeType.ValueType(code_type.value) -def delivery_area_from_proto(message: delivery_area_pb2.DeliveryArea) -> DeliveryArea: +@deprecated( + "`delivery_area_from_proto` is deprecated; use " + "`delivery_area_from_proto2` (returns " + "`DeliveryArea | InvalidDeliveryArea`) instead." +) +def delivery_area_from_proto( # noqa: DOC502 + message: delivery_area_pb2.DeliveryArea, +) -> DeliveryArea: """Convert a protobuf message to a [`DeliveryArea`][....DeliveryArea] object. + Warning: Deprecated + Use [`delivery_area_from_proto2`][..delivery_area_from_proto2] + instead. The new converter distinguishes well-formed from + malformed data at the type level + (`DeliveryArea | InvalidDeliveryArea`) rather than silently + constructing a `DeliveryArea` with invalid content. + Args: message: The protobuf message to convert. diff --git a/tests/grid/proto/v1alpha8/test_delivery_area.py b/tests/grid/proto/v1alpha8/test_delivery_area.py index 531d9395..4061a801 100644 --- a/tests/grid/proto/v1alpha8/test_delivery_area.py +++ b/tests/grid/proto/v1alpha8/test_delivery_area.py @@ -114,7 +114,8 @@ def test_from_proto( code=case.code or "", code_type=case.code_type # type: ignore[arg-type] ) with caplog.at_level("WARNING"): - area = delivery_area_from_proto(proto) + with pytest.deprecated_call(match="delivery_area_from_proto"): + area = delivery_area_from_proto(proto) assert area.code == case.expected_code assert area.code_type == case.expected_code_type @@ -134,14 +135,25 @@ def test_get_code_type_from_proto_unspecified_raises() -> None: delivery_area_pb2.EnergyMarketCodeType.ENERGY_MARKET_CODE_TYPE_UNSPECIFIED ), ) - with warnings.catch_warnings(): - warnings.simplefilter("error", DeprecationWarning) + with pytest.deprecated_call(match="delivery_area_from_proto"): area = delivery_area_from_proto(proto) assert area.code_type == 0 with pytest.raises(UnspecifiedEnumValueError): area.get_code_type() +def test_from_proto_emits_deprecation_warning() -> None: + """`delivery_area_from_proto` itself is deprecated and warns on call.""" + proto = delivery_area_pb2.DeliveryArea( + code="DE", + code_type=( + delivery_area_pb2.EnergyMarketCodeType.ENERGY_MARKET_CODE_TYPE_EUROPE_EIC + ), + ) + with pytest.deprecated_call(match="delivery_area_from_proto2"): + delivery_area_from_proto(proto) + + @dataclass(frozen=True, kw_only=True) class _FromProto2TestCase: """Test case for `delivery_area_from_proto2` conversion.""" From 26372c3e8764639cf521b24b445364d008c3e965 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Mon, 6 Jul 2026 15:02:06 +0000 Subject: [PATCH 13/13] Update release notes Signed-off-by: Leandro Lucarella --- RELEASE_NOTES.md | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 084c6c96..1873c6b9 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -44,6 +44,14 @@ 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. +* `frequenz.client.common.grid.proto.v1alpha8.delivery_area_from_proto` is now deprecated; use `delivery_area_from_proto2` instead. + + The new converter returns `DeliveryArea | InvalidDeliveryArea` and surfaces malformed wire data at the type level rather than silently constructing a `DeliveryArea` with invalid content. The old converter continues to work but emits a `DeprecationWarning`. + +* `frequenz.client.common.grid.DeliveryArea` construction with invalid data is deprecated. Please construct only valid `DeliveryArea` objects. + + A well-formed `DeliveryArea` has a non-empty `code` and a non-`UNSPECIFIED` `code_type`. Constructing one with invalid data currently emits a `DeprecationWarning`; a future release will replace the warning with a hard `ValueError`. Prefer `delivery_area_from_proto2` to load delivery areas from the wire — malformed messages become `InvalidDeliveryArea` instances instead. + ## New Features * Added 4 new electrical component classes for categories that previously collapsed into `UnrecognizedElectricalComponent`: @@ -68,12 +76,20 @@ * `frequenz.client.common.UnspecifiedEnumValueError` for unspecified enum values (raw `0` or the deprecated member). * `frequenz.client.common.UnrecognizedEnumValueError` 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: +* Added safe convenience getters that raise the new exceptions for unspecified, unrecognized, missing or invalid values: * `frequenz.client.common.grid.DeliveryArea.get_code_type()` * `frequenz.client.common.metrics.MetricConnection.get_category()` * `frequenz.client.common.metrics.MetricSample.get_metric()` +* Added new delivery-area class hierarchy: + + * `frequenz.client.common.grid.BaseDeliveryArea` — abstract common supertype of the two concrete leaves; not directly instantiable. + * `frequenz.client.common.grid.DeliveryArea` — well-formed delivery area (retroactively made a subclass of `BaseDeliveryArea`; its shared `__str__` and `get_code_type()` are hoisted to the base). + * `frequenz.client.common.grid.InvalidDeliveryArea` — malformed wire data; same fields as `DeliveryArea` with no invariants enforced, so callers can inspect whatever the server actually sent. + +* Added `frequenz.client.common.grid.proto.v1alpha8.delivery_area_from_proto2` returning `DeliveryArea | InvalidDeliveryArea`. This is the replacement for the now-deprecated `delivery_area_from_proto` and is what `microgrid_from_proto` uses internally. + * 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.