diff --git a/src/frequenz/client/assets/_client.py b/src/frequenz/client/assets/_client.py index 23b39ff..b090e7b 100644 --- a/src/frequenz/client/assets/_client.py +++ b/src/frequenz/client/assets/_client.py @@ -18,12 +18,23 @@ 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, + InvalidElectricalComponentError, + InvalidMicrogridError, +) DEFAULT_GRPC_CALL_TIMEOUT = 60.0 """The default timeout for gRPC calls made by this client (in seconds).""" @@ -88,14 +99,21 @@ 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) - self, microgrid_id: MicrogridId + async def get_microgrid( # noqa: DOC502,DOC503 (raises indirectly) + 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 an + [InvalidMicrogridError][frequenz.client.assets.exceptions.InvalidMicrogridError] + when major validation issues are found instead of just + logging them. Returns: The details of the microgrid. @@ -103,6 +121,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, @@ -113,19 +133,48 @@ async def get_microgrid( # noqa: DOC502 (raises ApiClientError indirectly) method_name="GetMicrogrid", ) + 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, 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 an + `ExceptionGroup[InvalidElectricalComponentError]` + when major validation issues are found in any component instead + of just logging them. Returns: The electrical components of the microgrid. + + Raises: + ExceptionGroup: If `raise_on_errors` is True and major validation + issues are found. All exceptions in the group are + [InvalidElectricalComponentError][frequenz.client.assets.exceptions.InvalidElectricalComponentError]. """ response = await call_stub_method( self, @@ -138,6 +187,35 @@ 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 ExceptionGroup( + f"{len(exceptions)} electrical component(s) failed validation", + exceptions, + ) + return components + return [ electrical_component_proto(component) for component in response.components ] @@ -147,7 +225,9 @@ async def list_microgrid_electrical_component_connections( microgrid_id: MicrogridId, source_component_ids: Iterable[ElectricalComponentId] = (), destination_component_ids: Iterable[ElectricalComponentId] = (), - ) -> list[ComponentConnection | None]: + *, + raise_on_errors: bool = False, + ) -> list[ComponentConnection]: """ Get the electrical component connections of a microgrid. @@ -158,9 +238,18 @@ 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 an + `ExceptionGroup[InvalidConnectionError]` + when major validation issues are found in any connection instead + of just logging them. Returns: The electrical component connections of the microgrid. + + Raises: + ExceptionGroup: If `raise_on_errors` is True and major validation + issues are found. All exceptions in the group are + [InvalidConnectionError][frequenz.client.assets.exceptions.InvalidConnectionError]. """ request = assets_pb2.ListMicrogridElectricalComponentConnectionsRequest( microgrid_id=int(microgrid_id), @@ -177,9 +266,37 @@ async def list_microgrid_electrical_component_connections( method_name="ListMicrogridElectricalComponentConnections", ) - return list( - map( + if raise_on_errors: + valid_connections: list[ComponentConnection] = [] + 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: + valid_connections.append(connection) + if exceptions: + raise ExceptionGroup( + f"{len(exceptions)} connection(s) failed validation", + exceptions, + ) + return valid_connections + + return [ + c + for c in map( component_connection_from_proto, filter(bool, response.connections), ) - ) + if c is not None + ] diff --git a/src/frequenz/client/assets/_microgrid_proto.py b/src/frequenz/client/assets/_microgrid_proto.py index 7e59682..18f2c5a 100644 --- a/src/frequenz/client/assets/_microgrid_proto.py +++ b/src/frequenz/client/assets/_microgrid_proto.py @@ -32,6 +32,46 @@ def microgrid_from_proto(message: microgrid_pb2.Microgrid) -> Microgrid: 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) @@ -54,20 +94,6 @@ def microgrid_from_proto(message: microgrid_pb2.Microgrid) -> Microgrid: elif isinstance(status, int): major_issues.append("status is unrecognized") - 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( id=MicrogridId(message.id), enterprise_id=EnterpriseId(message.enterprise_id), diff --git a/src/frequenz/client/assets/exceptions.py b/src/frequenz/client/assets/exceptions.py index 9036862..2b1bae4 100644 --- a/src/frequenz/client/assets/exceptions.py +++ b/src/frequenz/client/assets/exceptions.py @@ -3,6 +3,10 @@ """Exceptions raised by the assets API client.""" +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + from frequenz.client.base.exception import ( ApiClientError, ClientNotConnected, @@ -26,6 +30,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", @@ -35,6 +44,9 @@ "GrpcError", "InternalError", "InvalidArgument", + "InvalidConnectionError", + "InvalidElectricalComponentError", + "InvalidMicrogridError", "OperationAborted", "OperationCancelled", "OperationNotImplemented", @@ -47,5 +59,137 @@ "ServiceUnavailable", "UnknownError", "UnrecognizedGrpcStatus", - "PermissionDenied", + "ValidationError", ] + + +class ValidationError(Exception): + """Base error for protobuf message validation failures. + + 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 found during validation.""" + + minor_issues: list[str] + """List of minor issues found during validation.""" + + raw_message: Any + """The original protobuf message that was being validated.""" + + def __init__( + self, + *, + major_issues: list[str], + minor_issues: list[str], + raw_message: Any, + ) -> None: + """Create a new ValidationError. + + Args: + 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) + message = ( + f"Validation failed: {issues_summary}" + if issues_summary + else "Validation failed" + ) + super().__init__(message) + 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