From 939af907ee95b0f313762f1cda4959a5af89b889 Mon Sep 17 00:00:00 2001 From: eduardiazf Date: Mon, 26 Jan 2026 11:24:02 +0100 Subject: [PATCH 1/2] Add raise_on_errors parameter to parsing functions Signed-off-by: eduardiazf --- src/frequenz/client/assets/_client.py | 39 +++++++++++++----- .../client/assets/_microgrid_proto.py | 18 +++++++- .../electrical_component/_connection_proto.py | 23 ++++++++++- .../_electrical_component_proto.py | 14 +++++++ src/frequenz/client/assets/exceptions.py | 41 ++++++++++++++++++- 5 files changed, 122 insertions(+), 13 deletions(-) diff --git a/src/frequenz/client/assets/_client.py b/src/frequenz/client/assets/_client.py index 23b39ff..dad9b74 100644 --- a/src/frequenz/client/assets/_client.py +++ b/src/frequenz/client/assets/_client.py @@ -89,13 +89,20 @@ def stub(self) -> assets_pb2_grpc.PlatformAssetsAsyncStub: return self._stub # type: ignore async def get_microgrid( # noqa: DOC502 (raises ApiClientError indirectly) - self, microgrid_id: MicrogridId + self, + microgrid_id: MicrogridId, + *, + raise_on_errors: bool = False, ) -> Microgrid: """ Get the details of a microgrid. Args: microgrid_id: The ID of the microgrid to get the details of. + raise_on_errors: If True, raise a + [ParsingError][frequenz.client.assets.exceptions.ParsingError] + when major issues are found in the response instead of just + logging them. Returns: The details of the microgrid. @@ -113,16 +120,23 @@ async def get_microgrid( # noqa: DOC502 (raises ApiClientError indirectly) method_name="GetMicrogrid", ) - return microgrid_from_proto(response.microgrid) + return microgrid_from_proto(response.microgrid, raise_on_errors=raise_on_errors) async def list_microgrid_electrical_components( - self, microgrid_id: MicrogridId + self, + microgrid_id: MicrogridId, + *, + raise_on_errors: bool = False, ) -> list[ElectricalComponent]: """ Get the electrical components of a microgrid. Args: microgrid_id: The ID of the microgrid to get the electrical components of. + raise_on_errors: If True, raise a + [ParsingError][frequenz.client.assets.exceptions.ParsingError] + when major issues are found in any component instead of just + logging them. Returns: The electrical components of the microgrid. @@ -139,7 +153,8 @@ async def list_microgrid_electrical_components( ) return [ - electrical_component_proto(component) for component in response.components + electrical_component_proto(component, raise_on_errors=raise_on_errors) + for component in response.components ] async def list_microgrid_electrical_component_connections( @@ -147,6 +162,8 @@ async def list_microgrid_electrical_component_connections( microgrid_id: MicrogridId, source_component_ids: Iterable[ElectricalComponentId] = (), destination_component_ids: Iterable[ElectricalComponentId] = (), + *, + raise_on_errors: bool = False, ) -> list[ComponentConnection | None]: """ Get the electrical component connections of a microgrid. @@ -158,6 +175,10 @@ async def list_microgrid_electrical_component_connections( these component IDs. If None or empty, no filtering is applied. destination_component_ids: Only return connections that terminate at these component IDs. If None or empty, no filtering is applied. + raise_on_errors: If True, raise a + [ParsingError][frequenz.client.assets.exceptions.ParsingError] + when major issues are found in any connection instead of just + logging them. Returns: The electrical component connections of the microgrid. @@ -177,9 +198,7 @@ async def list_microgrid_electrical_component_connections( method_name="ListMicrogridElectricalComponentConnections", ) - return list( - map( - component_connection_from_proto, - filter(bool, response.connections), - ) - ) + return [ + component_connection_from_proto(conn, raise_on_errors=raise_on_errors) + for conn in filter(bool, response.connections) + ] diff --git a/src/frequenz/client/assets/_microgrid_proto.py b/src/frequenz/client/assets/_microgrid_proto.py index 7e59682..d30b04a 100644 --- a/src/frequenz/client/assets/_microgrid_proto.py +++ b/src/frequenz/client/assets/_microgrid_proto.py @@ -16,18 +16,28 @@ from ._location import Location from ._location_proto import location_from_proto from ._microgrid import Microgrid, MicrogridStatus +from .exceptions import ParsingError _logger = logging.getLogger(__name__) -def microgrid_from_proto(message: microgrid_pb2.Microgrid) -> Microgrid: +def microgrid_from_proto( + message: microgrid_pb2.Microgrid, + *, + raise_on_errors: bool = False, +) -> Microgrid: """Convert a protobuf microgrid message to a microgrid object. Args: message: The protobuf message to convert. + raise_on_errors: If True, raise a ParsingError when major issues + are found instead of just logging them. Returns: The resulting microgrid object. + + Raises: + ParsingError: If `raise_on_errors` is True and major issues are found. """ major_issues: list[str] = [] minor_issues: list[str] = [] @@ -55,6 +65,12 @@ def microgrid_from_proto(message: microgrid_pb2.Microgrid) -> Microgrid: major_issues.append("status is unrecognized") if major_issues: + if raise_on_errors: + raise ParsingError( + major_issues=major_issues, + minor_issues=minor_issues, + raw_message=message, + ) _logger.warning( "Found issues in microgrid: %s | Protobuf message:\n%s", ", ".join(major_issues), diff --git a/src/frequenz/client/assets/electrical_component/_connection_proto.py b/src/frequenz/client/assets/electrical_component/_connection_proto.py index 11c7d61..7f460a5 100644 --- a/src/frequenz/client/assets/electrical_component/_connection_proto.py +++ b/src/frequenz/client/assets/electrical_component/_connection_proto.py @@ -12,6 +12,7 @@ from .._lifetime import Lifetime from .._lifetime_proto import lifetime_from_proto +from ..exceptions import ParsingError from ._connection import ComponentConnection _logger = logging.getLogger(__name__) @@ -19,8 +20,22 @@ def component_connection_from_proto( message: electrical_components_pb2.ElectricalComponentConnection, + *, + raise_on_errors: bool = False, ) -> ComponentConnection | None: - """Create a `ComponentConnection` from a protobuf message.""" + """Create a `ComponentConnection` from a protobuf message. + + Args: + message: The protobuf message to parse. + raise_on_errors: If True, raise a ParsingError when major issues + are found instead of just logging them. + + Returns: + The parsed ComponentConnection, or None if completely invalid. + + Raises: + ParsingError: If `raise_on_errors` is True and major issues are found. + """ major_issues: list[str] = [] minor_issues: list[str] = [] @@ -29,6 +44,12 @@ def component_connection_from_proto( ) if major_issues: + if raise_on_errors: + raise ParsingError( + major_issues=major_issues, + minor_issues=minor_issues, + raw_message=message, + ) _logger.warning( "Found issues in component connection: %s | Protobuf message:\n%s", ", ".join(major_issues), diff --git a/src/frequenz/client/assets/electrical_component/_electrical_component_proto.py b/src/frequenz/client/assets/electrical_component/_electrical_component_proto.py index 3c5f77d..46c595b 100644 --- a/src/frequenz/client/assets/electrical_component/_electrical_component_proto.py +++ b/src/frequenz/client/assets/electrical_component/_electrical_component_proto.py @@ -16,6 +16,7 @@ from .._lifetime import Lifetime from .._lifetime_proto import lifetime_from_proto +from ..exceptions import ParsingError from ..metrics._bounds import Bounds from ..metrics._bounds_proto import bounds_from_proto from ..metrics._metric import Metric @@ -66,14 +67,21 @@ def electrical_component_proto( message: electrical_components_pb2.ElectricalComponent, + *, + raise_on_errors: bool = False, ) -> ElectricalComponentType: """Convert a protobuf message to a `Component` instance. Args: message: The protobuf message. + raise_on_errors: If True, raise a ParsingError when major issues + are found instead of just logging them. Returns: The resulting `ElectricalComponent` instance. + + Raises: + ParsingError: If `raise_on_errors` is True and major issues are found. """ major_issues: list[str] = [] minor_issues: list[str] = [] @@ -85,6 +93,12 @@ def electrical_component_proto( ) if major_issues: + if raise_on_errors: + raise ParsingError( + major_issues=major_issues, + minor_issues=minor_issues, + raw_message=message, + ) _logger.warning( "Found issues in component: %s | Protobuf message:\n%s", ", ".join(major_issues), diff --git a/src/frequenz/client/assets/exceptions.py b/src/frequenz/client/assets/exceptions.py index 9036862..de2a666 100644 --- a/src/frequenz/client/assets/exceptions.py +++ b/src/frequenz/client/assets/exceptions.py @@ -3,6 +3,8 @@ """Exceptions raised by the assets API client.""" +from typing import Any + from frequenz.client.base.exception import ( ApiClientError, ClientNotConnected, @@ -42,10 +44,47 @@ "OperationPreconditionFailed", "OperationTimedOut", "OperationUnauthenticated", + "ParsingError", "PermissionDenied", "ResourceExhausted", "ServiceUnavailable", "UnknownError", "UnrecognizedGrpcStatus", - "PermissionDenied", ] + + +class ParsingError(Exception): + """Error raised when parsing protobuf messages fails with major issues. + + This exception is raised when `raise_on_errors=True` is passed to parsing + functions and major issues are detected in the protobuf message. + """ + + major_issues: list[str] + """List of major issues that indicate serious data problems.""" + + minor_issues: list[str] + """List of minor/informational issues.""" + + raw_message: Any + """The original protobuf message that was being parsed.""" + + def __init__( + self, + *, + major_issues: list[str], + minor_issues: list[str], + raw_message: Any, + ) -> None: + """Create a new ParsingError. + + Args: + major_issues: List of major issues found during parsing. + minor_issues: List of minor issues found during parsing. + raw_message: The protobuf message that failed parsing. + """ + issues_summary = ", ".join(major_issues) + super().__init__(f"Parsing failed with major issues: {issues_summary}") + self.major_issues = major_issues + self.minor_issues = minor_issues + self.raw_message = raw_message From 86debfc598665cf89e245fe2d28ad7d591d7ccbe Mon Sep 17 00:00:00 2001 From: eduardiazf Date: Thu, 29 Jan 2026 12:37:13 +0100 Subject: [PATCH 2/2] refactor(raise_on_errors): replace ParsingError with ValidationError hierarchy and move validation logic to client level Signed-off-by: eduardiazf --- src/frequenz/client/assets/_client.py | 142 +++++++-- .../client/assets/_microgrid_proto.py | 72 +++-- .../electrical_component/_connection_proto.py | 23 +- .../_electrical_component_proto.py | 14 - src/frequenz/client/assets/exceptions.py | 269 +++++++++++++++++- 5 files changed, 416 insertions(+), 104 deletions(-) diff --git a/src/frequenz/client/assets/_client.py b/src/frequenz/client/assets/_client.py index dad9b74..5789cf7 100644 --- a/src/frequenz/client/assets/_client.py +++ b/src/frequenz/client/assets/_client.py @@ -18,12 +18,25 @@ from frequenz.client.common.microgrid.electrical_components import ElectricalComponentId from ._microgrid import Microgrid -from ._microgrid_proto import microgrid_from_proto +from ._microgrid_proto import microgrid_from_proto, microgrid_from_proto_with_issues from .electrical_component._connection import ComponentConnection -from .electrical_component._connection_proto import component_connection_from_proto +from .electrical_component._connection_proto import ( + component_connection_from_proto, + component_connection_from_proto_with_issues, +) from .electrical_component._electrical_component import ElectricalComponent -from .electrical_component._electrical_component_proto import electrical_component_proto -from .exceptions import ClientNotConnected +from .electrical_component._electrical_component_proto import ( + electrical_component_from_proto_with_issues, + electrical_component_proto, +) +from .exceptions import ( + ClientNotConnected, + InvalidConnectionError, + InvalidConnectionErrorGroup, + InvalidElectricalComponentError, + InvalidElectricalComponentErrorGroup, + InvalidMicrogridError, +) DEFAULT_GRPC_CALL_TIMEOUT = 60.0 """The default timeout for gRPC calls made by this client (in seconds).""" @@ -88,7 +101,7 @@ def stub(self) -> assets_pb2_grpc.PlatformAssetsAsyncStub: # use the async stub, so we cast the sync stub to the async stub. return self._stub # type: ignore - async def get_microgrid( # noqa: DOC502 (raises ApiClientError indirectly) + async def get_microgrid( # noqa: DOC502,DOC503 (raises indirectly) self, microgrid_id: MicrogridId, *, @@ -99,9 +112,9 @@ async def get_microgrid( # noqa: DOC502 (raises ApiClientError indirectly) Args: microgrid_id: The ID of the microgrid to get the details of. - raise_on_errors: If True, raise a - [ParsingError][frequenz.client.assets.exceptions.ParsingError] - when major issues are found in the response instead of just + raise_on_errors: If True, raise an + [InvalidMicrogridError][frequenz.client.assets.exceptions.InvalidMicrogridError] + when major validation issues are found instead of just logging them. Returns: @@ -110,6 +123,8 @@ async def get_microgrid( # noqa: DOC502 (raises ApiClientError indirectly) Raises: ApiClientError: If there are any errors communicating with the Assets API, most likely a subclass of [GrpcError][frequenz.client.base.exception.GrpcError]. + InvalidMicrogridError: If `raise_on_errors` is True and major + validation issues are found. """ response = await call_stub_method( self, @@ -120,7 +135,24 @@ async def get_microgrid( # noqa: DOC502 (raises ApiClientError indirectly) method_name="GetMicrogrid", ) - return microgrid_from_proto(response.microgrid, raise_on_errors=raise_on_errors) + if raise_on_errors: + major_issues: list[str] = [] + minor_issues: list[str] = [] + microgrid = microgrid_from_proto_with_issues( + response.microgrid, + major_issues=major_issues, + minor_issues=minor_issues, + ) + if major_issues: + raise InvalidMicrogridError( + microgrid=microgrid, + major_issues=major_issues, + minor_issues=minor_issues, + raw_message=response.microgrid, + ) + return microgrid + + return microgrid_from_proto(response.microgrid) async def list_microgrid_electrical_components( self, @@ -133,13 +165,17 @@ async def list_microgrid_electrical_components( Args: microgrid_id: The ID of the microgrid to get the electrical components of. - raise_on_errors: If True, raise a - [ParsingError][frequenz.client.assets.exceptions.ParsingError] - when major issues are found in any component instead of just - logging them. + raise_on_errors: If True, raise an + [InvalidElectricalComponentErrorGroup][frequenz.client.assets.exceptions.InvalidElectricalComponentErrorGroup] + when major validation issues are found in any component instead + of just logging them. Returns: The electrical components of the microgrid. + + Raises: + InvalidElectricalComponentErrorGroup: If `raise_on_errors` is True + and major validation issues are found. """ response = await call_stub_method( self, @@ -152,9 +188,37 @@ async def list_microgrid_electrical_components( method_name="ListMicrogridElectricalComponents", ) + if raise_on_errors: + components: list[ElectricalComponent] = [] + exceptions: list[InvalidElectricalComponentError] = [] + for component_pb in response.components: + major_issues: list[str] = [] + minor_issues: list[str] = [] + component = electrical_component_from_proto_with_issues( + component_pb, + major_issues=major_issues, + minor_issues=minor_issues, + ) + if major_issues: + exceptions.append( + InvalidElectricalComponentError( + component=component, + major_issues=major_issues, + minor_issues=minor_issues, + raw_message=component_pb, + ) + ) + else: + components.append(component) + if exceptions: + raise InvalidElectricalComponentErrorGroup( + valid_components=components, + exceptions=exceptions, + ) + return components + return [ - electrical_component_proto(component, raise_on_errors=raise_on_errors) - for component in response.components + electrical_component_proto(component) for component in response.components ] async def list_microgrid_electrical_component_connections( @@ -175,13 +239,17 @@ async def list_microgrid_electrical_component_connections( these component IDs. If None or empty, no filtering is applied. destination_component_ids: Only return connections that terminate at these component IDs. If None or empty, no filtering is applied. - raise_on_errors: If True, raise a - [ParsingError][frequenz.client.assets.exceptions.ParsingError] - when major issues are found in any connection instead of just - logging them. + raise_on_errors: If True, raise an + [InvalidConnectionErrorGroup][frequenz.client.assets.exceptions.InvalidConnectionErrorGroup] + when major validation issues are found in any connection instead + of just logging them. Returns: The electrical component connections of the microgrid. + + Raises: + InvalidConnectionErrorGroup: If `raise_on_errors` is True and + major validation issues are found. """ request = assets_pb2.ListMicrogridElectricalComponentConnectionsRequest( microgrid_id=int(microgrid_id), @@ -198,7 +266,35 @@ async def list_microgrid_electrical_component_connections( method_name="ListMicrogridElectricalComponentConnections", ) - return [ - component_connection_from_proto(conn, raise_on_errors=raise_on_errors) - for conn in filter(bool, response.connections) - ] + if raise_on_errors: + connections: list[ComponentConnection | None] = [] + exceptions: list[InvalidConnectionError] = [] + for conn_pb in filter(bool, response.connections): + major_issues: list[str] = [] + connection = component_connection_from_proto_with_issues( + conn_pb, major_issues=major_issues + ) + if major_issues: + exceptions.append( + InvalidConnectionError( + connection=connection, + major_issues=major_issues, + minor_issues=[], + raw_message=conn_pb, + ) + ) + elif connection is not None: + connections.append(connection) + if exceptions: + raise InvalidConnectionErrorGroup( + valid_connections=[c for c in connections if c is not None], + exceptions=exceptions, + ) + return connections + + return list( + map( + component_connection_from_proto, + filter(bool, response.connections), + ) + ) diff --git a/src/frequenz/client/assets/_microgrid_proto.py b/src/frequenz/client/assets/_microgrid_proto.py index d30b04a..18f2c5a 100644 --- a/src/frequenz/client/assets/_microgrid_proto.py +++ b/src/frequenz/client/assets/_microgrid_proto.py @@ -16,32 +16,62 @@ from ._location import Location from ._location_proto import location_from_proto from ._microgrid import Microgrid, MicrogridStatus -from .exceptions import ParsingError _logger = logging.getLogger(__name__) -def microgrid_from_proto( - message: microgrid_pb2.Microgrid, - *, - raise_on_errors: bool = False, -) -> Microgrid: +def microgrid_from_proto(message: microgrid_pb2.Microgrid) -> Microgrid: """Convert a protobuf microgrid message to a microgrid object. Args: message: The protobuf message to convert. - raise_on_errors: If True, raise a ParsingError when major issues - are found instead of just logging them. Returns: The resulting microgrid object. - - Raises: - ParsingError: If `raise_on_errors` is True and major issues are found. """ major_issues: list[str] = [] minor_issues: list[str] = [] + microgrid = microgrid_from_proto_with_issues( + message, major_issues=major_issues, minor_issues=minor_issues + ) + + if major_issues: + _logger.warning( + "Found issues in microgrid: %s | Protobuf message:\n%s", + ", ".join(major_issues), + message, + ) + + if minor_issues: + _logger.debug( + "Found minor issues in microgrid: %s | Protobuf message:\n%s", + ", ".join(minor_issues), + message, + ) + + return microgrid + + +def microgrid_from_proto_with_issues( + message: microgrid_pb2.Microgrid, + *, + major_issues: list[str], + minor_issues: list[str], +) -> Microgrid: + """Convert a protobuf microgrid message to a microgrid object, collecting issues. + + This function is useful when you want to collect issues during parsing + rather than logging them immediately. + + Args: + message: The protobuf message to convert. + major_issues: A list to collect major issues found during validation. + minor_issues: A list to collect minor issues found during validation. + + Returns: + The resulting microgrid object. + """ delivery_area: DeliveryArea | None = None if message.HasField("delivery_area"): delivery_area = delivery_area_from_proto(message.delivery_area) @@ -64,26 +94,6 @@ def microgrid_from_proto( elif isinstance(status, int): major_issues.append("status is unrecognized") - if major_issues: - if raise_on_errors: - raise ParsingError( - major_issues=major_issues, - minor_issues=minor_issues, - raw_message=message, - ) - _logger.warning( - "Found issues in microgrid: %s | Protobuf message:\n%s", - ", ".join(major_issues), - message, - ) - - if minor_issues: - _logger.debug( - "Found minor issues in microgrid: %s | Protobuf message:\n%s", - ", ".join(minor_issues), - message, - ) - return Microgrid( id=MicrogridId(message.id), enterprise_id=EnterpriseId(message.enterprise_id), diff --git a/src/frequenz/client/assets/electrical_component/_connection_proto.py b/src/frequenz/client/assets/electrical_component/_connection_proto.py index 7f460a5..11c7d61 100644 --- a/src/frequenz/client/assets/electrical_component/_connection_proto.py +++ b/src/frequenz/client/assets/electrical_component/_connection_proto.py @@ -12,7 +12,6 @@ from .._lifetime import Lifetime from .._lifetime_proto import lifetime_from_proto -from ..exceptions import ParsingError from ._connection import ComponentConnection _logger = logging.getLogger(__name__) @@ -20,22 +19,8 @@ def component_connection_from_proto( message: electrical_components_pb2.ElectricalComponentConnection, - *, - raise_on_errors: bool = False, ) -> ComponentConnection | None: - """Create a `ComponentConnection` from a protobuf message. - - Args: - message: The protobuf message to parse. - raise_on_errors: If True, raise a ParsingError when major issues - are found instead of just logging them. - - Returns: - The parsed ComponentConnection, or None if completely invalid. - - Raises: - ParsingError: If `raise_on_errors` is True and major issues are found. - """ + """Create a `ComponentConnection` from a protobuf message.""" major_issues: list[str] = [] minor_issues: list[str] = [] @@ -44,12 +29,6 @@ def component_connection_from_proto( ) if major_issues: - if raise_on_errors: - raise ParsingError( - major_issues=major_issues, - minor_issues=minor_issues, - raw_message=message, - ) _logger.warning( "Found issues in component connection: %s | Protobuf message:\n%s", ", ".join(major_issues), diff --git a/src/frequenz/client/assets/electrical_component/_electrical_component_proto.py b/src/frequenz/client/assets/electrical_component/_electrical_component_proto.py index 46c595b..3c5f77d 100644 --- a/src/frequenz/client/assets/electrical_component/_electrical_component_proto.py +++ b/src/frequenz/client/assets/electrical_component/_electrical_component_proto.py @@ -16,7 +16,6 @@ from .._lifetime import Lifetime from .._lifetime_proto import lifetime_from_proto -from ..exceptions import ParsingError from ..metrics._bounds import Bounds from ..metrics._bounds_proto import bounds_from_proto from ..metrics._metric import Metric @@ -67,21 +66,14 @@ def electrical_component_proto( message: electrical_components_pb2.ElectricalComponent, - *, - raise_on_errors: bool = False, ) -> ElectricalComponentType: """Convert a protobuf message to a `Component` instance. Args: message: The protobuf message. - raise_on_errors: If True, raise a ParsingError when major issues - are found instead of just logging them. Returns: The resulting `ElectricalComponent` instance. - - Raises: - ParsingError: If `raise_on_errors` is True and major issues are found. """ major_issues: list[str] = [] minor_issues: list[str] = [] @@ -93,12 +85,6 @@ def electrical_component_proto( ) if major_issues: - if raise_on_errors: - raise ParsingError( - major_issues=major_issues, - minor_issues=minor_issues, - raw_message=message, - ) _logger.warning( "Found issues in component: %s | Protobuf message:\n%s", ", ".join(major_issues), diff --git a/src/frequenz/client/assets/exceptions.py b/src/frequenz/client/assets/exceptions.py index de2a666..929de37 100644 --- a/src/frequenz/client/assets/exceptions.py +++ b/src/frequenz/client/assets/exceptions.py @@ -3,7 +3,10 @@ """Exceptions raised by the assets API client.""" -from typing import Any +from __future__ import annotations + +from collections.abc import Sequence +from typing import TYPE_CHECKING, Any, Self from frequenz.client.base.exception import ( ApiClientError, @@ -28,6 +31,11 @@ UnrecognizedGrpcStatus, ) +if TYPE_CHECKING: + from ._microgrid import Microgrid + from .electrical_component._connection import ComponentConnection + from .electrical_component._electrical_component import ElectricalComponent + __all__ = [ "ApiClientError", "ClientNotConnected", @@ -37,6 +45,11 @@ "GrpcError", "InternalError", "InvalidArgument", + "InvalidConnectionError", + "InvalidConnectionErrorGroup", + "InvalidElectricalComponentError", + "InvalidElectricalComponentErrorGroup", + "InvalidMicrogridError", "OperationAborted", "OperationCancelled", "OperationNotImplemented", @@ -44,30 +57,31 @@ "OperationPreconditionFailed", "OperationTimedOut", "OperationUnauthenticated", - "ParsingError", "PermissionDenied", "ResourceExhausted", "ServiceUnavailable", "UnknownError", "UnrecognizedGrpcStatus", + "ValidationError", + "ValidationErrorGroup", ] -class ParsingError(Exception): - """Error raised when parsing protobuf messages fails with major issues. +class ValidationError(Exception): + """Base error for protobuf message validation failures. - This exception is raised when `raise_on_errors=True` is passed to parsing - functions and major issues are detected in the protobuf message. + This exception is raised when ``raise_on_errors=True`` is passed to + client methods and validation issues are detected in the protobuf message. """ major_issues: list[str] - """List of major issues that indicate serious data problems.""" + """List of major issues found during validation.""" minor_issues: list[str] - """List of minor/informational issues.""" + """List of minor issues found during validation.""" raw_message: Any - """The original protobuf message that was being parsed.""" + """The original protobuf message that was being validated.""" def __init__( self, @@ -76,15 +90,242 @@ def __init__( minor_issues: list[str], raw_message: Any, ) -> None: - """Create a new ParsingError. + """Create a new ValidationError. Args: - major_issues: List of major issues found during parsing. - minor_issues: List of minor issues found during parsing. - raw_message: The protobuf message that failed parsing. + major_issues: List of major issues found during validation. + minor_issues: List of minor issues found during validation. + raw_message: The protobuf message that failed validation. """ issues_summary = ", ".join(major_issues) - super().__init__(f"Parsing failed with major issues: {issues_summary}") + super().__init__(f"Validation failed: {issues_summary}") self.major_issues = major_issues self.minor_issues = minor_issues self.raw_message = raw_message + + +class InvalidMicrogridError(ValidationError): + """Raised when a microgrid message has validation issues.""" + + microgrid: Microgrid + """The partially validated microgrid object.""" + + def __init__( + self, + *, + microgrid: Microgrid, + major_issues: list[str], + minor_issues: list[str], + raw_message: Any, + ) -> None: + """Create a new InvalidMicrogridError. + + Args: + microgrid: The partially validated microgrid object. + major_issues: List of major issues found during validation. + minor_issues: List of minor issues found during validation. + raw_message: The protobuf message that failed validation. + """ + super().__init__( + major_issues=major_issues, + minor_issues=minor_issues, + raw_message=raw_message, + ) + self.microgrid = microgrid + + +class InvalidElectricalComponentError(ValidationError): + """Raised when a single electrical component has validation issues.""" + + component: ElectricalComponent + """The partially validated electrical component.""" + + def __init__( + self, + *, + component: ElectricalComponent, + major_issues: list[str], + minor_issues: list[str], + raw_message: Any, + ) -> None: + """Create a new InvalidElectricalComponentError. + + Args: + component: The partially validated electrical component. + major_issues: List of major issues found during validation. + minor_issues: List of minor issues found during validation. + raw_message: The protobuf message that failed validation. + """ + super().__init__( + major_issues=major_issues, + minor_issues=minor_issues, + raw_message=raw_message, + ) + self.component = component + + +class InvalidConnectionError(ValidationError): + """Raised when a single connection has validation issues.""" + + connection: ComponentConnection | None + """The partially validated connection, or None if completely invalid.""" + + def __init__( + self, + *, + connection: ComponentConnection | None, + major_issues: list[str], + minor_issues: list[str], + raw_message: Any, + ) -> None: + """Create a new InvalidConnectionError. + + Args: + connection: The partially validated connection, or None. + major_issues: List of major issues found during validation. + minor_issues: List of minor issues found during validation. + raw_message: The protobuf message that failed validation. + """ + super().__init__( + major_issues=major_issues, + minor_issues=minor_issues, + raw_message=raw_message, + ) + self.connection = connection + + +class ValidationErrorGroup(ValidationError, ExceptionGroup[ValidationError]): + """Base group of validation errors. + + Inherits from both + [ValidationError][frequenz.client.assets.exceptions.ValidationError] + and `ExceptionGroup`, so all validation error groups can be caught with + ``except ValidationError``. + """ + + def __new__( + cls, + message: str, + exceptions: Sequence[ValidationError], + ) -> Self: + """Create a new ValidationErrorGroup. + + Args: + message: The error message. + exceptions: The validation errors in this group. + + Returns: + The new exception group. + """ + instance = super().__new__(cls, message, exceptions) + instance.major_issues = [] + instance.minor_issues = [] + instance.raw_message = None + return instance + + def derive( # type: ignore[override] + self, excs: Sequence[ValidationError] + ) -> ValidationErrorGroup: + """Derive a new group from a subset of exceptions. + + Args: + excs: The subset of exceptions for the derived group. + + Returns: + A new exception group. + """ + return ValidationErrorGroup(self.message, excs) + + +class InvalidElectricalComponentErrorGroup( + ValidationErrorGroup, +): + """Raised when multiple electrical components have validation issues.""" + + valid_components: list[ElectricalComponent] + """The components that were successfully validated.""" + + def __new__( + cls, + *, + valid_components: list[ElectricalComponent], + exceptions: Sequence[InvalidElectricalComponentError], + ) -> InvalidElectricalComponentErrorGroup: + """Create a new InvalidElectricalComponentErrorGroup. + + Args: + valid_components: The components that passed validation. + exceptions: The validation errors for components that failed. + + Returns: + The new exception group. + """ + instance = super().__new__( + cls, + f"{len(exceptions)} electrical component(s) failed validation", + exceptions, + ) + instance.valid_components = valid_components + return instance + + def derive( # type: ignore[override] + self, excs: Sequence[InvalidElectricalComponentError] + ) -> InvalidElectricalComponentErrorGroup: + """Derive a new group from a subset of exceptions. + + Args: + excs: The subset of exceptions for the derived group. + + Returns: + A new exception group with the same valid components. + """ + return InvalidElectricalComponentErrorGroup( + valid_components=self.valid_components, + exceptions=excs, + ) + + +class InvalidConnectionErrorGroup(ValidationErrorGroup): + """Raised when multiple connections have validation issues.""" + + valid_connections: list[ComponentConnection] + """The connections that were successfully validated.""" + + def __new__( + cls, + *, + valid_connections: list[ComponentConnection], + exceptions: Sequence[InvalidConnectionError], + ) -> InvalidConnectionErrorGroup: + """Create a new InvalidConnectionErrorGroup. + + Args: + valid_connections: The connections that passed validation. + exceptions: The validation errors for connections that failed. + + Returns: + The new exception group. + """ + instance = super().__new__( + cls, + f"{len(exceptions)} connection(s) failed validation", + exceptions, + ) + instance.valid_connections = valid_connections + return instance + + def derive( # type: ignore[override] + self, excs: Sequence[InvalidConnectionError] + ) -> InvalidConnectionErrorGroup: + """Derive a new group from a subset of exceptions. + + Args: + excs: The subset of exceptions for the derived group. + + Returns: + A new exception group with the same valid connections. + """ + return InvalidConnectionErrorGroup( + valid_connections=self.valid_connections, + exceptions=excs, + )