Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 29 additions & 10 deletions src/frequenz/client/assets/_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)
Copy link
Contributor

@llucax llucax Jan 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do the error handling here instead:

if raise_on_errors:
    major_issues = []
    minor_issues = []
    microgrid = microgrid_from_proto_with_issues(response.microgrid, major_issues, minor_issues)
    # For now I would ignore minor_issues, I have the feeling we should only have
    # "errors" in the future, this minor/minor distinction is not really useful
    if major_issues:
        raise InvalidMicrogridError(microgrid, response.microgrid, major_errors, minor_errors)
    return microgrid
else:
    microgrid_from_proto(response.microgrid, major_issues)

EDIT: Updated example to only call xxx_with_issues() if `raise_on_errors is true.

I would use InvalidMicrogridError rather than ParseError as we are not really parsing anything here, protobuf messages are already parsed and have no problem at the protobuf-level, is just some validations we are doing that don't pass.

In the future we might even change the argument for something like on_validation_error=LOG|RAISE_ONCE|RAISE_ALL|IGNORE (for now I think we can keep it simple and leave the 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.
Expand All @@ -139,14 +153,17 @@ 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
]
Comment on lines 155 to 158
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same, but we could use a ExceptionGroup to raise many exceptions:

components = []
exceptions = []
for component_pb in response.components:
    major_issues = []  # minor_issues...
    component = electrical_component_from_proto_with_issues(component_pb, major_issues, ...)
    if major_issues:
        exceptions.append(
            InvalidElectricalComponentError(component, component_pb, major_issues, ...)
        )
    else:
        components.append(component)

if exceptions:
    raise InvalidElectricalComponentErrorGroup(valid_components=components, exceptions)

return components


async def list_microgrid_electrical_component_connections(
self,
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.
Expand All @@ -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.
Expand All @@ -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)
]
18 changes: 17 additions & 1 deletion src/frequenz/client/assets/_microgrid_proto.py
Original file line number Diff line number Diff line change
Expand Up @@ -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] = []
Expand Down Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,30 @@

from .._lifetime import Lifetime
from .._lifetime_proto import lifetime_from_proto
from ..exceptions import ParsingError
from ._connection import ComponentConnection

_logger = logging.getLogger(__name__)


def component_connection_from_proto(
message: electrical_components_pb2.ElectricalComponentConnection,
*,
raise_on_errors: bool = False,
Comment on lines +23 to +24
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wouldn't add this here, I would add this at the client level.

) -> 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] = []

Expand All @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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] = []
Expand All @@ -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),
Expand Down
41 changes: 40 additions & 1 deletion src/frequenz/client/assets/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@

"""Exceptions raised by the assets API client."""

from typing import Any

from frequenz.client.base.exception import (
ApiClientError,
ClientNotConnected,
Expand Down Expand Up @@ -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
Loading