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
2 changes: 2 additions & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@
* Added new exceptions:

* `frequenz.client.common.ClientCommonError` as a base exception for the package.
* `frequenz.client.common.InvalidAttributeError` as a base for all exceptions raised when an invalid attribute is encountered. Inherits also from `ValueError` for convenience.
* `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.

Expand Down
4 changes: 4 additions & 0 deletions src/frequenz/client/common/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,16 @@

from ._exception import (
ClientCommonError,
InvalidAttributeError,
MissingFieldError,
UnrecognizedEnumValueError,
UnspecifiedEnumValueError,
)

__all__ = [
"ClientCommonError",
"InvalidAttributeError",
"MissingFieldError",
"UnrecognizedEnumValueError",
"UnspecifiedEnumValueError",
]
103 changes: 99 additions & 4 deletions src/frequenz/client/common/_exception.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -19,25 +49,90 @@ 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 {instance}"
),
)


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
[`UnrecognizedEnumValueError`][..UnrecognizedEnumValueError].

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}"
),
)


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"missing protobuf field {attr_name!r} in {instance}"
),
)
10 changes: 2 additions & 8 deletions src/frequenz/client/common/grid/_delivery_area.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,16 +119,10 @@ 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)
17 changes: 4 additions & 13 deletions src/frequenz/client/common/metrics/_sample.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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)
7 changes: 5 additions & 2 deletions src/frequenz/client/common/microgrid/_microgrid.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,11 +93,14 @@ 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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 "
Expand All @@ -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 "
Expand Down
4 changes: 4 additions & 0 deletions tests/exception/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# License: MIT
# Copyright © 2026 Frequenz Energy-as-a-Service GmbH

"""Tests for the common exception hierarchy."""
25 changes: 25 additions & 0 deletions tests/exception/test_client_common_error.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# 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


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",
"MissingFieldError",
"UnrecognizedEnumValueError",
"UnspecifiedEnumValueError",
):
assert name in frequenz.client.common.__all__
33 changes: 33 additions & 0 deletions tests/exception/test_invalid_attribute_error.py
Original file line number Diff line number Diff line change
@@ -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"
38 changes: 38 additions & 0 deletions tests/exception/test_missing_field_error.py
Original file line number Diff line number Diff line change
@@ -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 `missing protobuf field ...` template."""
assert (
str(MissingFieldError("some-instance", "field_x"))
== "missing protobuf field 'field_x' in some-instance"
)


def test_custom_message_replaces_the_default() -> None:
"""A custom message replaces the default entirely."""
assert str(MissingFieldError("i", "a", "explicit msg")) == "explicit msg"
Loading
Loading