diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 51c99511..084c6c96 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -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. diff --git a/src/frequenz/client/common/__init__.py b/src/frequenz/client/common/__init__.py index 302376ce..0580c58d 100644 --- a/src/frequenz/client/common/__init__.py +++ b/src/frequenz/client/common/__init__.py @@ -5,12 +5,16 @@ from ._exception import ( ClientCommonError, + InvalidAttributeError, + MissingFieldError, UnrecognizedEnumValueError, UnspecifiedEnumValueError, ) __all__ = [ "ClientCommonError", + "InvalidAttributeError", + "MissingFieldError", "UnrecognizedEnumValueError", "UnspecifiedEnumValueError", ] diff --git a/src/frequenz/client/common/_exception.py b/src/frequenz/client/common/_exception.py index d7fb21b0..dd7317b4 100644 --- a/src/frequenz/client/common/_exception.py +++ b/src/frequenz/client/common/_exception.py @@ -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 @@ -19,21 +49,33 @@ 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 @@ -41,3 +83,56 @@ class UnspecifiedEnumValueError(ClientCommonError, ValueError): 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}" + ), + ) diff --git a/src/frequenz/client/common/grid/_delivery_area.py b/src/frequenz/client/common/grid/_delivery_area.py index 4dd4e990..5cabaa4d 100644 --- a/src/frequenz/client/common/grid/_delivery_area.py +++ b/src/frequenz/client/common/grid/_delivery_area.py @@ -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) diff --git a/src/frequenz/client/common/metrics/_sample.py b/src/frequenz/client/common/metrics/_sample.py index 99eddfda..71dfb515 100644 --- a/src/frequenz/client/common/metrics/_sample.py +++ b/src/frequenz/client/common/metrics/_sample.py @@ -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) @@ -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) diff --git a/src/frequenz/client/common/microgrid/_microgrid.py b/src/frequenz/client/common/microgrid/_microgrid.py index 21d6641c..d3a0788c 100644 --- a/src/frequenz/client/common/microgrid/_microgrid.py +++ b/src/frequenz/client/common/microgrid/_microgrid.py @@ -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) diff --git a/src/frequenz/client/common/microgrid/electrical_components/_electrical_component.py b/src/frequenz/client/common/microgrid/electrical_components/_electrical_component.py index 6d458583..1c06514f 100644 --- a/src/frequenz/client/common/microgrid/electrical_components/_electrical_component.py +++ b/src/frequenz/client/common/microgrid/electrical_components/_electrical_component.py @@ -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 " @@ -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 " diff --git a/tests/exception/__init__.py b/tests/exception/__init__.py new file mode 100644 index 00000000..691c2190 --- /dev/null +++ b/tests/exception/__init__.py @@ -0,0 +1,4 @@ +# License: MIT +# Copyright © 2026 Frequenz Energy-as-a-Service GmbH + +"""Tests for the common exception hierarchy.""" diff --git a/tests/exception/test_client_common_error.py b/tests/exception/test_client_common_error.py new file mode 100644 index 00000000..2c3ed4ee --- /dev/null +++ b/tests/exception/test_client_common_error.py @@ -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__ diff --git a/tests/exception/test_invalid_attribute_error.py b/tests/exception/test_invalid_attribute_error.py new file mode 100644 index 00000000..0a6b56ba --- /dev/null +++ b/tests/exception/test_invalid_attribute_error.py @@ -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" diff --git a/tests/exception/test_missing_field_error.py b/tests/exception/test_missing_field_error.py new file mode 100644 index 00000000..a4146e77 --- /dev/null +++ b/tests/exception/test_missing_field_error.py @@ -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" diff --git a/tests/exception/test_unrecognized_enum_value_error.py b/tests/exception/test_unrecognized_enum_value_error.py new file mode 100644 index 00000000..87b17ac1 --- /dev/null +++ b/tests/exception/test_unrecognized_enum_value_error.py @@ -0,0 +1,41 @@ +# License: MIT +# Copyright © 2026 Frequenz Energy-as-a-Service GmbH + +"""Tests for `UnrecognizedEnumValueError`.""" + +from frequenz.client.common import ( + ClientCommonError, + InvalidAttributeError, + UnrecognizedEnumValueError, +) + + +def test_inherits_invalid_attribute_error() -> None: + """`UnrecognizedEnumValueError` inherits `InvalidAttributeError` (and thus `ValueError`).""" + assert issubclass(UnrecognizedEnumValueError, InvalidAttributeError) + assert issubclass(UnrecognizedEnumValueError, ClientCommonError) + assert issubclass(UnrecognizedEnumValueError, ValueError) + + +def test_stores_instance_attr_name_and_value() -> None: + """`UnrecognizedEnumValueError` stores `instance`, `attr_name`, and `value` as attributes.""" + instance = object() + error = UnrecognizedEnumValueError(instance, "attr_x", 999) + assert error.instance is instance + assert error.attr_name == "attr_x" + assert error.value == 999 + + +def test_default_message() -> None: + """The default message follows the `unrecognized enum value ...` template.""" + assert ( + str(UnrecognizedEnumValueError("some-instance", "attr_x", 7)) + == "unrecognized enum value 7 for attribute 'attr_x' in some-instance" + ) + + +def test_custom_message_replaces_the_default() -> None: + """A custom message replaces the default entirely.""" + assert ( + str(UnrecognizedEnumValueError("i", "a", 7, "explicit msg")) == "explicit msg" + ) diff --git a/tests/exception/test_unspecified_enum_value_error.py b/tests/exception/test_unspecified_enum_value_error.py new file mode 100644 index 00000000..adbf156a --- /dev/null +++ b/tests/exception/test_unspecified_enum_value_error.py @@ -0,0 +1,38 @@ +# License: MIT +# Copyright © 2026 Frequenz Energy-as-a-Service GmbH + +"""Tests for `UnspecifiedEnumValueError`.""" + +from frequenz.client.common import ( + ClientCommonError, + InvalidAttributeError, + UnspecifiedEnumValueError, +) + + +def test_inherits_invalid_attribute_error() -> None: + """`UnspecifiedEnumValueError` inherits `InvalidAttributeError` (and thus `ValueError`).""" + assert issubclass(UnspecifiedEnumValueError, InvalidAttributeError) + assert issubclass(UnspecifiedEnumValueError, ClientCommonError) + assert issubclass(UnspecifiedEnumValueError, ValueError) + + +def test_stores_instance_and_attr_name() -> None: + """`UnspecifiedEnumValueError` stores its `instance` and `attr_name` args.""" + instance = object() + error = UnspecifiedEnumValueError(instance, "attr_x") + assert error.instance is instance + assert error.attr_name == "attr_x" + + +def test_default_message() -> None: + """The default message follows the `unspecified enum value ...` template.""" + assert ( + str(UnspecifiedEnumValueError("some-instance", "attr_x")) + == "unspecified enum 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(UnspecifiedEnumValueError("i", "a", "explicit msg")) == "explicit msg" diff --git a/tests/test_exception.py b/tests/test_exception.py deleted file mode 100644 index 0308cc6b..00000000 --- a/tests/test_exception.py +++ /dev/null @@ -1,44 +0,0 @@ -"""Tests for common exceptions.""" - -import frequenz.client.common -from frequenz.client.common import ( - ClientCommonError, - UnrecognizedEnumValueError, - UnspecifiedEnumValueError, -) - - -def test_exceptions_exported_and_related() -> None: - """Given exception exports, then their hierarchy and string form are correct.""" - assert issubclass(UnspecifiedEnumValueError, ClientCommonError) - assert issubclass(UnspecifiedEnumValueError, ValueError) - assert issubclass(UnrecognizedEnumValueError, ClientCommonError) - assert issubclass(UnrecognizedEnumValueError, ValueError) - assert not issubclass(ClientCommonError, ValueError) - assert str(UnspecifiedEnumValueError("msg")) == "msg" - - -def test_all_is_sorted_and_exports_unrecognized() -> None: - """Given the package exports, then __all__ includes the new error and stays sorted.""" - assert "UnrecognizedEnumValueError" in frequenz.client.common.__all__ - assert list(frequenz.client.common.__all__) == sorted( - frequenz.client.common.__all__ - ) - - -def test_unrecognized_enum_value_error_carries_value() -> None: - """Given an unrecognized value, then it is stored and catchable as both base types.""" - error = UnrecognizedEnumValueError(999) - assert error.value == 999 - assert isinstance(error, ValueError) - assert isinstance(error, ClientCommonError) - - -def test_unrecognized_enum_value_error_default_message() -> None: - """Given no explicit message, then the default string contains the raw value.""" - assert "7" in str(UnrecognizedEnumValueError(7)) - - -def test_unrecognized_enum_value_error_custom_message() -> None: - """Given a custom message, then the string form is exactly that message.""" - assert str(UnrecognizedEnumValueError(7, "msg")) == "msg"