diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 084c6c96..9ad015fb 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -44,6 +44,14 @@ Users are encouraged to switch from direct field access to the new `get_*()` methods (see New Features), which provide a safer way to handle unspecified or unrecognized values. +* `frequenz.client.common.grid.proto.v1alpha8.delivery_area_from_proto` is now deprecated; use `delivery_area_from_proto2` instead. + + The new converter returns `DeliveryArea | InvalidDeliveryArea` and surfaces malformed wire data at the type level rather than silently constructing a `DeliveryArea` with invalid content. The old converter continues to work but emits a `DeprecationWarning`. + +* `frequenz.client.common.grid.DeliveryArea` construction with invalid data is deprecated. Please construct only valid `DeliveryArea` objects. + + A well-formed `DeliveryArea` has a non-empty `code` and a 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 * 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. diff --git a/src/frequenz/client/common/grid/__init__.py b/src/frequenz/client/common/grid/__init__.py index 68c239c9..8fe18304 100644 --- a/src/frequenz/client/common/grid/__init__.py +++ b/src/frequenz/client/common/grid/__init__.py @@ -3,9 +3,18 @@ """Grid definitions for the energy market.""" -from ._delivery_area import DeliveryArea, EnergyMarketCodeType +from ._delivery_area import ( + BaseDeliveryArea, + DeliveryArea, + EnergyMarketCodeType, + InvalidDeliveryArea, + InvalidDeliveryAreaError, +) __all__ = [ + "BaseDeliveryArea", "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 5cabaa4d..578476ae 100644 --- a/src/frequenz/client/common/grid/_delivery_area.py +++ b/src/frequenz/client/common/grid/_delivery_area.py @@ -4,12 +4,16 @@ """Delivery area information for the energy market.""" import warnings -from dataclasses import dataclass -from typing import assert_never +from dataclasses import InitVar, dataclass +from typing import Any, Self, assert_never from frequenz.core.enum import Enum, deprecated_member, unique -from .._exception import UnrecognizedEnumValueError, UnspecifiedEnumValueError +from .._exception import ( + InvalidAttributeError, + UnrecognizedEnumValueError, + UnspecifiedEnumValueError, +) @unique @@ -55,7 +59,59 @@ class EnergyMarketCodeType(Enum): @dataclass(frozen=True, kw_only=True) -class DeliveryArea: +class BaseDeliveryArea: + """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. + + 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. + + 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 + def __new__(cls, *args: Any, **kwargs: Any) -> Self: + """Prevent instantiation of this class.""" + if cls is BaseDeliveryArea: + raise TypeError(f"Cannot instantiate {cls.__name__} directly") + return super().__new__(cls) + + def __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): """A geographical or administrative region where electricity deliveries occur. DeliveryArea represents the geographical or administrative region, usually defined @@ -66,6 +122,18 @@ class DeliveryArea: location. Delivery areas can have different codes based on the jurisdiction in which they operate. + Warning: Construction of invalid instances is deprecated + A well-formed `DeliveryArea` carries a non-empty [`code`][.code] and a + specified [`code_type`][.code_type]. Constructing one with data that + violates this invariant is **deprecated**, and will raise a + [`ValueError`][] in a future release. + + 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. + Note: Jurisdictional Differences This is typically represented by specific codes according to local jurisdiction. @@ -75,28 +143,45 @@ 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. + _raise_on_invalid: InitVar[bool] = False + """Whether to raise a `ValueError` on invalid data. - This is the lower-level, forward-compatible accessor; prefer - `DeliveryArea.get_code_type()` to obtain a known member or a clear error. + 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.""" - 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. @@ -126,3 +211,71 @@ 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.""" + # 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}]" + + +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/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 3b886d65..a7f58ed3 100644 --- a/src/frequenz/client/common/grid/proto/v1alpha8/_delivery_area.py +++ b/src/frequenz/client/common/grid/proto/v1alpha8/_delivery_area.py @@ -4,11 +4,13 @@ """Conversion of DeliveryArea and EnergyMarketCodeType to/from protobuf v1alpha8.""" import logging +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 +from ..._delivery_area import DeliveryArea, EnergyMarketCodeType, InvalidDeliveryArea _logger = logging.getLogger(__name__) @@ -42,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. @@ -75,4 +91,58 @@ 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 `@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) + + +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. + + 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. + 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 + well-formed, an [`InvalidDeliveryArea`][....InvalidDeliveryArea] + otherwise. + """ + raw_code_type = message.code_type + code_type: EnergyMarketCodeType | int = ( + replace_unspecified_code_type_with + if raw_code_type == 0 + else energy_market_code_type_from_proto(raw_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/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/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/_delivery_area/test_delivery_area.py b/tests/grid/_delivery_area/test_delivery_area.py new file mode 100644 index 00000000..eb9b5c1b --- /dev/null +++ b/tests/grid/_delivery_area/test_delivery_area.py @@ -0,0 +1,282 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Tests for the DeliveryArea class.""" + +import warnings +from dataclasses import dataclass + +import pytest + +from frequenz.client.common import ( + UnrecognizedEnumValueError, + UnspecifiedEnumValueError, +) +from frequenz.client.common.grid import ( + BaseDeliveryArea, + DeliveryArea, + EnergyMarketCodeType, +) + + +@dataclass(frozen=True, kw_only=True) +class _TestCase: + """Test case for DeliveryArea 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.""" + + +@pytest.mark.parametrize( + "case", + [ + _TestCase( + name="valid_EIC_code", + code="10Y1001A1001A450", + code_type=EnergyMarketCodeType.EUROPE_EIC, + expected_str="10Y1001A1001A450[EUROPE_EIC]", + ), + _TestCase( + name="valid_NERC_code", + code="PJM", + code_type=EnergyMarketCodeType.US_NERC, + expected_str="PJM[US_NERC]", + ), + _TestCase( + 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: _TestCase) -> 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", + [ + _TestCase( + name="no_code", + code=None, + code_type=EnergyMarketCodeType.EUROPE_EIC, + expected_str="None[EUROPE_EIC]", + ), + _TestCase( + name="empty_code", + code="", + code_type=EnergyMarketCodeType.EUROPE_EIC, + expected_str="[EUROPE_EIC]", + ), + ], + ids=lambda case: case.name, +) +def test_creation_without_code_emits_deprecation_warning( + case: _TestCase, +) -> 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_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). `DeliveryArea` trusts its inputs and does not + annotate them in `__str__`; use `InvalidDeliveryArea` to render the + invalidity marker explicitly. + """ + with pytest.warns( + DeprecationWarning, + match="Constructing a DeliveryArea with `code_type=0`", + ): + area = DeliveryArea(code="DE", code_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_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 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 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( + code="10Y1001A1001A450", + code_type=EnergyMarketCodeType.EUROPE_EIC, + ) + area2 = DeliveryArea( + code="10Y1001A1001A450", + code_type=EnergyMarketCodeType.EUROPE_EIC, + ) + area3 = DeliveryArea(code="PJM", code_type=EnergyMarketCodeType.US_NERC) + + assert area1 == area2 + assert area1 != area3 + + +def test_hash() -> None: + """Test that DeliveryArea objects can be used in sets and as dict keys.""" + area1 = DeliveryArea( + code="10Y1001A1001A450", + code_type=EnergyMarketCodeType.EUROPE_EIC, + ) + area2 = DeliveryArea( + code="10Y1001A1001A450", + code_type=EnergyMarketCodeType.EUROPE_EIC, + ) + area3 = DeliveryArea(code="PJM", code_type=EnergyMarketCodeType.US_NERC) + + area_set = {area1, area2, area3} + assert len(area_set) == 2 # area1 and area2 are equal + + +@pytest.mark.parametrize( + "member", + [EnergyMarketCodeType.EUROPE_EIC, EnergyMarketCodeType.US_NERC], + ids=lambda member: member.name, +) +def test_get_code_type_returns_known_member(member: EnergyMarketCodeType) -> None: + """get_code_type() returns a known member unchanged.""" + area = DeliveryArea(code="10Y1001A1001A450", code_type=member) + assert area.get_code_type() is member + + +def test_get_code_type_raises_unspecified_for_int_zero() -> None: + """get_code_type() raises UnspecifiedEnumValueError for a raw int 0 code type.""" + with pytest.deprecated_call(): + area = DeliveryArea(code="TEST", code_type=0) + with pytest.raises(UnspecifiedEnumValueError): + area.get_code_type() + + +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(): + 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): + area.get_code_type() + + +def test_get_code_type_raises_unrecognized_for_unknown_int() -> None: + """get_code_type() raises UnrecognizedEnumValueError carrying the raw value.""" + area = DeliveryArea(code="TEST", code_type=999) + with pytest.raises(UnrecognizedEnumValueError) as exc_info: + area.get_code_type() + assert exc_info.value.value == 999 + + +def test_is_base_delivery_area_subclass() -> None: + """`DeliveryArea` is a subclass of `BaseDeliveryArea`.""" + assert issubclass(DeliveryArea, BaseDeliveryArea) 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..2c5f6024 --- /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) diff --git a/tests/grid/proto/v1alpha8/test_delivery_area.py b/tests/grid/proto/v1alpha8/test_delivery_area.py index f821d1e5..211f8d7d 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, ) @@ -109,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 @@ -129,9 +135,154 @@ 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.""" + + 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_replaced_with_default", + code="DE", + code_type=delivery_area_pb2.EnergyMarketCodeType.ENERGY_MARKET_CODE_TYPE_UNSPECIFIED, + expected_code="DE", + expected_code_type=EnergyMarketCodeType.EUROPE_EIC, + expected_type=DeliveryArea, + ), + _FromProto2TestCase( + 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=EnergyMarketCodeType.EUROPE_EIC, + 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 + + +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 diff --git a/tests/grid/test_delivery_area.py b/tests/grid/test_delivery_area.py deleted file mode 100644 index 8714ac0f..00000000 --- a/tests/grid/test_delivery_area.py +++ /dev/null @@ -1,151 +0,0 @@ -# License: MIT -# Copyright © 2025 Frequenz Energy-as-a-Service GmbH - -"""Tests for the DeliveryArea class.""" - -import warnings -from dataclasses import dataclass - -import pytest - -from frequenz.client.common import ( - UnrecognizedEnumValueError, - UnspecifiedEnumValueError, -) -from frequenz.client.common.grid import DeliveryArea, EnergyMarketCodeType - - -@dataclass(frozen=True, kw_only=True) -class _DeliveryAreaTestCase: - """Test case for DeliveryArea 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.""" - - -@pytest.mark.parametrize( - "case", - [ - _DeliveryAreaTestCase( - name="valid_EIC_code", - code="10Y1001A1001A450", - code_type=EnergyMarketCodeType.EUROPE_EIC, - expected_str="10Y1001A1001A450[EUROPE_EIC]", - ), - _DeliveryAreaTestCase( - name="valid_NERC_code", - code="PJM", - code_type=EnergyMarketCodeType.US_NERC, - expected_str="PJM[US_NERC]", - ), - _DeliveryAreaTestCase( - name="no_code", - code=None, - code_type=EnergyMarketCodeType.EUROPE_EIC, - 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]", - ), - ], - 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) - assert area.code == case.code - assert area.code_type == case.code_type - assert str(area) == case.expected_str - - -def test_equality() -> None: - """Test equality of DeliveryArea objects.""" - area1 = DeliveryArea( - code="10Y1001A1001A450", - code_type=EnergyMarketCodeType.EUROPE_EIC, - ) - area2 = DeliveryArea( - code="10Y1001A1001A450", - code_type=EnergyMarketCodeType.EUROPE_EIC, - ) - area3 = DeliveryArea(code="PJM", code_type=EnergyMarketCodeType.US_NERC) - - assert area1 == area2 - assert area1 != area3 - - -def test_hash() -> None: - """Test that DeliveryArea objects can be used in sets and as dict keys.""" - area1 = DeliveryArea( - code="10Y1001A1001A450", - code_type=EnergyMarketCodeType.EUROPE_EIC, - ) - area2 = DeliveryArea( - code="10Y1001A1001A450", - code_type=EnergyMarketCodeType.EUROPE_EIC, - ) - area3 = DeliveryArea(code="PJM", code_type=EnergyMarketCodeType.US_NERC) - - area_set = {area1, area2, area3} - assert len(area_set) == 2 # area1 and area2 are equal - - -def test_unspecified_member_is_deprecated() -> None: - """The UNSPECIFIED member is deprecated; the known members are not.""" - with pytest.deprecated_call(): - deprecated = EnergyMarketCodeType.UNSPECIFIED - assert deprecated in EnergyMarketCodeType - - -@pytest.mark.parametrize( - "member", - [EnergyMarketCodeType.EUROPE_EIC, EnergyMarketCodeType.US_NERC], - ids=lambda member: member.name, -) -def test_get_code_type_returns_known_member(member: EnergyMarketCodeType) -> None: - """get_code_type() returns a known member unchanged.""" - area = DeliveryArea(code="10Y1001A1001A450", code_type=member) - assert area.get_code_type() is member - - -def test_get_code_type_raises_unspecified_for_int_zero() -> None: - """get_code_type() raises UnspecifiedEnumValueError for a raw int 0 code type.""" - area = DeliveryArea(code="TEST", code_type=0) - with pytest.raises(UnspecifiedEnumValueError): - area.get_code_type() - - -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) - with warnings.catch_warnings(): - warnings.simplefilter("error", DeprecationWarning) - with pytest.raises(UnspecifiedEnumValueError): - area.get_code_type() - - -def test_get_code_type_raises_unrecognized_for_unknown_int() -> None: - """get_code_type() raises UnrecognizedEnumValueError carrying the raw value.""" - area = DeliveryArea(code="TEST", code_type=999) - with pytest.raises(UnrecognizedEnumValueError) as exc_info: - area.get_code_type() - assert exc_info.value.value == 999 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()