From e06318390716552a549e36a90326e31d28cf6aec Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Mon, 6 Jul 2026 14:55:29 +0000 Subject: [PATCH 01/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 | 24 ++++++++++++++++++- tests/grid/test_delivery_area.py | 12 +++++++++- 3 files changed, 40 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..cf00dba9 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,28 @@ 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) + + @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 7d249e3f5339650755f6727b8c8ecf8324b78e28 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Tue, 7 Jul 2026 12:34:57 +0200 Subject: [PATCH 02/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 | 21 +++++++------------ tests/grid/test_delivery_area.py | 5 +++++ 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/src/frequenz/client/common/grid/_delivery_area.py b/src/frequenz/client/common/grid/_delivery_area.py index cf00dba9..4d02a69c 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 @@ -77,7 +83,7 @@ def __new__(cls, *args: Any, **kwargs: Any) -> Self: @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 @@ -97,19 +103,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 "" 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 65d7f0f0379a1a2c6d7356ca6905fa8264ea71e0 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Tue, 7 Jul 2026 12:43:16 +0200 Subject: [PATCH 03/13] Deprecate constructing invalid `DeliveryArea` A `__post_init__` is added with invariant checks: a well-formed delivery area must have a non-empty `code`. Constructing one 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 | 18 +++++ .../grid/proto/v1alpha8/_delivery_area.py | 10 ++- tests/grid/test_delivery_area.py | 76 +++++++++++++++---- 3 files changed, 90 insertions(+), 14 deletions(-) diff --git a/src/frequenz/client/common/grid/_delivery_area.py b/src/frequenz/client/common/grid/_delivery_area.py index 4d02a69c..847e41d8 100644 --- a/src/frequenz/client/common/grid/_delivery_area.py +++ b/src/frequenz/client/common/grid/_delivery_area.py @@ -94,6 +94,14 @@ 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]. + Constructing one with data that violates this invariant is + **deprecated**, and will raise a [`ValueError`][] in a future release. + + In the future, delivery areas with an unspecified [`code_type`][.code_type] + will also be considered invalid. + Note: Jurisdictional Differences This is typically represented by specific codes according to local jurisdiction. @@ -103,6 +111,16 @@ 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.""" + if not self.code: + warnings.warn( + "Constructing a DeliveryArea without a `code` is deprecated and will raise " + "a `ValueError` in a future release", + DeprecationWarning, + stacklevel=3, + ) + def __str__(self) -> str: """Return a human-readable string representation of this instance.""" code = self.code or "" 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..e4978eea 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,56 @@ class _DeliveryAreaTestCase: expected_str="[EUROPE_EIC]", ), _DeliveryAreaTestCase( - name="unspecified_code_type", - code="TEST", - code_type=EnergyMarketCodeType.UNSPECIFIED, - expected_str="TEST[UNSPECIFIED]", - ), - _DeliveryAreaTestCase( - name="unknown_code_type", - code="TEST", - code_type=999, - expected_str="TEST[type=999]", + name="empty_code", + code="", + code_type=EnergyMarketCodeType.EUROPE_EIC, + expected_str="[EUROPE_EIC]", ), ], 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_without_code_emits_deprecation_warning( + case: _DeliveryAreaTestCase, +) -> None: + """Constructing DeliveryArea without a `code` emits a DeprecationWarning.""" + with pytest.warns( + DeprecationWarning, match="Constructing a DeliveryArea without a `code`" + ): + 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_with_int_zero_code_type_does_not_warn() -> None: + """Constructing DeliveryArea with `code_type=0` does not currently warn. + + The unspecified `code_type` is documented as invalid in a future release + (see the class docstring), but currently `__post_init__` only warns on a + missing `code`. + """ + with warnings.catch_warnings(): + warnings.simplefilter("error", DeprecationWarning) + DeliveryArea(code="DE", code_type=0) + + +def test_creation_with_unspecified_code_type_member_does_not_warn() -> None: + """`__post_init__` does not warn when `code_type` is the UNSPECIFIED member. + + Accessing [`EnergyMarketCodeType.UNSPECIFIED`][...EnergyMarketCodeType] itself + emits its own `DeprecationWarning`; this test confirms that constructing a + `DeliveryArea` with a valid `code` and that pre-accessed member does not + trigger any additional warning from `__post_init__`. + """ + with pytest.deprecated_call(): + unspecified = EnergyMarketCodeType.UNSPECIFIED + with warnings.catch_warnings(): + warnings.simplefilter("error", DeprecationWarning) + area = DeliveryArea(code="DE", code_type=unspecified) + assert area.code == "DE" + assert area.code_type is unspecified + + def test_equality() -> None: """Test equality of DeliveryArea objects.""" area1 = DeliveryArea( From be13f3514eddea28ae9a4a453207fc4329e08720 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Tue, 7 Jul 2026 12:50:38 +0200 Subject: [PATCH 04/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 | 40 ++++++++++- tests/grid/test_delivery_area.py | 68 +++++++++++++++++++ 3 files changed, 108 insertions(+), 2 deletions(-) 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 847e41d8..a89e21a4 100644 --- a/src/frequenz/client/common/grid/_delivery_area.py +++ b/src/frequenz/client/common/grid/_delivery_area.py @@ -56,7 +56,14 @@ 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. + """ code: str | None """The code representing the unique identifier for the delivery area.""" @@ -102,6 +109,9 @@ class DeliveryArea(BaseDeliveryArea): In the future, delivery areas with an unspecified [`code_type`][.code_type] will also be considered invalid. + Use [`InvalidDeliveryArea`][..InvalidDeliveryArea] if you need to + represent a malformed message. + Note: Jurisdictional Differences This is typically represented by specific codes according to local jurisdiction. @@ -116,7 +126,7 @@ def __post_init__(self) -> None: if not self.code: warnings.warn( "Constructing a DeliveryArea without a `code` is deprecated and will raise " - "a `ValueError` in a future release", + "a `ValueError` in a future release. Use `InvalidDeliveryArea` instead.", DeprecationWarning, stacklevel=3, ) @@ -159,3 +169,29 @@ 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. + """ + + def __str__(self) -> str: + """Return a human-readable string representation of this instance.""" + code = self.code or "❌" + match self.code_type: + case EnergyMarketCodeType(): + code_type = self.code_type.name + case 0: + code_type = "type=❌" + case int() as code_type: + code_type = f"type={code_type}" + case unexpected: + assert_never(unexpected) + return f"{code}[{code_type}]" diff --git a/tests/grid/test_delivery_area.py b/tests/grid/test_delivery_area.py index e4978eea..e6b59a30 100644 --- a/tests/grid/test_delivery_area.py +++ b/tests/grid/test_delivery_area.py @@ -16,6 +16,7 @@ BaseDeliveryArea, DeliveryArea, EnergyMarketCodeType, + InvalidDeliveryArea, ) @@ -214,3 +215,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=❌]", + ), + _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 9fbbcb648e1e4c033e1cc665f0279e4a8687d896 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Fri, 10 Jul 2026 10:07:00 +0200 Subject: [PATCH 05/13] Deprecate using `None` for `DeliveryArea.code` The protobuf message is required, so the field will never be missing. An empty string should be a validation error instead of being translated to `None`. Support for using `None` will be removed in v0.5.0. Signed-off-by: Leandro Lucarella --- .../client/common/grid/_delivery_area.py | 19 ++++++++++++++++++- tests/grid/test_delivery_area.py | 18 ++++++++++++------ 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/src/frequenz/client/common/grid/_delivery_area.py b/src/frequenz/client/common/grid/_delivery_area.py index a89e21a4..f7f1a82c 100644 --- a/src/frequenz/client/common/grid/_delivery_area.py +++ b/src/frequenz/client/common/grid/_delivery_area.py @@ -66,7 +66,14 @@ class BaseDeliveryArea: """ code: str | None - """The code representing the unique identifier for the delivery area.""" + """The code representing the unique identifier for the delivery area. + + Warning: Using `None` is deprecated + This field is required for a well-formed `DeliveryArea`, so we are + making this more explicit by deprecating the use of `None` here. In the + future, `| None` will be removed so passing `None` will fail type + checking. + """ code_type: EnergyMarketCodeType | int """Type of code used for identifying the delivery area itself. @@ -88,6 +95,16 @@ def __new__(cls, *args: Any, **kwargs: Any) -> Self: raise TypeError(f"Cannot instantiate {cls.__name__} directly") return super().__new__(cls) + def __post_init__(self) -> None: + """Warn if this instance carries invalid data.""" + if self.code is None: + warnings.warn( + "Using `None` for `code` is deprecated and will be " + "removed in a future release.", + DeprecationWarning, + stacklevel=3, + ) + @dataclass(frozen=True, kw_only=True) class DeliveryArea(BaseDeliveryArea): diff --git a/tests/grid/test_delivery_area.py b/tests/grid/test_delivery_area.py index e6b59a30..14ce1a4e 100644 --- a/tests/grid/test_delivery_area.py +++ b/tests/grid/test_delivery_area.py @@ -231,12 +231,6 @@ def test_invalid_delivery_area_is_base_delivery_area_subclass() -> None: 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", @@ -268,6 +262,18 @@ def test_invalid_delivery_area_creation(case: _DeliveryAreaTestCase) -> None: assert str(area) == case.expected_str +def test_invalid_delivery_area_creation_with_none_code_emits_deprecation() -> None: + """`InvalidDeliveryArea` accepts `None` code but emits a DeprecationWarning.""" + with pytest.warns( + DeprecationWarning, + match="Using `None` for `code` is deprecated and will be removed in a future release.", + ): + area = InvalidDeliveryArea(code=None, code_type=EnergyMarketCodeType.EUROPE_EIC) + assert area.code is None + assert area.code_type == EnergyMarketCodeType.EUROPE_EIC + assert str(area) == "❌[EUROPE_EIC]" + + def test_invalid_delivery_area_equality() -> None: """Two `InvalidDeliveryArea` instances with the same data are equal.""" area1 = InvalidDeliveryArea(code="", code_type=0) From 8849891513dbee0a4cb967d421fe1fe08db7aa01 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Tue, 7 Jul 2026 13:02:55 +0200 Subject: [PATCH 06/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 | 44 ++++++++++++++++++- tests/grid/test_delivery_area.py | 37 ++++++++++++++++ 3 files changed, 82 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 f7f1a82c..3a1e0a71 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 @@ -212,3 +216,41 @@ def __str__(self) -> str: case unexpected: assert_never(unexpected) return f"{code}[{code_type}]" + + +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, + instance: object, + attr_name: str, + delivery_area: InvalidDeliveryArea, + message: str | None = None, + ) -> None: + """Initialize this error. + + Args: + instance: The instance that was being accessed when this error was raised. + attr_name: The name of the attribute that was being accessed when this + error was raised. + delivery_area: The invalid delivery area instance. + 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.""" + + message = ( + f"invalid delivery area {delivery_area!r} for attribute {attr_name!r} in {instance}" + if message is None + else message + ) + super().__init__(instance, attr_name, message) diff --git a/tests/grid/test_delivery_area.py b/tests/grid/test_delivery_area.py index 14ce1a4e..fe3eacbf 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, ) @@ -288,3 +290,38 @@ 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("some-instance", "delivery_area", invalid) + assert error.delivery_area is invalid + assert ( + "invalid delivery area InvalidDeliveryArea(code='', code_type=0) for " + "attribute 'delivery_area' in some-instance" == 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( + "some-instance", "attr", invalid, 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("other-instance", "delivery_area", invalid) + + +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("some-instance", "delivery_area", invalid) From a0c9b904b36185ad02e422109f5b9f681471d50d Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Mon, 6 Jul 2026 14:57:10 +0000 Subject: [PATCH 07/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. 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. For practical reasons, a `code_type` of `0` (which normally should invalidate a delivery area, is considered valid, as it is not always set but we can fall back to a well-known default currently. 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 --- .../common/grid/proto/v1alpha8/__init__.py | 2 + .../grid/proto/v1alpha8/_delivery_area.py | 40 ++++++- .../grid/proto/v1alpha8/test_delivery_area.py | 103 +++++++++++++++++- 3 files changed, 143 insertions(+), 2 deletions(-) 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..ef2af4d0 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,41 @@ 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 `int` `code_type` values are treated as valid to + preserve forward compatibility with new protobuf enum values. + + Warning: `code_type` of `0` will be considered invalid in the future + A `0` value for `code_type` means it is `UNSPECIFIED`, which should not + be a valid value, but currently this field is not always being set, and + we normally fall back to a well-known default, so considering it a + validation failure at the moment is not practical. + + 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: + 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..29c95039 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_valid", + code="DE", + code_type=delivery_area_pb2.EnergyMarketCodeType.ENERGY_MARKET_CODE_TYPE_UNSPECIFIED, + expected_code="DE", + expected_code_type=0, + expected_type=DeliveryArea, + ), + _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 30268e831ce5e62448f49b82e5847639460795b5 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Mon, 6 Jul 2026 14:59:29 +0000 Subject: [PATCH 08/13] Add safe accessors for `Microgrid.delivery_area` 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 accessors `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. Also add `Microgrid.get_delivery_area_or_none() -> DeliveryArea | None` for cases where users want to handle the `None` case gracefully, only raising on an invalid delivery area. Signed-off-by: Leandro Lucarella --- .../client/common/microgrid/_microgrid.py | 83 +++++++++++++++++- .../microgrid/proto/v1alpha8/_microgrid.py | 8 +- .../proto/v1alpha8/test_microgrid.py | 2 +- tests/microgrid/test_microgrid.py | 86 ++++++++++++++++++- 4 files changed, 169 insertions(+), 10 deletions(-) diff --git a/src/frequenz/client/common/microgrid/_microgrid.py b/src/frequenz/client/common/microgrid/_microgrid.py index d3a0788c..8f558ca9 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,64 @@ 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(self, "delivery_area", invalid) + case DeliveryArea() as valid: + return valid + case unknown: + assert_never(unknown) + + def get_delivery_area_or_none(self) -> DeliveryArea | None: + """Return the delivery area as a well-formed `DeliveryArea`, or `None`. + + This is the higher-level accessor for the [`delivery_area`][..delivery_area] + attribute that tolerates a missing field: it resolves the field to a + valid [`DeliveryArea`][....grid.DeliveryArea], returns `None` when the + field was not set on the wire, or raises a clear, catchable error when + the field carries an invalid delivery area. + + Returns: + The delivery area when it is a well-formed + [`DeliveryArea`][....grid.DeliveryArea], or `None` when it is + not set. + + Raises: + 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: + return None + case InvalidDeliveryArea() as invalid: + raise InvalidDeliveryAreaError(self, "delivery_area", invalid) + 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..38fe1a32 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,81 @@ 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="", + 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() + + +def test_get_delivery_area_or_none_returns_valid() -> None: + """`get_delivery_area_or_none()` returns the valid `DeliveryArea` unchanged.""" + area = DeliveryArea(code="DE", code_type=EnergyMarketCodeType.EUROPE_EIC) + info = _make_microgrid(area) + assert info.get_delivery_area_or_none() is area + + +def test_get_delivery_area_or_none_returns_none_for_none() -> None: + """`get_delivery_area_or_none()` returns `None` when the field is `None`.""" + info = _make_microgrid(None) + assert info.get_delivery_area_or_none() is None + + +def test_get_delivery_area_or_none_raises_invalid_for_invalid_delivery_area() -> None: + """`get_delivery_area_or_none()` raises `InvalidDeliveryAreaError`.""" + invalid = InvalidDeliveryArea(code="", code_type=0) + info = _make_microgrid(invalid) + with pytest.raises(InvalidDeliveryAreaError) as exc_info: + info.get_delivery_area_or_none() + assert exc_info.value.delivery_area is invalid + + +def test_get_delivery_area_or_none_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_or_none() From 324c87d181951632a7ab4af66f6fb2c38744cbec Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Mon, 6 Jul 2026 15:01:10 +0000 Subject: [PATCH 09/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 | 24 +++++++++++++++---- .../grid/proto/v1alpha8/test_delivery_area.py | 18 +++++++++++--- 2 files changed, 34 insertions(+), 8 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 ef2af4d0..094d15a3 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. @@ -77,10 +92,9 @@ def delivery_area_from_proto(message: delivery_area_pb2.DeliveryArea) -> Deliver ) # `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. + # invalid data. This function is `@deprecated` itself, 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/proto/v1alpha8/test_delivery_area.py b/tests/grid/proto/v1alpha8/test_delivery_area.py index 29c95039..2776738c 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 bbb0e89f87e707f30cfce3888b577a02c8fc42a8 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Mon, 13 Jul 2026 08:50:26 +0000 Subject: [PATCH 10/13] Split `DeliveryArea` tests into per-type files `src/.../grid/_delivery_area.py` exposes five public symbols (`EnergyMarketCodeType`, `BaseDeliveryArea`, `DeliveryArea`, `InvalidDeliveryArea`, `InvalidDeliveryAreaError`), so the test file grew quite big. This commit splits the test file following the pattern: tests/grid/test_delivery_area.py -> tests/grid/_delivery_area/test_.py Test bodies and assertions are unchanged; only their location and their names change. Test function names are stripped of the redundant module-name prefix per the same guideline (e.g. `test_invalid_delivery_area_error_default_message` -> `test_default_message` inside `test_invalid_delivery_area_error.py`). Signed-off-by: Leandro Lucarella --- tests/grid/_delivery_area/__init__.py | 4 + .../_delivery_area/test_base_delivery_area.py | 14 ++ .../test_delivery_area.py | 142 ++---------------- .../test_energy_market_code_type.py | 15 ++ .../test_invalid_delivery_area.py | 106 +++++++++++++ .../test_invalid_delivery_area_error.py | 44 ++++++ 6 files changed, 192 insertions(+), 133 deletions(-) create mode 100644 tests/grid/_delivery_area/__init__.py create mode 100644 tests/grid/_delivery_area/test_base_delivery_area.py rename tests/grid/{ => _delivery_area}/test_delivery_area.py (54%) create mode 100644 tests/grid/_delivery_area/test_energy_market_code_type.py create mode 100644 tests/grid/_delivery_area/test_invalid_delivery_area.py create mode 100644 tests/grid/_delivery_area/test_invalid_delivery_area_error.py diff --git a/tests/grid/_delivery_area/__init__.py b/tests/grid/_delivery_area/__init__.py new file mode 100644 index 00000000..c6118d7a --- /dev/null +++ b/tests/grid/_delivery_area/__init__.py @@ -0,0 +1,4 @@ +# License: MIT +# Copyright © 2026 Frequenz Energy-as-a-Service GmbH + +"""Tests for the _delivery_area module.""" diff --git a/tests/grid/_delivery_area/test_base_delivery_area.py b/tests/grid/_delivery_area/test_base_delivery_area.py new file mode 100644 index 00000000..827e5969 --- /dev/null +++ b/tests/grid/_delivery_area/test_base_delivery_area.py @@ -0,0 +1,14 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Tests for the BaseDeliveryArea class.""" + +import pytest + +from frequenz.client.common.grid import BaseDeliveryArea, EnergyMarketCodeType + + +def test_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) diff --git a/tests/grid/test_delivery_area.py b/tests/grid/_delivery_area/test_delivery_area.py similarity index 54% rename from tests/grid/test_delivery_area.py rename to tests/grid/_delivery_area/test_delivery_area.py index fe3eacbf..9d83ce36 100644 --- a/tests/grid/test_delivery_area.py +++ b/tests/grid/_delivery_area/test_delivery_area.py @@ -9,7 +9,6 @@ import pytest from frequenz.client.common import ( - InvalidAttributeError, UnrecognizedEnumValueError, UnspecifiedEnumValueError, ) @@ -17,13 +16,11 @@ BaseDeliveryArea, DeliveryArea, EnergyMarketCodeType, - InvalidDeliveryArea, - InvalidDeliveryAreaError, ) @dataclass(frozen=True, kw_only=True) -class _DeliveryAreaTestCase: +class _TestCase: """Test case for DeliveryArea creation.""" name: str @@ -42,19 +39,19 @@ class _DeliveryAreaTestCase: @pytest.mark.parametrize( "case", [ - _DeliveryAreaTestCase( + _TestCase( name="valid_EIC_code", code="10Y1001A1001A450", code_type=EnergyMarketCodeType.EUROPE_EIC, expected_str="10Y1001A1001A450[EUROPE_EIC]", ), - _DeliveryAreaTestCase( + _TestCase( name="valid_NERC_code", code="PJM", code_type=EnergyMarketCodeType.US_NERC, expected_str="PJM[US_NERC]", ), - _DeliveryAreaTestCase( + _TestCase( name="unknown_code_type_is_valid", code="FR", code_type=999, @@ -63,7 +60,7 @@ class _DeliveryAreaTestCase: ], ids=lambda case: case.name, ) -def test_creation_valid(case: _DeliveryAreaTestCase) -> None: +def test_creation_valid(case: _TestCase) -> None: """Well-formed DeliveryArea construction succeeds without warnings.""" with warnings.catch_warnings(): warnings.simplefilter("error", DeprecationWarning) @@ -76,13 +73,13 @@ def test_creation_valid(case: _DeliveryAreaTestCase) -> None: @pytest.mark.parametrize( "case", [ - _DeliveryAreaTestCase( + _TestCase( name="no_code", code=None, code_type=EnergyMarketCodeType.EUROPE_EIC, expected_str="[EUROPE_EIC]", ), - _DeliveryAreaTestCase( + _TestCase( name="empty_code", code="", code_type=EnergyMarketCodeType.EUROPE_EIC, @@ -92,7 +89,7 @@ def test_creation_valid(case: _DeliveryAreaTestCase) -> None: ids=lambda case: case.name, ) def test_creation_without_code_emits_deprecation_warning( - case: _DeliveryAreaTestCase, + case: _TestCase, ) -> None: """Constructing DeliveryArea without a `code` emits a DeprecationWarning.""" with pytest.warns( @@ -165,13 +162,6 @@ def test_hash() -> None: 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 - - @pytest.mark.parametrize( "member", [EnergyMarketCodeType.EUROPE_EIC, EnergyMarketCodeType.US_NERC], @@ -190,12 +180,6 @@ 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(): @@ -214,114 +198,6 @@ def test_get_code_type_raises_unrecognized_for_unknown_int() -> None: assert exc_info.value.value == 999 -def test_delivery_area_is_base_delivery_area_subclass() -> None: +def test_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="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=❌]", - ), - _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_creation_with_none_code_emits_deprecation() -> None: - """`InvalidDeliveryArea` accepts `None` code but emits a DeprecationWarning.""" - with pytest.warns( - DeprecationWarning, - match="Using `None` for `code` is deprecated and will be removed in a future release.", - ): - area = InvalidDeliveryArea(code=None, code_type=EnergyMarketCodeType.EUROPE_EIC) - assert area.code is None - assert area.code_type == EnergyMarketCodeType.EUROPE_EIC - assert str(area) == "❌[EUROPE_EIC]" - - -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] - - -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("some-instance", "delivery_area", invalid) - assert error.delivery_area is invalid - assert ( - "invalid delivery area InvalidDeliveryArea(code='', code_type=0) for " - "attribute 'delivery_area' in some-instance" == 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( - "some-instance", "attr", invalid, 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("other-instance", "delivery_area", invalid) - - -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("some-instance", "delivery_area", invalid) diff --git a/tests/grid/_delivery_area/test_energy_market_code_type.py b/tests/grid/_delivery_area/test_energy_market_code_type.py new file mode 100644 index 00000000..a92bf98c --- /dev/null +++ b/tests/grid/_delivery_area/test_energy_market_code_type.py @@ -0,0 +1,15 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Tests for the EnergyMarketCodeType enum.""" + +import pytest + +from frequenz.client.common.grid import EnergyMarketCodeType + + +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 diff --git a/tests/grid/_delivery_area/test_invalid_delivery_area.py b/tests/grid/_delivery_area/test_invalid_delivery_area.py new file mode 100644 index 00000000..08f0b547 --- /dev/null +++ b/tests/grid/_delivery_area/test_invalid_delivery_area.py @@ -0,0 +1,106 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Tests for the InvalidDeliveryArea class.""" + +import warnings +from dataclasses import dataclass + +import pytest + +from frequenz.client.common.grid import ( + BaseDeliveryArea, + DeliveryArea, + EnergyMarketCodeType, + InvalidDeliveryArea, +) + + +@dataclass(frozen=True, kw_only=True) +class _TestCase: + """Test case for InvalidDeliveryArea creation.""" + + name: str + """Description of the test case.""" + + code: str | None + """The code to use for the delivery area.""" + + code_type: EnergyMarketCodeType | int + """The type of code being used.""" + + expected_str: str + """Expected string representation.""" + + +def test_is_base_delivery_area_subclass() -> None: + """`InvalidDeliveryArea` is a subclass of `BaseDeliveryArea`.""" + assert issubclass(InvalidDeliveryArea, BaseDeliveryArea) + + +@pytest.mark.parametrize( + "case", + [ + _TestCase( + name="empty_code", + code="", + code_type=EnergyMarketCodeType.EUROPE_EIC, + expected_str="❌[EUROPE_EIC]", + ), + _TestCase( + name="long_code", + code="10Y1001A1001A450", + code_type=EnergyMarketCodeType.EUROPE_EIC, + expected_str="10Y1001A1001A450[EUROPE_EIC]", + ), + _TestCase( + name="unspecified_code_type_int", + code="DE", + code_type=0, + expected_str="DE[type=❌]", + ), + _TestCase( + name="unknown_code_type_int", + code="DE", + code_type=999, + expected_str="DE[type=999]", + ), + ], + ids=lambda case: case.name, +) +def test_creation(case: _TestCase) -> 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_creation_with_none_code_emits_deprecation() -> None: + """`InvalidDeliveryArea` accepts `None` code but emits a DeprecationWarning.""" + with pytest.warns( + DeprecationWarning, + match="Using `None` for `code` is deprecated and will be removed in a future release.", + ): + area = InvalidDeliveryArea(code=None, code_type=EnergyMarketCodeType.EUROPE_EIC) + assert area.code is None + assert area.code_type == EnergyMarketCodeType.EUROPE_EIC + assert str(area) == "❌[EUROPE_EIC]" + + +def test_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_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] diff --git a/tests/grid/_delivery_area/test_invalid_delivery_area_error.py b/tests/grid/_delivery_area/test_invalid_delivery_area_error.py new file mode 100644 index 00000000..9bb7be28 --- /dev/null +++ b/tests/grid/_delivery_area/test_invalid_delivery_area_error.py @@ -0,0 +1,44 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Tests for the InvalidDeliveryAreaError class.""" + +import pytest + +from frequenz.client.common import InvalidAttributeError +from frequenz.client.common.grid import InvalidDeliveryArea, InvalidDeliveryAreaError + + +def test_default_message() -> None: + """`InvalidDeliveryAreaError` builds a default message from the invalid area.""" + invalid = InvalidDeliveryArea(code="", code_type=0) + error = InvalidDeliveryAreaError("some-instance", "delivery_area", invalid) + assert error.delivery_area is invalid + assert ( + "invalid delivery area InvalidDeliveryArea(code='', code_type=0) for " + "attribute 'delivery_area' in some-instance" == str(error) + ) + + +def test_custom_message() -> None: + """`InvalidDeliveryAreaError` accepts a custom message.""" + invalid = InvalidDeliveryArea(code="X", code_type=0) + error = InvalidDeliveryAreaError( + "some-instance", "attr", invalid, message="bad delivery area from server" + ) + assert error.delivery_area is invalid + assert str(error) == "bad delivery area from server" + + +def test_is_invalid_attribute_error() -> None: + """`InvalidDeliveryAreaError` is also a `InvalidAttributeError` for convenience.""" + invalid = InvalidDeliveryArea(code="", code_type=0) + with pytest.raises(InvalidAttributeError): + raise InvalidDeliveryAreaError("other-instance", "delivery_area", invalid) + + +def test_is_value_error() -> None: + """`InvalidDeliveryAreaError` is also a `ValueError` for convenience.""" + invalid = InvalidDeliveryArea(code="", code_type=0) + with pytest.raises(ValueError): + raise InvalidDeliveryAreaError("some-instance", "delivery_area", invalid) From 9048b55cecc0e2f4f4cff7e20bfccc1e02bf5148 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Mon, 6 Jul 2026 15:02:06 +0000 Subject: [PATCH 11/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..ed4a37a7 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` (a non-`UNSPECIFIED` `code_type` is currently accepted as valid for practical reasons but will be considered invalid in the future). 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`). + * `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`. + * 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. From a07b6912dfeeb9f6c04be4dbb5f7fef0f9feecc4 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Wed, 15 Jul 2026 16:49:40 +0000 Subject: [PATCH 12/13] Use `` marker for invalid data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the ad-hoc placeholders (the `` sentinel and the `❌` fallbacks). `InvalidDeliveryArea` to now wraps the fields that violate invariants in an explicit `` marker. Concretely, `str(area)` now renders as: For `DeliveryArea` (invariants trusted, no annotation): * `"10Y1001A1001A450[EUROPE_EIC]"` well-formed * `"DE[type=999]"` unknown `int` `code_type` * `"DE[type=0]"` raw `int(0)` `code_type` * `"DE[UNSPECIFIED]"` deprecated `UNSPECIFIED` member * `"None[EUROPE_EIC]"` (deprecated) `None` `code` * `"[EUROPE_EIC]"` (deprecated) empty `code` For `InvalidDeliveryArea` (invariant violations flagged): * `"[EUROPE_EIC]"` empty `code` * `"[EUROPE_EIC]"` (deprecated) `None` `code` * `"DE[type=]"` `code_type` is raw `int(0)` or deprecated `UNSPECIFIED` member * `"DE[type=999]"` unknown `int` `code_type` * `"10Y1001A1001A450[EUROPE_EIC]"` otherwise-well-formed data The two `__str__` methods are separate on purpose: `DeliveryArea` is the "trust the values" contract, while `InvalidDeliveryArea` is explicitly for malformed wire data — so only the latter should call out what actually violated an invariant. Signed-off-by: Leandro Lucarella --- .../client/common/grid/_delivery_area.py | 27 ++++++++++--------- .../grid/_delivery_area/test_delivery_area.py | 16 +++++++---- .../test_invalid_delivery_area.py | 6 ++--- 3 files changed, 29 insertions(+), 20 deletions(-) diff --git a/src/frequenz/client/common/grid/_delivery_area.py b/src/frequenz/client/common/grid/_delivery_area.py index 3a1e0a71..ff6b9dc2 100644 --- a/src/frequenz/client/common/grid/_delivery_area.py +++ b/src/frequenz/client/common/grid/_delivery_area.py @@ -154,13 +154,12 @@ def __post_init__(self) -> None: 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}]" + return f"{self.code}[{code_type}]" def get_code_type(self) -> EnergyMarketCodeType: """Return the code type as a known enum member. @@ -205,16 +204,20 @@ class InvalidDeliveryArea(BaseDeliveryArea): def __str__(self) -> str: """Return a human-readable string representation of this instance.""" - code = self.code or "❌" - match self.code_type: - case EnergyMarketCodeType(): - code_type = self.code_type.name - case 0: - code_type = "type=❌" - case int() as code_type: - code_type = f"type={code_type}" - case unexpected: - assert_never(unexpected) + # 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: + code_type = "type=" + case EnergyMarketCodeType() as enum_code: + code_type = enum_code.name + case int() as int_code: + code_type = f"type={int_code}" + case unexpected: + assert_never(unexpected) + code = self.code or f"" return f"{code}[{code_type}]" diff --git a/tests/grid/_delivery_area/test_delivery_area.py b/tests/grid/_delivery_area/test_delivery_area.py index 9d83ce36..15d239f0 100644 --- a/tests/grid/_delivery_area/test_delivery_area.py +++ b/tests/grid/_delivery_area/test_delivery_area.py @@ -77,13 +77,13 @@ def test_creation_valid(case: _TestCase) -> None: name="no_code", code=None, code_type=EnergyMarketCodeType.EUROPE_EIC, - expected_str="[EUROPE_EIC]", + expected_str="None[EUROPE_EIC]", ), _TestCase( name="empty_code", code="", code_type=EnergyMarketCodeType.EUROPE_EIC, - expected_str="[EUROPE_EIC]", + expected_str="[EUROPE_EIC]", ), ], ids=lambda case: case.name, @@ -106,11 +106,14 @@ def test_creation_with_int_zero_code_type_does_not_warn() -> None: The unspecified `code_type` is documented as invalid in a future release (see the class docstring), but currently `__post_init__` only warns on a - missing `code`. + missing `code`. `DeliveryArea` trusts its inputs and does not annotate + them in `__str__`; use `InvalidDeliveryArea` to render the invalidity + marker explicitly. """ with warnings.catch_warnings(): warnings.simplefilter("error", DeprecationWarning) - DeliveryArea(code="DE", code_type=0) + area = DeliveryArea(code="DE", code_type=0) + assert str(area) == "DE[type=0]" def test_creation_with_unspecified_code_type_member_does_not_warn() -> None: @@ -119,7 +122,9 @@ def test_creation_with_unspecified_code_type_member_does_not_warn() -> None: Accessing [`EnergyMarketCodeType.UNSPECIFIED`][...EnergyMarketCodeType] itself emits its own `DeprecationWarning`; this test confirms that constructing a `DeliveryArea` with a valid `code` and that pre-accessed member does not - trigger any additional warning from `__post_init__`. + trigger any additional warning from `__post_init__`. Consistent with the + `int(0)` case above, `DeliveryArea` renders the enum name bare — the + invalidity marker only appears on `InvalidDeliveryArea`. """ with pytest.deprecated_call(): unspecified = EnergyMarketCodeType.UNSPECIFIED @@ -128,6 +133,7 @@ def test_creation_with_unspecified_code_type_member_does_not_warn() -> None: area = DeliveryArea(code="DE", code_type=unspecified) assert area.code == "DE" assert area.code_type is unspecified + assert str(area) == "DE[UNSPECIFIED]" def test_equality() -> None: diff --git a/tests/grid/_delivery_area/test_invalid_delivery_area.py b/tests/grid/_delivery_area/test_invalid_delivery_area.py index 08f0b547..2c5f6024 100644 --- a/tests/grid/_delivery_area/test_invalid_delivery_area.py +++ b/tests/grid/_delivery_area/test_invalid_delivery_area.py @@ -45,7 +45,7 @@ def test_is_base_delivery_area_subclass() -> None: name="empty_code", code="", code_type=EnergyMarketCodeType.EUROPE_EIC, - expected_str="❌[EUROPE_EIC]", + expected_str="[EUROPE_EIC]", ), _TestCase( name="long_code", @@ -57,7 +57,7 @@ def test_is_base_delivery_area_subclass() -> None: name="unspecified_code_type_int", code="DE", code_type=0, - expected_str="DE[type=❌]", + expected_str="DE[type=]", ), _TestCase( name="unknown_code_type_int", @@ -87,7 +87,7 @@ def test_creation_with_none_code_emits_deprecation() -> None: area = InvalidDeliveryArea(code=None, code_type=EnergyMarketCodeType.EUROPE_EIC) assert area.code is None assert area.code_type == EnergyMarketCodeType.EUROPE_EIC - assert str(area) == "❌[EUROPE_EIC]" + assert str(area) == "[EUROPE_EIC]" def test_equality() -> None: From 1b93e7abca9d22ec62ae4edca32b45a0b38dcea4 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Thu, 16 Jul 2026 09:32:56 +0000 Subject: [PATCH 13/13] Deprecate unspecified `code_type` in `DeliveryArea` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the `DeliveryArea.__post_init__` invariant so an unspecified `code_type` (the raw `int` `0` or the deprecated `UNSPECIFIED` member) is treated the same as a missing `code`: a `DeprecationWarning` now, a hard `ValueError` in v0.5.0. The class docstring is updated to reflect the extended invariant. To let callers opt into the upcoming behavior right away, add a private `_raise_on_invalid: InitVar[bool] = False` init-only parameter that switches the checks from warning to raising. The `_`-prefix signals that this is a transitional lever, not part of the stable public surface: it will be removed together with the warning path once the invariants become hard errors. Adapt `delivery_area_from_proto2` to the new invariant. It now calls `DeliveryArea(..., _raise_on_invalid=True)` and falls back to `InvalidDeliveryArea` when the construction raises, instead of only checking `message.code` by hand — this keeps the validation logic in a single place. To preserve the current shape of well-formed conversions while the service still omits `code_type` in many messages, the converter grows a `replace_unspecified_code_type_with` keyword argument (default `EnergyMarketCodeType.EUROPE_EIC`) that fills in the missing value before invariant checking. Once the service starts populating `code_type` consistently, the argument (and this accommodation) can be removed and unspecified values will simply produce an `InvalidDeliveryArea`. Signed-off-by: Leandro Lucarella --- RELEASE_NOTES.md | 2 +- .../client/common/grid/_delivery_area.py | 36 ++++-- .../grid/proto/v1alpha8/_delivery_area.py | 18 ++- .../grid/_delivery_area/test_delivery_area.py | 111 +++++++++++++++--- .../grid/proto/v1alpha8/test_delivery_area.py | 46 +++++++- 5 files changed, 178 insertions(+), 35 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index ed4a37a7..9ad015fb 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -50,7 +50,7 @@ * `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` (a non-`UNSPECIFIED` `code_type` is currently accepted as valid for practical reasons but will be considered invalid in the future). 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. + A well-formed `DeliveryArea` has a non-empty `code` and a specified (non-`UNSPECIFIED`) `code_type`. Constructing one with invalid data currently emits a `DeprecationWarning`; a future release will replace the warning with a hard `ValueError`. To opt into the upcoming behavior right now, pass `_raise_on_invalid=True` to the constructor. Prefer `delivery_area_from_proto2` to load delivery areas from the wire — malformed messages become `InvalidDeliveryArea` instances instead. ## New Features diff --git a/src/frequenz/client/common/grid/_delivery_area.py b/src/frequenz/client/common/grid/_delivery_area.py index ff6b9dc2..578476ae 100644 --- a/src/frequenz/client/common/grid/_delivery_area.py +++ b/src/frequenz/client/common/grid/_delivery_area.py @@ -4,7 +4,7 @@ """Delivery area information for the energy market.""" import warnings -from dataclasses import dataclass +from dataclasses import InitVar, dataclass from typing import Any, Self, assert_never from frequenz.core.enum import Enum, deprecated_member, unique @@ -123,12 +123,13 @@ class DeliveryArea(BaseDeliveryArea): which they operate. Warning: Construction of invalid instances is deprecated - A well-formed `DeliveryArea` carries a non-empty [`code`][.code]. - Constructing one with data that violates this invariant is - **deprecated**, and will raise a [`ValueError`][] in a future release. + A well-formed `DeliveryArea` carries a non-empty [`code`][.code] and a + specified [`code_type`][.code_type]. Constructing one with data that + violates this invariant is **deprecated**, and will raise a + [`ValueError`][] in a future release. - In the future, delivery areas with an unspecified [`code_type`][.code_type] - will also be considered invalid. + You can temporarily use the `_raise_on_invalid` keyword argument to get + the upcoming behavior now (raising instead of deprecation warning). Use [`InvalidDeliveryArea`][..InvalidDeliveryArea] if you need to represent a malformed message. @@ -142,15 +143,36 @@ class DeliveryArea(BaseDeliveryArea): EICs](https://www.entsoe.eu/data/energy-identification-codes-eic/eic-approved-codes/). """ - def __post_init__(self) -> None: + _raise_on_invalid: InitVar[bool] = False + """Whether to raise a `ValueError` on invalid data. + + This will be removed in a future release and always raise on invalid data. + """ + + # pylint: disable-next=arguments-differ + def __post_init__(self, _raise_on_invalid: bool) -> None: """Warn if this instance carries invalid data.""" if not self.code: + if _raise_on_invalid: + raise ValueError("`code` cannot be None or empty") warnings.warn( "Constructing a DeliveryArea without a `code` is deprecated and will raise " "a `ValueError` in a future release. Use `InvalidDeliveryArea` instead.", DeprecationWarning, stacklevel=3, ) + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=DeprecationWarning) + unspecified_code_type = EnergyMarketCodeType.UNSPECIFIED + if self.code_type in (0, unspecified_code_type): + if _raise_on_invalid: + raise ValueError("`code_type` cannot be 0 (UNSPECIFIED)") + warnings.warn( + "Constructing a DeliveryArea with `code_type=0` is deprecated and will raise " + "a `ValueError` in a future release. Use `InvalidDeliveryArea` instead.", + DeprecationWarning, + stacklevel=3, + ) def __str__(self) -> str: """Return a human-readable string representation of this instance.""" 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 094d15a3..a7f58ed3 100644 --- a/src/frequenz/client/common/grid/proto/v1alpha8/_delivery_area.py +++ b/src/frequenz/client/common/grid/proto/v1alpha8/_delivery_area.py @@ -102,6 +102,8 @@ def delivery_area_from_proto( # noqa: DOC502 def delivery_area_from_proto2( message: delivery_area_pb2.DeliveryArea, + *, + replace_unspecified_code_type_with: EnergyMarketCodeType = EnergyMarketCodeType.EUROPE_EIC, ) -> DeliveryArea | InvalidDeliveryArea: """Convert a protobuf message to a delivery area object. @@ -121,6 +123,10 @@ def delivery_area_from_proto2( Args: message: The protobuf message to convert. + replace_unspecified_code_type_with: The default `EnergyMarketCodeType` + to use when the protobuf message has `code_type` of `0` + (`UNSPECIFIED`). This is a temporary option until delivery areas + consistently provide a valid `code_type`. Returns: A [`DeliveryArea`][....DeliveryArea] when the wire data is @@ -129,10 +135,14 @@ def delivery_area_from_proto2( """ raw_code_type = message.code_type code_type: EnergyMarketCodeType | int = ( - raw_code_type + replace_unspecified_code_type_with if raw_code_type == 0 else energy_market_code_type_from_proto(raw_code_type) ) - if not message.code: - return InvalidDeliveryArea(code=message.code, code_type=code_type) - return DeliveryArea(code=message.code, code_type=code_type) + try: + return DeliveryArea( + code=message.code, code_type=code_type, _raise_on_invalid=True + ) + except ValueError: + pass + return InvalidDeliveryArea(code=message.code, code_type=code_type) diff --git a/tests/grid/_delivery_area/test_delivery_area.py b/tests/grid/_delivery_area/test_delivery_area.py index 15d239f0..eb9b5c1b 100644 --- a/tests/grid/_delivery_area/test_delivery_area.py +++ b/tests/grid/_delivery_area/test_delivery_area.py @@ -101,41 +101,111 @@ def test_creation_without_code_emits_deprecation_warning( assert str(area) == case.expected_str -def test_creation_with_int_zero_code_type_does_not_warn() -> None: - """Constructing DeliveryArea with `code_type=0` does not currently warn. +def test_creation_with_int_zero_code_type_emits_deprecation_warning() -> None: + """Constructing DeliveryArea with `code_type=0` emits a DeprecationWarning. The unspecified `code_type` is documented as invalid in a future release - (see the class docstring), but currently `__post_init__` only warns on a - missing `code`. `DeliveryArea` trusts its inputs and does not annotate - them in `__str__`; use `InvalidDeliveryArea` to render the invalidity - marker explicitly. + (see the class docstring). `DeliveryArea` trusts its inputs and does not + annotate them in `__str__`; use `InvalidDeliveryArea` to render the + invalidity marker explicitly. """ - with warnings.catch_warnings(): - warnings.simplefilter("error", DeprecationWarning) + with pytest.warns( + DeprecationWarning, + match="Constructing a DeliveryArea with `code_type=0`", + ): area = DeliveryArea(code="DE", code_type=0) - assert str(area) == "DE[type=0]" + assert area.code == "DE" + assert area.code_type == 0 + assert str(area) == "DE[type=0]" -def test_creation_with_unspecified_code_type_member_does_not_warn() -> None: - """`__post_init__` does not warn when `code_type` is the UNSPECIFIED member. +def test_creation_with_unspecified_code_type_member_emits_deprecation_warning() -> None: + """`__post_init__` warns when `code_type` is the UNSPECIFIED member. Accessing [`EnergyMarketCodeType.UNSPECIFIED`][...EnergyMarketCodeType] itself emits its own `DeprecationWarning`; this test confirms that constructing a - `DeliveryArea` with a valid `code` and that pre-accessed member does not - trigger any additional warning from `__post_init__`. Consistent with the - `int(0)` case above, `DeliveryArea` renders the enum name bare — the - invalidity marker only appears on `InvalidDeliveryArea`. + `DeliveryArea` with a valid `code` and that pre-accessed member also triggers + the `__post_init__` invariant warning. Consistent with the `int(0)` case + above, `DeliveryArea` renders the enum name bare — the invalidity marker + only appears on `InvalidDeliveryArea`. """ with pytest.deprecated_call(): unspecified = EnergyMarketCodeType.UNSPECIFIED - with warnings.catch_warnings(): - warnings.simplefilter("error", DeprecationWarning) + with pytest.warns( + DeprecationWarning, + match="Constructing a DeliveryArea with `code_type=0`", + ): area = DeliveryArea(code="DE", code_type=unspecified) assert area.code == "DE" assert area.code_type is unspecified assert str(area) == "DE[UNSPECIFIED]" +@pytest.mark.parametrize( + "case", + [ + _TestCase( + name="empty_code", + code="", + code_type=EnergyMarketCodeType.EUROPE_EIC, + expected_str="", + ), + _TestCase( + name="none_code", + code=None, + code_type=EnergyMarketCodeType.EUROPE_EIC, + expected_str="", + ), + ], + ids=lambda case: case.name, +) +def test_creation_raises_on_invalid_code(case: _TestCase) -> None: + """`_raise_on_invalid=True` raises `ValueError` on empty/`None` code.""" + with warnings.catch_warnings(): + warnings.simplefilter("error", DeprecationWarning) + with pytest.raises(ValueError, match="`code` cannot be None or empty"): + DeliveryArea( + code=case.code, + code_type=case.code_type, + _raise_on_invalid=True, + ) + + +def test_creation_raises_on_unspecified_int_code_type() -> None: + """`_raise_on_invalid=True` raises `ValueError` on `code_type=0`.""" + with warnings.catch_warnings(): + warnings.simplefilter("error", DeprecationWarning) + with pytest.raises( + ValueError, match="`code_type` cannot be 0 \\(UNSPECIFIED\\)" + ): + DeliveryArea(code="DE", code_type=0, _raise_on_invalid=True) + + +def test_creation_raises_on_unspecified_member_code_type() -> None: + """`_raise_on_invalid=True` raises `ValueError` on the UNSPECIFIED member.""" + with pytest.deprecated_call(): + unspecified = EnergyMarketCodeType.UNSPECIFIED + with warnings.catch_warnings(): + warnings.simplefilter("error", DeprecationWarning) + with pytest.raises( + ValueError, match="`code_type` cannot be 0 \\(UNSPECIFIED\\)" + ): + DeliveryArea(code="DE", code_type=unspecified, _raise_on_invalid=True) + + +def test_creation_does_not_raise_when_valid() -> None: + """`_raise_on_invalid=True` does not raise on well-formed data.""" + with warnings.catch_warnings(): + warnings.simplefilter("error", DeprecationWarning) + area = DeliveryArea( + code="DE", + code_type=EnergyMarketCodeType.EUROPE_EIC, + _raise_on_invalid=True, + ) + assert area.code == "DE" + assert area.code_type is EnergyMarketCodeType.EUROPE_EIC + + def test_equality() -> None: """Test equality of DeliveryArea objects.""" area1 = DeliveryArea( @@ -181,7 +251,8 @@ def test_get_code_type_returns_known_member(member: EnergyMarketCodeType) -> Non def test_get_code_type_raises_unspecified_for_int_zero() -> None: """get_code_type() raises UnspecifiedEnumValueError for a raw int 0 code type.""" - area = DeliveryArea(code="TEST", code_type=0) + with pytest.deprecated_call(): + area = DeliveryArea(code="TEST", code_type=0) with pytest.raises(UnspecifiedEnumValueError): area.get_code_type() @@ -189,7 +260,9 @@ def test_get_code_type_raises_unspecified_for_int_zero() -> None: 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(): - area = DeliveryArea(code="TEST", code_type=EnergyMarketCodeType.UNSPECIFIED) + unspecified = EnergyMarketCodeType.UNSPECIFIED + with pytest.deprecated_call(): + area = DeliveryArea(code="TEST", code_type=unspecified) with warnings.catch_warnings(): warnings.simplefilter("error", DeprecationWarning) with pytest.raises(UnspecifiedEnumValueError): diff --git a/tests/grid/proto/v1alpha8/test_delivery_area.py b/tests/grid/proto/v1alpha8/test_delivery_area.py index 2776738c..211f8d7d 100644 --- a/tests/grid/proto/v1alpha8/test_delivery_area.py +++ b/tests/grid/proto/v1alpha8/test_delivery_area.py @@ -213,19 +213,19 @@ class _FromProto2TestCase: expected_type=InvalidDeliveryArea, ), _FromProto2TestCase( - name="unspecified_code_type_is_valid", + name="unspecified_code_type_replaced_with_default", code="DE", code_type=delivery_area_pb2.EnergyMarketCodeType.ENERGY_MARKET_CODE_TYPE_UNSPECIFIED, expected_code="DE", - expected_code_type=0, + expected_code_type=EnergyMarketCodeType.EUROPE_EIC, expected_type=DeliveryArea, ), _FromProto2TestCase( - name="both_invalid", + name="no_code_with_unspecified_code_type_is_invalid", code="", code_type=delivery_area_pb2.EnergyMarketCodeType.ENERGY_MARKET_CODE_TYPE_UNSPECIFIED, expected_code="", - expected_code_type=0, + expected_code_type=EnergyMarketCodeType.EUROPE_EIC, expected_type=InvalidDeliveryArea, ), ], @@ -248,3 +248,41 @@ def test_from_proto2( assert area.code_type == case.expected_code_type # The new converter never logs issues. assert len(caplog.records) == 0 + + +def test_from_proto2_replaces_unspecified_code_type_with_custom_default() -> None: + """`replace_unspecified_code_type_with` overrides the fallback for `code_type=0`.""" + proto = delivery_area_pb2.DeliveryArea( + code="PJM", + code_type=( + delivery_area_pb2.EnergyMarketCodeType.ENERGY_MARKET_CODE_TYPE_UNSPECIFIED + ), + ) + with warnings.catch_warnings(): + warnings.simplefilter("error", DeprecationWarning) + area = delivery_area_from_proto2( + proto, replace_unspecified_code_type_with=EnergyMarketCodeType.US_NERC + ) + + assert isinstance(area, DeliveryArea) + assert area.code == "PJM" + assert area.code_type is EnergyMarketCodeType.US_NERC + + +def test_from_proto2_does_not_replace_specified_code_type() -> None: + """`replace_unspecified_code_type_with` is ignored when `code_type` is specified.""" + proto = delivery_area_pb2.DeliveryArea( + code="10Y1001A1001A450", + code_type=( + delivery_area_pb2.EnergyMarketCodeType.ENERGY_MARKET_CODE_TYPE_EUROPE_EIC + ), + ) + with warnings.catch_warnings(): + warnings.simplefilter("error", DeprecationWarning) + area = delivery_area_from_proto2( + proto, replace_unspecified_code_type_with=EnergyMarketCodeType.US_NERC + ) + + assert isinstance(area, DeliveryArea) + assert area.code == "10Y1001A1001A450" + assert area.code_type is EnergyMarketCodeType.EUROPE_EIC