diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 51c99511..fa31a19e 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -44,6 +44,14 @@ Users are encouraged to switch from direct field access to the new `get_*()` methods (see New Features), which provide a safer way to handle unspecified or unrecognized values. +* `frequenz.client.common.grid.proto.v1alpha8.delivery_area_from_proto` is now deprecated; use `delivery_area_from_proto2` instead. + + The new converter returns `DeliveryArea | InvalidDeliveryArea` and surfaces malformed wire data at the type level rather than silently constructing a `DeliveryArea` with invalid content. The old converter continues to work but emits a `DeprecationWarning`. + +* `frequenz.client.common.grid.DeliveryArea` construction with invalid data is deprecated. Please construct only valid `DeliveryArea` objects. + + A well-formed `DeliveryArea` has a non-empty `code` and a non-`UNSPECIFIED` `code_type`. Constructing one with invalid data currently emits a `DeprecationWarning`; a future release will replace the warning with a hard `ValueError`. Prefer `delivery_area_from_proto2` to load delivery areas from the wire — malformed messages become `InvalidDeliveryArea` instances instead. + ## New Features * Added 4 new electrical component classes for categories that previously collapsed into `UnrecognizedElectricalComponent`: @@ -63,15 +71,24 @@ * Added new exceptions: * `frequenz.client.common.ClientCommonError` as a base exception for the package. + * `frequenz.client.common.MissingFieldError` for accessors that resolve a `T | ... | None` wrapper field to a concrete value and see `None` because the underlying field was not set on the wire. * `frequenz.client.common.UnspecifiedEnumValueError` for unspecified enum values (raw `0` or the deprecated member). * `frequenz.client.common.UnrecognizedEnumValueError` for enum members not yet recognized by the library. Carries the raw integer value in its `value` attribute. -* Added safe convenience getters that raise the new exceptions for unspecified or unrecognized values: +* Added safe convenience getters that raise the new exceptions for unspecified, unrecognized, missing or invalid values: * `frequenz.client.common.grid.DeliveryArea.get_code_type()` * `frequenz.client.common.metrics.MetricConnection.get_category()` * `frequenz.client.common.metrics.MetricSample.get_metric()` +* Added new delivery-area class hierarchy: + + * `frequenz.client.common.grid.BaseDeliveryArea` — abstract common supertype of the two concrete leaves; not directly instantiable. + * `frequenz.client.common.grid.DeliveryArea` — well-formed delivery area (retroactively made a subclass of `BaseDeliveryArea`; its shared `__str__` and `get_code_type()` are hoisted to the base). + * `frequenz.client.common.grid.InvalidDeliveryArea` — malformed wire data; same fields as `DeliveryArea` with no invariants enforced, so callers can inspect whatever the server actually sent. + +* Added `frequenz.client.common.grid.proto.v1alpha8.delivery_area_from_proto2` returning `DeliveryArea | InvalidDeliveryArea`. This is the replacement for the now-deprecated `delivery_area_from_proto` and is what `microgrid_from_proto` uses internally. + * Added a new `frequenz.client.common.types.Lifetime` type together with the `frequenz.client.common.types.proto.v1alpha8.lifetime_from_proto` conversion function. * Added a new `frequenz.client.common.types.Location` type together with the `frequenz.client.common.types.proto.v1alpha8.location_from_proto` conversion function. diff --git a/pyproject.toml b/pyproject.toml index ae56ed2c..46dd6327 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -163,7 +163,6 @@ filterwarnings = [ # characters as this is a regex. 'ignore:Protobuf gencode version .*exactly one major version older.*:UserWarning', ] -addopts = "-vv" testpaths = ["tests", "src"] asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" diff --git a/src/frequenz/client/common/__init__.py b/src/frequenz/client/common/__init__.py index 302376ce..0580c58d 100644 --- a/src/frequenz/client/common/__init__.py +++ b/src/frequenz/client/common/__init__.py @@ -5,12 +5,16 @@ from ._exception import ( ClientCommonError, + InvalidAttributeError, + MissingFieldError, UnrecognizedEnumValueError, UnspecifiedEnumValueError, ) __all__ = [ "ClientCommonError", + "InvalidAttributeError", + "MissingFieldError", "UnrecognizedEnumValueError", "UnspecifiedEnumValueError", ] diff --git a/src/frequenz/client/common/_exception.py b/src/frequenz/client/common/_exception.py index d7fb21b0..26c19f96 100644 --- a/src/frequenz/client/common/_exception.py +++ b/src/frequenz/client/common/_exception.py @@ -8,7 +8,37 @@ class ClientCommonError(Exception): """Base class for all errors raised by frequenz-client-common.""" -class UnrecognizedEnumValueError(ClientCommonError, ValueError): +class InvalidAttributeError(ClientCommonError, ValueError): + """Raised when a semantic accessor sees an invalid value for a field. + + This is also a [`ValueError`][] for convenience. + """ + + def __init__( + self, instance: object, attr_name: str, message: str | None = None + ) -> None: + """Initialize this error. + + Args: + instance: The object instance that had an invalid value. + attr_name: The name of the attribute that had an invalid value. + message: A custom error message. If `None`, a default message + mentioning the instance and attribute is used. + """ + self.instance: object = instance + """The object instance that had an invalid value.""" + + self.attr_name: str = attr_name + """The name of the attribute that had an invalid value.""" + + super().__init__( + message + if message is not None + else f"invalid value for attribute {attr_name!r} in {instance}" + ) + + +class UnrecognizedEnumValueError(InvalidAttributeError): """Raised when a semantic accessor sees an unrecognized protobuf enum value. This happens when the server sets an enum value that this version of the @@ -19,21 +49,34 @@ class UnrecognizedEnumValueError(ClientCommonError, ValueError): This is also a ``ValueError`` for convenience. """ - def __init__(self, value: int, message: str | None = None) -> None: + def __init__( + self, instance: object, attr_name: str, value: int, message: str | None = None + ) -> None: """Initialize this error. Args: + instance: The object instance that had the unrecognized value. + attr_name: The name of the attribute that had the unrecognized value. value: The raw protobuf value that was not recognized. message: A custom error message. If `None`, a default message mentioning the unrecognized value is used. """ self.value: int = value + """The raw protobuf value that was not recognized.""" + super().__init__( - message if message is not None else f"unrecognized enum value: {value!r}" + instance, + attr_name, + ( + message + if message is not None + else f"unrecognized enum value {value!r} for attribute {attr_name!r} in " + f"instance {instance!r}" + ), ) -class UnspecifiedEnumValueError(ClientCommonError, ValueError): +class UnspecifiedEnumValueError(InvalidAttributeError): """Raised when a semantic accessor sees an unspecified protobuf enum value. For a value that is set but not recognized by this client, see @@ -41,3 +84,56 @@ class UnspecifiedEnumValueError(ClientCommonError, ValueError): This is also a [`ValueError`][] for convenience. """ + + def __init__( + self, instance: object, attr_name: str, message: str | None = None + ) -> None: + """Initialize this error. + + Args: + instance: The object instance that had the unspecified value. + attr_name: The name of the attribute that had the unspecified value. + message: A custom error message. If `None`, a default message + mentioning the unspecified value is used. + """ + super().__init__( + instance, + attr_name, + ( + message + if message is not None + else f"unspecified enum value for attribute {attr_name!r} in instance {instance!r}" + ), + ) + + +class MissingFieldError(InvalidAttributeError): + """Raised when a semantic accessor sees a missing optional field. + + This is used by accessors that resolve a wrapper field which may be + absent (typed as `T | None` or `T | ... | None`) to a concrete value, + when the underlying field was not set on the wire. + + This is also a [`ValueError`][] for convenience. + """ + + def __init__( + self, instance: object, attr_name: str, message: str | None = None + ) -> None: + """Initialize this error. + + Args: + instance: The object instance that was missing the field. + attr_name: The name of the missing field. + message: A custom error message. If `None`, a default message + mentioning the missing field is used. + """ + super().__init__( + instance, + attr_name, + ( + message + if message is not None + else f"attribute {attr_name!r} for {instance} missing in protobuf message" + ), + ) 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 4dd4e990..c647d1d2 100644 --- a/src/frequenz/client/common/grid/_delivery_area.py +++ b/src/frequenz/client/common/grid/_delivery_area.py @@ -5,11 +5,15 @@ 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 -from .._exception import UnrecognizedEnumValueError, UnspecifiedEnumValueError +from .._exception import ( + InvalidAttributeError, + UnrecognizedEnumValueError, + UnspecifiedEnumValueError, +) @unique @@ -55,24 +59,15 @@ class EnergyMarketCodeType(Enum): @dataclass(frozen=True, kw_only=True) -class DeliveryArea: - """A geographical or administrative region where electricity deliveries occur. - - DeliveryArea represents the geographical or administrative region, usually defined - and maintained by a Transmission System Operator (TSO), where electricity deliveries - for a contract occur. - - The concept is important to energy trading as it delineates the agreed-upon delivery - location. Delivery areas can have different codes based on the jurisdiction in - which they operate. - - Note: Jurisdictional Differences - This is typically represented by specific codes according to local jurisdiction. - - In Europe, this is represented by an - [EIC](https://en.wikipedia.org/wiki/Energy_Identification_Code) (Energy - Identification Code). [List of - EICs](https://www.entsoe.eu/data/energy-identification-codes-eic/eic-approved-codes/). +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, or obtain instances via + [`delivery_area_from_proto2`][..proto.v1alpha8.delivery_area_from_proto2]. """ code: str | None @@ -84,10 +79,20 @@ class DeliveryArea: This code could be extended in the future, in case an unknown code type is encountered, a plain integer value is used to represent it. - This is the lower-level, forward-compatible accessor; prefer - `DeliveryArea.get_code_type()` to obtain a known member or a clear error. + 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 __str__(self) -> str: """Return a human-readable string representation of this instance.""" code = self.code or "" @@ -98,6 +103,77 @@ def __str__(self) -> str: ) return f"{code}[{code_type}]" + +@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 + and maintained by a Transmission System Operator (TSO), where electricity deliveries + for a contract occur. + + The concept is important to energy trading as it delineates the agreed-upon delivery + location. Delivery areas can have different codes based on the jurisdiction in + which they operate. + + Warning: Construction of invalid instances is deprecated + A well-formed `DeliveryArea` carries a non-empty [`code`][.code] and a known + well specified [`code_type`][.code_type]. Constructing one with data that violates + these invariants is **deprecated**, and will raise a [`ValueError`][] in a future + release. + + Prefer + [`delivery_area_from_proto2`][..proto.v1alpha8.delivery_area_from_proto2] + to load delivery areas from the wire — malformed messages become + [`InvalidDeliveryArea`][..InvalidDeliveryArea] instances instead. + + Note: Jurisdictional Differences + This is typically represented by specific codes according to local jurisdiction. + + In Europe, this is represented by an + [EIC](https://en.wikipedia.org/wiki/Energy_Identification_Code) (Energy + Identification Code). [List of + EICs](https://www.entsoe.eu/data/energy-identification-codes-eic/eic-approved-codes/). + """ + + def __post_init__(self) -> None: + """Warn if this instance carries invalid data. + + A well-formed `DeliveryArea` has: + + * `code` non-empty; and + * `code_type` that is not raw `0` or the deprecated + [`EnergyMarketCodeType.UNSPECIFIED`][...EnergyMarketCodeType] + member. + + Constructing with any other data currently emits a + [`DeprecationWarning`][]; this will become a hard [`ValueError`][] + in a future release. + """ + issues: list[str] = [] + if not self.code: + issues.append("`code` must be non-empty, got " f"{self.code!r}") + # Suppressing the deprecation warning can be removed when UNSPECIFIED is removed + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=DeprecationWarning) + if ( + self.code_type == 0 + or self.code_type is EnergyMarketCodeType.UNSPECIFIED + ): + issues.append( + "`code_type` must not be unspecified, got " f"{self.code_type!r}" + ) + if issues: + warnings.warn( + "DeliveryArea constructed with invalid data (" + + "; ".join(issues) + + "); this will raise ValueError in a future release. " + "Use `delivery_area_from_proto2` to obtain an " + "`InvalidDeliveryArea` for malformed wire data.", + DeprecationWarning, + stacklevel=3, + ) + def get_code_type(self) -> EnergyMarketCodeType: """Return the code type as a known enum member. @@ -119,16 +195,53 @@ def get_code_type(self) -> EnergyMarketCodeType: warnings.filterwarnings("ignore", category=DeprecationWarning) match self.code_type: case 0 | EnergyMarketCodeType.UNSPECIFIED: - raise UnspecifiedEnumValueError( - f"code type of {self} is unspecified" - ) + raise UnspecifiedEnumValueError(self, "code_type") case EnergyMarketCodeType() as code_type: return code_type case int() as code_type: - raise UnrecognizedEnumValueError( - code_type, - f"code type {code_type!r} of {self} is not a recognized " - "EnergyMarketCodeType", - ) + raise UnrecognizedEnumValueError(self, "code_type", code_type) case unknown: assert_never(unknown) + + +@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. + """ + + +class InvalidDeliveryAreaError(InvalidAttributeError): + """Raised when a semantic accessor sees an invalid delivery area. + + The offending [`InvalidDeliveryArea`][..InvalidDeliveryArea] instance + is available as the `delivery_area` attribute so callers can inspect + the raw wire data. + + This is also a [`ValueError`][] for convenience. + """ + + def __init__( + self, + delivery_area: InvalidDeliveryArea, + attr_name: str, + message: str | None = None, + ) -> None: + """Initialize this error. + + Args: + delivery_area: The invalid delivery area instance. + attr_name: The name of the attribute that was being accessed when this + error was raised. + message: A custom error message. If `None`, a default message + mentioning the invalid delivery area is used. + """ + self.delivery_area: InvalidDeliveryArea = delivery_area + """The invalid delivery area instance that caused this error.""" + + super().__init__(delivery_area, attr_name, message) diff --git a/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..2da4ddbc 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,43 @@ 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) + + +def delivery_area_from_proto2( + message: delivery_area_pb2.DeliveryArea, +) -> DeliveryArea | InvalidDeliveryArea: + """Convert a protobuf message to a delivery area object. + + A well-formed message becomes a [`DeliveryArea`][....DeliveryArea]; a + message that fails the `DeliveryArea` invariant becomes an + [`InvalidDeliveryArea`][....InvalidDeliveryArea] carrying the raw wire + data so callers can inspect or report it. + + Unknown non-zero `int` `code_type` values are treated as valid to + preserve forward compatibility with new protobuf enum values. + + Args: + message: The protobuf message to convert. + + Returns: + A [`DeliveryArea`][....DeliveryArea] when the wire data is + well-formed, an [`InvalidDeliveryArea`][....InvalidDeliveryArea] + otherwise. + """ + raw_code_type = message.code_type + code_type: EnergyMarketCodeType | int = ( + raw_code_type + if raw_code_type == 0 + else energy_market_code_type_from_proto(raw_code_type) + ) + if not message.code or raw_code_type == 0: + return InvalidDeliveryArea(code=message.code, code_type=code_type) + return DeliveryArea(code=message.code, code_type=code_type) diff --git a/src/frequenz/client/common/metrics/_sample.py b/src/frequenz/client/common/metrics/_sample.py index 99eddfda..71dfb515 100644 --- a/src/frequenz/client/common/metrics/_sample.py +++ b/src/frequenz/client/common/metrics/_sample.py @@ -157,17 +157,11 @@ def get_category(self) -> MetricConnectionCategory: warnings.filterwarnings("ignore", category=DeprecationWarning) match self.category: case 0 | MetricConnectionCategory.UNSPECIFIED: - raise UnspecifiedEnumValueError( - "connection category is unspecified" - ) + raise UnspecifiedEnumValueError(self, "category") case MetricConnectionCategory(): return self.category case int(): - raise UnrecognizedEnumValueError( - self.category, - f"connection category {self.category!r} is not a recognized " - "MetricConnectionCategory", - ) + raise UnrecognizedEnumValueError(self, "category", self.category) case unexpected: assert_never(unexpected) @@ -305,13 +299,10 @@ def get_metric(self) -> Metric: warnings.filterwarnings("ignore", category=DeprecationWarning) match self.metric: case 0 | Metric.UNSPECIFIED: - raise UnspecifiedEnumValueError("sampled metric is unspecified") + raise UnspecifiedEnumValueError(self, "metric") case Metric(): return self.metric case int(): - raise UnrecognizedEnumValueError( - self.metric, - f"sampled metric {self.metric!r} is not a recognized Metric", - ) + raise UnrecognizedEnumValueError(self, "metric", self.metric) case unexpected: assert_never(unexpected) diff --git a/src/frequenz/client/common/microgrid/_microgrid.py b/src/frequenz/client/common/microgrid/_microgrid.py index af5060eb..75c0c705 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 | None """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.""" @@ -93,15 +110,46 @@ def is_active(self) -> bool: return active case 0: raise UnspecifiedEnumValueError( - f"status of microgrid {self} is unspecified" + self, "_active", f"status of microgrid {self} is unspecified" ) case int() as value: raise UnrecognizedEnumValueError( - value, f"unrecognized status of microgrid {self}: {value!r}" + self, + "_active", + value, + f"unrecognized status of microgrid {self}: {value!r}", ) case unknown: assert_never(unknown) + def get_delivery_area(self) -> DeliveryArea: + """Return the delivery area as a well-formed `DeliveryArea`. + + This is the higher-level accessor for the [`delivery_area`][..delivery_area] + attribute: it resolves the field to a valid + [`DeliveryArea`][....grid.DeliveryArea] or raises a clear, catchable error. + + Returns: + The delivery area, when it is a well-formed + [`DeliveryArea`][....grid.DeliveryArea]. + + Raises: + MissingFieldError: If the delivery area is not set (`None`). + InvalidDeliveryAreaError: If the delivery area is an + [`InvalidDeliveryArea`][....grid.InvalidDeliveryArea]. The + offending instance is available on the exception's + `delivery_area` attribute. + """ + match self.delivery_area: + case None: + raise MissingFieldError(self, "delivery_area") + case InvalidDeliveryArea() as invalid: + raise InvalidDeliveryAreaError(invalid, "delivery_area") + case DeliveryArea() as valid: + return valid + case unknown: + assert_never(unknown) + def __str__(self) -> str: """Return the ID of this microgrid as a string.""" name = f":{self.name}" if self.name else "" diff --git a/src/frequenz/client/common/microgrid/electrical_components/_electrical_component.py b/src/frequenz/client/common/microgrid/electrical_components/_electrical_component.py index 62f48332..431d0b2f 100644 --- a/src/frequenz/client/common/microgrid/electrical_components/_electrical_component.py +++ b/src/frequenz/client/common/microgrid/electrical_components/_electrical_component.py @@ -133,11 +133,15 @@ def provides_telemetry(self) -> bool: return provides_telemetry case 0: raise UnspecifiedEnumValueError( + self, + "_provides_telemetry", f"operational mode of {self} is unspecified; " - "telemetry availability is unknown" + "telemetry availability is unknown", ) case int() as value: raise UnrecognizedEnumValueError( + self, + "_provides_telemetry", value, f"operational mode {value!r} of {self} is not a recognized " "ElectricalComponentOperationalMode; telemetry availability " @@ -162,11 +166,15 @@ def accepts_control(self) -> bool: return accepts_control case 0: raise UnspecifiedEnumValueError( + self, + "_accepts_control", f"operational mode of {self} is unspecified; " - "control availability is unknown" + "control availability is unknown", ) case int() as value: raise UnrecognizedEnumValueError( + self, + "_accepts_control", value, f"operational mode {value!r} of {self} is not a recognized " "ElectricalComponentOperationalMode; control availability " diff --git a/src/frequenz/client/common/microgrid/electrical_components/proto/v1alpha8/_electrical_component.py b/src/frequenz/client/common/microgrid/electrical_components/proto/v1alpha8/_electrical_component.py index 1cbb0bd0..97af9b3f 100644 --- a/src/frequenz/client/common/microgrid/electrical_components/proto/v1alpha8/_electrical_component.py +++ b/src/frequenz/client/common/microgrid/electrical_components/proto/v1alpha8/_electrical_component.py @@ -13,7 +13,6 @@ ) from google.protobuf.json_format import MessageToDict -from ....._exception import UnrecognizedEnumValueError from .....metrics import Bounds, Metric from .....metrics.proto.v1alpha8 import bounds_from_proto from .....proto import enum_from_proto @@ -744,7 +743,7 @@ def electrical_component_class_from_proto( * `(, None)` → its concrete typeless class (`Breaker`, `Meter`, ... one per category). * `(, )` → raises - `UnrecognizedEnumValueError`. + [`ValueError`][]. * `(, )` → `UnrecognizedElectricalComponent` (subtype silently dropped). @@ -771,9 +770,8 @@ def electrical_component_class_from_proto( The corresponding electrical component class. Raises: - UnrecognizedEnumValueError: If `subtype` is not `None` for a known - typeless category — that combination has no representation in the - protobuf wire format. + ValueError: If `subtype` is not `None` for a known typeless category — + that combination has no representation in the protobuf wire format. """ abstract_base = _ABSTRACT_CLASS_BY_TYPED_PROTO_CATEGORY.get(category) if abstract_base is not None: @@ -787,7 +785,10 @@ def electrical_component_class_from_proto( typeless_class = _TYPELESS_CLASS_BY_PROTO_CATEGORY.get(category) if typeless_class is not None: if subtype is not None: - raise UnrecognizedEnumValueError(int(subtype)) + raise ValueError( + f"protobuf subtype {int(subtype)!r} is not valid for typeless " + f"category {int(category)!r}" + ) return typeless_class return UnrecognizedElectricalComponent diff --git a/src/frequenz/client/common/microgrid/proto/v1alpha8/_microgrid.py b/src/frequenz/client/common/microgrid/proto/v1alpha8/_microgrid.py index f9ff2dbe..efcafb88 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 @@ -52,9 +52,9 @@ def microgrid_from_proto(message: microgrid_pb2.Microgrid) -> Microgrid: major_issues: list[str] = [] minor_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/exception/__init__.py b/tests/exception/__init__.py new file mode 100644 index 00000000..691c2190 --- /dev/null +++ b/tests/exception/__init__.py @@ -0,0 +1,4 @@ +# License: MIT +# Copyright © 2026 Frequenz Energy-as-a-Service GmbH + +"""Tests for the common exception hierarchy.""" diff --git a/tests/exception/test_client_common_error.py b/tests/exception/test_client_common_error.py new file mode 100644 index 00000000..f9a2d35b --- /dev/null +++ b/tests/exception/test_client_common_error.py @@ -0,0 +1,40 @@ +# License: MIT +# Copyright © 2026 Frequenz Energy-as-a-Service GmbH + +"""Tests for `ClientCommonError` and cross-cutting exception-module checks.""" + +import frequenz.client.common +from frequenz.client.common import ( + ClientCommonError, + MissingFieldError, + UnrecognizedEnumValueError, + UnspecifiedEnumValueError, +) + + +def test_is_a_plain_exception() -> None: + """`ClientCommonError` is an `Exception` but deliberately not a `ValueError`.""" + assert issubclass(ClientCommonError, Exception) + assert not issubclass(ClientCommonError, ValueError) + + +def test_all_exports_every_exception_class() -> None: + """Every public exception class is re-exported through the package `__all__`.""" + for name in ( + "ClientCommonError", + "InvalidAttributeError", + "UnrecognizedEnumValueError", + "UnspecifiedEnumValueError", + ): + assert name in frequenz.client.common.__all__ + + +def test_invalid_attribute_error_subclasses_are_siblings() -> None: + """The three `InvalidAttributeError` subclasses do not inherit from each other.""" + for a, b in ( + (UnrecognizedEnumValueError, UnspecifiedEnumValueError), + (UnrecognizedEnumValueError, MissingFieldError), + (UnspecifiedEnumValueError, MissingFieldError), + ): + assert not issubclass(a, b) + assert not issubclass(b, a) diff --git a/tests/exception/test_invalid_attribute_error.py b/tests/exception/test_invalid_attribute_error.py new file mode 100644 index 00000000..0a6b56ba --- /dev/null +++ b/tests/exception/test_invalid_attribute_error.py @@ -0,0 +1,33 @@ +# License: MIT +# Copyright © 2026 Frequenz Energy-as-a-Service GmbH + +"""Tests for `InvalidAttributeError`.""" + +from frequenz.client.common import ClientCommonError, InvalidAttributeError + + +def test_is_client_common_and_value_error() -> None: + """`InvalidAttributeError` is both a `ClientCommonError` and a `ValueError`.""" + assert issubclass(InvalidAttributeError, ClientCommonError) + assert issubclass(InvalidAttributeError, ValueError) + + +def test_stores_instance_and_attr_name() -> None: + """`InvalidAttributeError` stores its `instance` and `attr_name` args as attributes.""" + instance = object() + error = InvalidAttributeError(instance, "attr_x") + assert error.instance is instance + assert error.attr_name == "attr_x" + + +def test_default_message() -> None: + """The default message follows the `invalid value for attribute ...` template.""" + assert ( + str(InvalidAttributeError("some-instance", "attr_x")) + == "invalid value for attribute 'attr_x' in some-instance" + ) + + +def test_custom_message_replaces_the_default() -> None: + """A custom message replaces the default entirely.""" + assert str(InvalidAttributeError("i", "a", "explicit msg")) == "explicit msg" diff --git a/tests/exception/test_missing_field_error.py b/tests/exception/test_missing_field_error.py new file mode 100644 index 00000000..f39f10b0 --- /dev/null +++ b/tests/exception/test_missing_field_error.py @@ -0,0 +1,38 @@ +# License: MIT +# Copyright © 2026 Frequenz Energy-as-a-Service GmbH + +"""Tests for `MissingFieldError`.""" + +from frequenz.client.common import ( + ClientCommonError, + InvalidAttributeError, + MissingFieldError, +) + + +def test_inherits_invalid_attribute_error() -> None: + """`MissingFieldError` inherits `InvalidAttributeError` (and thus `ValueError`).""" + assert issubclass(MissingFieldError, InvalidAttributeError) + assert issubclass(MissingFieldError, ClientCommonError) + assert issubclass(MissingFieldError, ValueError) + + +def test_stores_instance_and_attr_name() -> None: + """`MissingFieldError` stores its `instance` and `attr_name` args.""" + instance = object() + error = MissingFieldError(instance, "field_x") + assert error.instance is instance + assert error.attr_name == "field_x" + + +def test_default_message() -> None: + """The default message follows the `attribute ... missing in protobuf message` template.""" + assert ( + str(MissingFieldError("some-instance", "field_x")) + == "attribute 'field_x' for some-instance missing in protobuf message" + ) + + +def test_custom_message_replaces_the_default() -> None: + """A custom message replaces the default entirely.""" + assert str(MissingFieldError("i", "a", "explicit msg")) == "explicit msg" diff --git a/tests/exception/test_unrecognized_enum_value_error.py b/tests/exception/test_unrecognized_enum_value_error.py new file mode 100644 index 00000000..ea30dc58 --- /dev/null +++ b/tests/exception/test_unrecognized_enum_value_error.py @@ -0,0 +1,41 @@ +# License: MIT +# Copyright © 2026 Frequenz Energy-as-a-Service GmbH + +"""Tests for `UnrecognizedEnumValueError`.""" + +from frequenz.client.common import ( + ClientCommonError, + InvalidAttributeError, + UnrecognizedEnumValueError, +) + + +def test_inherits_invalid_attribute_error() -> None: + """`UnrecognizedEnumValueError` inherits `InvalidAttributeError` (and thus `ValueError`).""" + assert issubclass(UnrecognizedEnumValueError, InvalidAttributeError) + assert issubclass(UnrecognizedEnumValueError, ClientCommonError) + assert issubclass(UnrecognizedEnumValueError, ValueError) + + +def test_stores_instance_attr_name_and_value() -> None: + """`UnrecognizedEnumValueError` stores `instance`, `attr_name`, and `value` as attributes.""" + instance = object() + error = UnrecognizedEnumValueError(instance, "attr_x", 999) + assert error.instance is instance + assert error.attr_name == "attr_x" + assert error.value == 999 + + +def test_default_message() -> None: + """The default message follows the `unrecognized enum value ...` template.""" + assert ( + str(UnrecognizedEnumValueError("some-instance", "attr_x", 7)) + == "unrecognized enum value 7 for attribute 'attr_x' in instance 'some-instance'" + ) + + +def test_custom_message_replaces_the_default() -> None: + """A custom message replaces the default entirely.""" + assert ( + str(UnrecognizedEnumValueError("i", "a", 7, "explicit msg")) == "explicit msg" + ) diff --git a/tests/exception/test_unspecified_enum_value_error.py b/tests/exception/test_unspecified_enum_value_error.py new file mode 100644 index 00000000..beba0791 --- /dev/null +++ b/tests/exception/test_unspecified_enum_value_error.py @@ -0,0 +1,38 @@ +# License: MIT +# Copyright © 2026 Frequenz Energy-as-a-Service GmbH + +"""Tests for `UnspecifiedEnumValueError`.""" + +from frequenz.client.common import ( + ClientCommonError, + InvalidAttributeError, + UnspecifiedEnumValueError, +) + + +def test_inherits_invalid_attribute_error() -> None: + """`UnspecifiedEnumValueError` inherits `InvalidAttributeError` (and thus `ValueError`).""" + assert issubclass(UnspecifiedEnumValueError, InvalidAttributeError) + assert issubclass(UnspecifiedEnumValueError, ClientCommonError) + assert issubclass(UnspecifiedEnumValueError, ValueError) + + +def test_stores_instance_and_attr_name() -> None: + """`UnspecifiedEnumValueError` stores its `instance` and `attr_name` args.""" + instance = object() + error = UnspecifiedEnumValueError(instance, "attr_x") + assert error.instance is instance + assert error.attr_name == "attr_x" + + +def test_default_message() -> None: + """The default message follows the `unspecified enum value ...` template.""" + assert ( + str(UnspecifiedEnumValueError("some-instance", "attr_x")) + == "unspecified enum value for attribute 'attr_x' in instance 'some-instance'" + ) + + +def test_custom_message_replaces_the_default() -> None: + """A custom message replaces the default entirely.""" + assert str(UnspecifiedEnumValueError("i", "a", "explicit msg")) == "explicit msg" diff --git a/tests/grid/proto/v1alpha8/test_delivery_area.py b/tests/grid/proto/v1alpha8/test_delivery_area.py index f821d1e5..4061a801 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,116 @@ 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_is_invalid", + code="DE", + code_type=delivery_area_pb2.EnergyMarketCodeType.ENERGY_MARKET_CODE_TYPE_UNSPECIFIED, + expected_code="DE", + expected_code_type=0, + expected_type=InvalidDeliveryArea, + ), + _FromProto2TestCase( + name="both_invalid", + code="", + code_type=delivery_area_pb2.EnergyMarketCodeType.ENERGY_MARKET_CODE_TYPE_UNSPECIFIED, + expected_code="", + expected_code_type=0, + expected_type=InvalidDeliveryArea, + ), + ], + ids=lambda case: case.name, +) +def test_from_proto2( + caplog: pytest.LogCaptureFixture, case: _FromProto2TestCase +) -> None: + """`delivery_area_from_proto2` returns a `DeliveryArea` or `InvalidDeliveryArea`.""" + proto = delivery_area_pb2.DeliveryArea( + code=case.code, code_type=case.code_type # type: ignore[arg-type] + ) + with caplog.at_level("WARNING"): + with warnings.catch_warnings(): + warnings.simplefilter("error", DeprecationWarning) + area = delivery_area_from_proto2(proto) + + assert isinstance(area, case.expected_type) + assert area.code == case.expected_code + assert area.code_type == case.expected_code_type + # The new converter never logs issues. + assert len(caplog.records) == 0 diff --git a/tests/grid/test_delivery_area.py b/tests/grid/test_delivery_area.py index 8714ac0f..25efa1f6 100644 --- a/tests/grid/test_delivery_area.py +++ b/tests/grid/test_delivery_area.py @@ -9,10 +9,17 @@ import pytest from frequenz.client.common import ( + InvalidAttributeError, UnrecognizedEnumValueError, UnspecifiedEnumValueError, ) -from frequenz.client.common.grid import DeliveryArea, EnergyMarketCodeType +from frequenz.client.common.grid import ( + BaseDeliveryArea, + DeliveryArea, + EnergyMarketCodeType, + InvalidDeliveryArea, + InvalidDeliveryAreaError, +) @dataclass(frozen=True, kw_only=True) @@ -47,6 +54,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, @@ -54,28 +83,44 @@ class _DeliveryAreaTestCase: expected_str="[EUROPE_EIC]", ), _DeliveryAreaTestCase( - name="unspecified_code_type", - code="TEST", - code_type=EnergyMarketCodeType.UNSPECIFIED, - expected_str="TEST[UNSPECIFIED]", + name="empty_code", + code="", + code_type=EnergyMarketCodeType.EUROPE_EIC, + expected_str="[EUROPE_EIC]", ), _DeliveryAreaTestCase( - name="unknown_code_type", - code="TEST", - code_type=999, - expected_str="TEST[type=999]", + name="unspecified_code_type_int", + code="DE", + code_type=0, + expected_str="DE[type=0]", ), ], ids=lambda case: case.name, ) -def test_creation(case: _DeliveryAreaTestCase) -> None: - """Test creating DeliveryArea instances with various parameters.""" - area = DeliveryArea(code=case.code, code_type=case.code_type) +def test_creation_invalid_emits_deprecation_warning( + case: _DeliveryAreaTestCase, +) -> None: + """Constructing DeliveryArea with invalid data emits a DeprecationWarning.""" + with pytest.warns( + DeprecationWarning, match="DeliveryArea constructed with invalid" + ): + area = DeliveryArea(code=case.code, code_type=case.code_type) assert area.code == case.code assert area.code_type == case.code_type assert str(area) == case.expected_str +def test_creation_invalid_code_type_member_emits_deprecation_warning() -> None: + """The deprecated UNSPECIFIED code_type member also triggers the warning.""" + with pytest.deprecated_call(): + unspecified = EnergyMarketCodeType.UNSPECIFIED + with pytest.warns( + DeprecationWarning, match="DeliveryArea constructed with invalid" + ): + area = DeliveryArea(code="DE", code_type=unspecified) + assert area.code == "DE" + + def test_equality() -> None: """Test equality of DeliveryArea objects.""" area1 = DeliveryArea( @@ -133,6 +178,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(): @@ -149,3 +200,109 @@ 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) + + +def test_invalid_delivery_area_is_base_delivery_area_subclass() -> None: + """`InvalidDeliveryArea` is a subclass of `BaseDeliveryArea`.""" + assert issubclass(InvalidDeliveryArea, BaseDeliveryArea) + + +@pytest.mark.parametrize( + "case", + [ + _DeliveryAreaTestCase( + name="empty_code", + code="", + code_type=EnergyMarketCodeType.EUROPE_EIC, + expected_str="[EUROPE_EIC]", + ), + _DeliveryAreaTestCase( + name="none_code", + code=None, + code_type=EnergyMarketCodeType.EUROPE_EIC, + expected_str="[EUROPE_EIC]", + ), + _DeliveryAreaTestCase( + name="long_code", + code="10Y1001A1001A450", + code_type=EnergyMarketCodeType.EUROPE_EIC, + expected_str="10Y1001A1001A450[EUROPE_EIC]", + ), + _DeliveryAreaTestCase( + name="unspecified_code_type_int", + code="DE", + code_type=0, + expected_str="DE[type=0]", + ), + _DeliveryAreaTestCase( + name="unknown_code_type_int", + code="DE", + code_type=999, + expected_str="DE[type=999]", + ), + ], + ids=lambda case: case.name, +) +def test_invalid_delivery_area_creation(case: _DeliveryAreaTestCase) -> None: + """`InvalidDeliveryArea` accepts any data with no invariants and no warnings.""" + with warnings.catch_warnings(): + warnings.simplefilter("error", DeprecationWarning) + area = InvalidDeliveryArea(code=case.code, code_type=case.code_type) + assert area.code == case.code + assert area.code_type == case.code_type + assert str(area) == case.expected_str + + +def test_invalid_delivery_area_equality() -> None: + """Two `InvalidDeliveryArea` instances with the same data are equal.""" + area1 = InvalidDeliveryArea(code="", code_type=0) + area2 = InvalidDeliveryArea(code="", code_type=0) + area3 = InvalidDeliveryArea(code="X", code_type=0) + assert area1 == area2 + assert area1 != area3 + + +def test_valid_and_invalid_delivery_area_are_distinct() -> None: + """A `DeliveryArea` and an `InvalidDeliveryArea` with identical fields are not equal.""" + valid = DeliveryArea(code="DE", code_type=EnergyMarketCodeType.EUROPE_EIC) + invalid = InvalidDeliveryArea(code="DE", code_type=EnergyMarketCodeType.EUROPE_EIC) + assert valid != invalid # type: ignore[comparison-overlap] + + +def test_invalid_delivery_area_error_default_message() -> None: + """`InvalidDeliveryAreaError` builds a default message from the invalid area.""" + invalid = InvalidDeliveryArea(code="", code_type=0) + error = InvalidDeliveryAreaError(invalid, "delivery_area") + assert error.delivery_area is invalid + assert "invalid value for attribute 'delivery_area' in [type=0]" == str( + error + ) + + +def test_invalid_delivery_area_error_custom_message() -> None: + """`InvalidDeliveryAreaError` accepts a custom message.""" + invalid = InvalidDeliveryArea(code="X", code_type=0) + error = InvalidDeliveryAreaError( + invalid, "attr", message="bad delivery area from server" + ) + assert error.delivery_area is invalid + assert str(error) == "bad delivery area from server" + + +def test_invalid_delivery_area_error_is_invalid_attribute_error() -> None: + """`InvalidDeliveryAreaError` is also a `InvalidAttributeError` for convenience.""" + invalid = InvalidDeliveryArea(code="", code_type=0) + with pytest.raises(InvalidAttributeError): + raise InvalidDeliveryAreaError(invalid, "delivery_area") + + +def test_invalid_delivery_area_error_is_value_error() -> None: + """`InvalidDeliveryAreaError` is also a `ValueError` for convenience.""" + invalid = InvalidDeliveryArea(code="", code_type=0) + with pytest.raises(ValueError): + raise InvalidDeliveryAreaError(invalid, "delivery_area") diff --git a/tests/microgrid/electrical_components/proto/v1alpha8/test_class.py b/tests/microgrid/electrical_components/proto/v1alpha8/test_class.py index a66ba192..e2bca9ad 100644 --- a/tests/microgrid/electrical_components/proto/v1alpha8/test_class.py +++ b/tests/microgrid/electrical_components/proto/v1alpha8/test_class.py @@ -10,7 +10,6 @@ electrical_components_pb2 as ec_pb2, ) -from frequenz.client.common import UnrecognizedEnumValueError from frequenz.client.common.microgrid import MicrogridId from frequenz.client.common.microgrid.electrical_components import ( AcEvCharger, @@ -525,14 +524,16 @@ def test_class_from_proto_rejects_typeless_subtype() -> None: """Test known typeless categories reject spurious subtype values.""" # Given: a known typeless category with an impossible subtype. # When: the protobuf values are converted to a component class. - with pytest.raises(UnrecognizedEnumValueError) as exc_info: + with pytest.raises( + ValueError, match=r"subtype 1 is not valid for typeless" + ) as exc_info: electrical_component_class_from_proto( ec_pb2.ELECTRICAL_COMPONENT_CATEGORY_METER, cast(_ProtoSubtype, 1), ) - # Then: the failing raw protobuf subtype is exposed on the typed error. - assert exc_info.value.value == 1 + # Then: a plain ValueError is raised, not one of the typed wrapper exceptions. + assert exc_info.type is ValueError # --------------------------------------------------------------------------- diff --git a/tests/microgrid/proto/v1alpha8/test_microgrid.py b/tests/microgrid/proto/v1alpha8/test_microgrid.py index 93b83d71..bc85a221 100644 --- a/tests/microgrid/proto/v1alpha8/test_microgrid.py +++ b/tests/microgrid/proto/v1alpha8/test_microgrid.py @@ -145,7 +145,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 cd5ebff3..6fa2d4dc 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 @@ -185,3 +191,52 @@ def test_replace_preserves_construction() -> None: replaced = dataclasses.replace(info, name="renamed") assert replaced.name == "renamed" assert replaced.is_active() is True + + +def _make_microgrid( + delivery_area: DeliveryArea | InvalidDeliveryArea | None, +) -> Microgrid: + """Build a Microgrid with the given delivery area for accessor tests.""" + return Microgrid( + id=MicrogridId(1234), + enterprise_id=EnterpriseId(5678), + name=None, + delivery_area=delivery_area, + location=None, + create_time=datetime.now(timezone.utc), + _active=True, + _allow_construction=True, + ) + + +def test_get_delivery_area_returns_valid() -> None: + """`get_delivery_area()` returns the valid `DeliveryArea` unchanged.""" + area = DeliveryArea(code="DE", code_type=EnergyMarketCodeType.EUROPE_EIC) + info = _make_microgrid(area) + assert info.get_delivery_area() is area + + +def test_get_delivery_area_raises_missing_for_none() -> None: + """`get_delivery_area()` raises `MissingFieldError` when the field is `None`.""" + info = _make_microgrid(None) + with pytest.raises( + MissingFieldError, + match=r"attribute 'delivery_area' for .*1234.* missing in protobuf message", + ): + 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() diff --git a/tests/test_exception.py b/tests/test_exception.py deleted file mode 100644 index 0308cc6b..00000000 --- a/tests/test_exception.py +++ /dev/null @@ -1,44 +0,0 @@ -"""Tests for common exceptions.""" - -import frequenz.client.common -from frequenz.client.common import ( - ClientCommonError, - UnrecognizedEnumValueError, - UnspecifiedEnumValueError, -) - - -def test_exceptions_exported_and_related() -> None: - """Given exception exports, then their hierarchy and string form are correct.""" - assert issubclass(UnspecifiedEnumValueError, ClientCommonError) - assert issubclass(UnspecifiedEnumValueError, ValueError) - assert issubclass(UnrecognizedEnumValueError, ClientCommonError) - assert issubclass(UnrecognizedEnumValueError, ValueError) - assert not issubclass(ClientCommonError, ValueError) - assert str(UnspecifiedEnumValueError("msg")) == "msg" - - -def test_all_is_sorted_and_exports_unrecognized() -> None: - """Given the package exports, then __all__ includes the new error and stays sorted.""" - assert "UnrecognizedEnumValueError" in frequenz.client.common.__all__ - assert list(frequenz.client.common.__all__) == sorted( - frequenz.client.common.__all__ - ) - - -def test_unrecognized_enum_value_error_carries_value() -> None: - """Given an unrecognized value, then it is stored and catchable as both base types.""" - error = UnrecognizedEnumValueError(999) - assert error.value == 999 - assert isinstance(error, ValueError) - assert isinstance(error, ClientCommonError) - - -def test_unrecognized_enum_value_error_default_message() -> None: - """Given no explicit message, then the default string contains the raw value.""" - assert "7" in str(UnrecognizedEnumValueError(7)) - - -def test_unrecognized_enum_value_error_custom_message() -> None: - """Given a custom message, then the string form is exactly that message.""" - assert str(UnrecognizedEnumValueError(7, "msg")) == "msg"