diff --git a/src/frequenz/client/assets/_client.py b/src/frequenz/client/assets/_client.py index 23b39ff..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,14 +101,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 +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, @@ -113,19 +135,47 @@ 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 + [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, @@ -138,6 +188,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 InvalidElectricalComponentErrorGroup( + valid_components=components, + exceptions=exceptions, + ) + return components + return [ electrical_component_proto(component) for component in response.components ] @@ -147,6 +226,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,9 +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 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), @@ -177,6 +266,32 @@ async def list_microgrid_electrical_component_connections( method_name="ListMicrogridElectricalComponentConnections", ) + 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, 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..929de37 100644 --- a/src/frequenz/client/assets/exceptions.py +++ b/src/frequenz/client/assets/exceptions.py @@ -3,6 +3,11 @@ """Exceptions raised by the assets API client.""" +from __future__ import annotations + +from collections.abc import Sequence +from typing import TYPE_CHECKING, Any, Self + from frequenz.client.base.exception import ( ApiClientError, ClientNotConnected, @@ -26,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", @@ -35,6 +45,11 @@ "GrpcError", "InternalError", "InvalidArgument", + "InvalidConnectionError", + "InvalidConnectionErrorGroup", + "InvalidElectricalComponentError", + "InvalidElectricalComponentErrorGroup", + "InvalidMicrogridError", "OperationAborted", "OperationCancelled", "OperationNotImplemented", @@ -47,5 +62,270 @@ "ServiceUnavailable", "UnknownError", "UnrecognizedGrpcStatus", - "PermissionDenied", + "ValidationError", + "ValidationErrorGroup", ] + + +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) + 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, + )