diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 5e747f4..5f0b32c 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -9,3 +9,5 @@ This is the initial release, extracted from the [SDK v1.0.0rc601](https://github - Added support for `__round__` (`round(quantity)`), `__pos__` (`+quantity`) and `__mod__` (`quantity % quantity`) operators. - Add `ReactivePower` quantity. - Add `ApparentPower` quantity. +- Add marshmallow module available when adding `[marshmallow]` to the requirements. + - Add a QuantitySchema supporting string/float based serialization and deserialization of most quantities (except for `ReactivePower` and `ApparentPower`). diff --git a/pyproject.toml b/pyproject.toml index cbab3b5..ef08a4f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,13 +65,13 @@ dev-mypy = [ "mypy == 1.11.2", "types-Markdown == 3.7.0.20240822", # For checking the noxfile, docs/ script, and tests - "frequenz-quantities[dev-mkdocs,dev-noxfile,dev-pytest]", + "frequenz-quantities[dev-mkdocs,dev-noxfile,dev-pytest,marshmallow]", ] dev-noxfile = ["nox == 2024.4.15", "frequenz-repo-config[lib] == 0.10.0"] dev-pylint = [ # dev-pytest already defines a dependency to pylint because of the examples # For checking the noxfile, docs/ script, and tests - "frequenz-quantities[dev-mkdocs,dev-noxfile,dev-pytest]", + "frequenz-quantities[dev-mkdocs,dev-noxfile,dev-pytest,marshmallow]", ] dev-pytest = [ "pytest == 8.3.3", @@ -81,9 +81,16 @@ dev-pytest = [ "pytest-asyncio == 0.24.0", "async-solipsism == 0.7", "hypothesis == 6.112.2", + "frequenz-quantities[marshmallow]", ] + +marshmallow = [ + "marshmallow >= 3.0.0, < 4", + "marshmallow-dataclass >= 8.0.0, < 9", +] + dev = [ - "frequenz-quantities[dev-mkdocs,dev-flake8,dev-formatting,dev-mkdocs,dev-mypy,dev-noxfile,dev-pylint,dev-pytest]", + "frequenz-quantities[dev-mkdocs,dev-flake8,dev-formatting,dev-mkdocs,dev-mypy,dev-noxfile,dev-pylint,dev-pytest,marshmallow]", ] [project.urls] diff --git a/src/frequenz/quantities/__init__.py b/src/frequenz/quantities/__init__.py index f282f0d..e4dcd2f 100644 --- a/src/frequenz/quantities/__init__.py +++ b/src/frequenz/quantities/__init__.py @@ -32,6 +32,10 @@ - [Temperature][frequenz.quantities.Temperature]: A quantity representing temperature. - [Voltage][frequenz.quantities.Voltage]: A quantity representing electric voltage. +Additionally, for each of those types, there is a corresponding marshmallow +field that can be used to serialize and deserialize the quantities using the +[QuantitySchema][frequenz.quantities.marshmallow.QuantitySchema] schema. + There is also the unitless [Quantity][frequenz.quantities.Quantity] class. All quantities are subclasses of this class and it can be used as a base to create new quantities. Using the `Quantity` class directly is discouraged, as it doesn't provide diff --git a/src/frequenz/quantities/marshmallow.py b/src/frequenz/quantities/marshmallow.py new file mode 100644 index 0000000..8a1ad12 --- /dev/null +++ b/src/frequenz/quantities/marshmallow.py @@ -0,0 +1,235 @@ +# License: All rights reserved +# Copyright © 2024 Frequenz Energy-as-a-Service GmbH + +"""Custom marshmallow fields and schema.""" + +from typing import Any, Type + +from marshmallow import Schema, ValidationError, fields + +from ._current import Current +from ._energy import Energy +from ._frequency import Frequency +from ._percentage import Percentage +from ._power import Power +from ._quantity import Quantity +from ._temperature import Temperature +from ._voltage import Voltage + + +class _QuantityField(fields.Field): + """Custom field for Quantity objects supporting per-field serialization configuration. + + This class handles serialization and deserialization of ALL Quantity + subclasses. + The specific Quantity subclass is determined by the field_type attribute. + + * Deserialization auto-detects the type of deserialization (float or string) + based on the input type. + * Serialization uses either the schema's default or the per-field + configuration found in the metadata. + + We need distinct QuantityField subclasses for each Quantity subclass, so + they can be used in the TYPE_MAPPING in the `QuantitySchema`. + Which means this class is not intended to be used directly. + + Instead, we use the specific QuantityField subclasses for each Quantity. + Each field subclass simply sets the field_type attribute to the corresponding + Quantity subclass. + + Those subclasses are generated and stored in the QUANTITY_FIELD_CLASSES + mapping and are used for the TYPE_MAPPING in the `QuantitySchema`. + """ + + field_type: Type[Quantity] | None = None + """The specific Quantity subclass.""" + + def _serialize( + self, value: Quantity, attr: str | None, obj: Any, **kwargs: Any + ) -> Any: + """Serialize the Quantity object based on per-field configuration.""" + if self.field_type is None or not issubclass(self.field_type, Quantity): + raise TypeError( + "field_type must be set to a Quantity subclass in the subclass." + ) + + assert self.parent is not None + + # Determine the serialization format + serialize_as_string = self.metadata.get( + "serialize_as_string", + self.parent.context.get("serialize_as_string_default", False), + ) + + if serialize_as_string: + # Use the Quantity's native string representation (includes unit) + return str(value) + + # Serialize as float using the Quantity's base value + return value.base_value + + def _deserialize( + self, value: Any, attr: str | None, data: Any, **kwargs: Any + ) -> Quantity: + """Deserialize the Quantity object from float or string.""" + if self.field_type is None or not issubclass(self.field_type, Quantity): + raise TypeError( + "field_type must be set to a Quantity subclass in the subclass." + ) + + if isinstance(value, str): + # Use the Quantity's from_string method + return self.field_type.from_string(value) + if isinstance(value, (float, int)): + # Use `_new` method for creating instance from base value + return self.field_type._new( # pylint: disable=protected-access + float(value) + ) + + raise ValidationError("Invalid input type for QuantityField.") + + +_QUANTITY_SUBCLASSES = [ + Current, + Energy, + Frequency, + Percentage, + Power, + Temperature, + Voltage, +] + + +class CurrentField(_QuantityField): + """Custom field for Current objects.""" + + field_type = Current + + +class EnergyField(_QuantityField): + """Custom field for Energy objects.""" + + field_type = Energy + + +class FrequencyField(_QuantityField): + """Custom field for Frequency objects.""" + + field_type = Frequency + + +class PercentageField(_QuantityField): + """Custom field for Percentage objects.""" + + field_type = Percentage + + +class PowerField(_QuantityField): + """Custom field for Power objects.""" + + field_type = Power + + +class TemperatureField(_QuantityField): + """Custom field for Temperature objects.""" + + field_type = Temperature + + +class VoltageField(_QuantityField): + """Custom field for Voltage objects.""" + + field_type = Voltage + + +QUANTITY_FIELD_CLASSES: dict[type[Quantity], type[fields.Field]] = { + Current: CurrentField, + Energy: EnergyField, + Frequency: FrequencyField, + Percentage: PercentageField, + Power: PowerField, + Temperature: TemperatureField, + Voltage: VoltageField, +} +"""Mapping of Quantity subclasses to their corresponding QuantityField subclasses. + +This mapping is used in the `QuantitySchema` to determine the correct field +class for each Quantity subclass. + +The keys are Quantity subclasses (e.g., Percentage, Energy) and the values are +the corresponding QuantityField subclasses. +""" + + +class QuantitySchema(Schema): + """A schema for quantities. + + Example usage: + + ```python + from dataclasses import dataclass, field + from marshmallow_dataclass import class_schema + from marshmallow.validate import Range + from frequenz.quantities import Percentage, QuantitySchema + from typing import cast + + @dataclass + class Config: + percentage_always_as_string: Percentage = field( + default_factory=lambda: Percentage.from_percent(25.0), + metadata={ + "metadata": { + "description": "A percentage field", + }, + "validate": Range(Percentage.zero(), Percentage.from_percent(100.0)), + "serialize_as_string": True, + }, + ) + + percentage_always_as_float: Percentage = field( + default_factory=lambda: Percentage.from_percent(25.0), + metadata={ + "metadata": { + "description": "A percentage field", + }, + "validate": Range(Percentage.zero(), Percentage.from_percent(100.0)), + "serialize_as_string": False, + }, + ) + + percentage_serialized_as_schema_default: Percentage = field( + default_factory=lambda: Percentage.from_percent(25.0), + metadata={ + "metadata": { + "description": "A percentage field", + }, + "validate": Range(Percentage.zero(), Percentage.from_percent(100.0)), + }, + ) + + @classmethod + def load(cls, config: dict[str, Any]) -> "Config": + schema = class_schema(cls, base_schema=QuantitySchema)( + serialize_as_string_default=True # type: ignore[call-arg] + ) + return cast(Config, schema.load(config)) + ``` + """ + + TYPE_MAPPING: dict[type[Quantity], type[fields.Field]] = QUANTITY_FIELD_CLASSES + + def __init__( + self, *args: Any, serialize_as_string_default: bool = False, **kwargs: Any + ) -> None: + """ + Initialize the schema with a default serialization format. + + Args: + *args: Additional positional arguments. + serialize_as_string_default: Default serialization format for quantities. + If True, quantities are serialized as strings with units. + If False, quantities are serialized as floats. + **kwargs: Additional keyword arguments. + """ + super().__init__(*args, **kwargs) + self.context["serialize_as_string_default"] = serialize_as_string_default diff --git a/tests/test_marshmallow.py b/tests/test_marshmallow.py new file mode 100644 index 0000000..777ea27 --- /dev/null +++ b/tests/test_marshmallow.py @@ -0,0 +1,188 @@ +# License: MIT +# Copyright © 2024 Frequenz Energy-as-a-Service GmbH + +"""Test marshmallow fields and schema.""" + + +from dataclasses import dataclass, field +from typing import Any, Self, cast + +from marshmallow_dataclass import class_schema + +from frequenz.quantities import Energy, Percentage, Power, Temperature, Voltage +from frequenz.quantities.marshmallow import QuantitySchema + + +@dataclass +class Config: + """Configuration test class.""" + + my_percent_field: Percentage = field( + default_factory=lambda: Percentage.from_percent(25.0), + metadata={ + "metadata": { + "description": "A percentage field", + }, + }, + ) + + my_power_field: Power = field( + default_factory=lambda: Power.from_watts(100.0), + metadata={ + "metadata": { + "description": "A power field", + }, + }, + ) + + my_energy_field: Energy = field( + default_factory=lambda: Energy.from_watt_hours(100.0), + metadata={ + "metadata": { + "description": "An energy field", + }, + }, + ) + + voltage_always_string: Voltage = field( + default_factory=lambda: Voltage.from_kilovolts(200.0), + metadata={ + "metadata": { + "description": "A voltage field that is always serialized as a string", + "serialize_as_string": True, + }, + }, + ) + + temp_never_string: Temperature = field( + default_factory=lambda: Temperature.from_celsius(100.0), + metadata={ + "metadata": { + "description": "A temperature field that is never serialized as a string", + "serialize_as_string": False, + }, + }, + ) + + @classmethod + def load(cls, config: dict[str, Any]) -> Self: + """Load the configuration.""" + schema = class_schema(cls, base_schema=QuantitySchema)() + return cast(Self, schema.load(config)) + + def dump(self, serialize_as_string_default: bool = False) -> dict[str, Any]: + """Dump the configuration.""" + schema = class_schema(Config, base_schema=QuantitySchema)( + serialize_as_string_default=serialize_as_string_default # type: ignore[call-arg] + ) + return cast(dict[str, Any], schema.dump(self)) + + +def test_config_schema_load() -> None: + """Test that the values are correctly loaded.""" + config = Config.load( + { + "my_percent_field": 50.0, + "my_power_field": 200.0, + "my_energy_field": 200.0, + "voltage_always_string": 250_000.0, + "temp_never_string": 100.0, + } + ) + + assert config.my_percent_field == Percentage.from_percent(50.0) + assert config.my_power_field == Power.from_watts(200.0) + assert config.my_energy_field == Energy.from_watt_hours(200.0) + assert config.voltage_always_string == Voltage.from_kilovolts(250.0) + assert config.temp_never_string == Temperature.from_celsius(100.0) + + +def test_config_schema_load_defaults() -> None: + """Test that the defaults are correctly loaded.""" + config = Config.load({}) + + assert config.my_percent_field == Percentage.from_percent(25.0) + assert config.my_power_field == Power.from_watts(100.0) + assert config.my_energy_field == Energy.from_watt_hours(100.0) + assert config.voltage_always_string == Voltage.from_kilovolts(200) + assert config.temp_never_string == Temperature.from_celsius(100.0) + + +def test_config_schema_load_from_string() -> None: + """Test that the values are correctly loaded from string.""" + config = Config.load( + { + "my_percent_field": "50 %", + "my_power_field": "200 W", + "my_energy_field": "200 Wh", + "voltage_always_string": "250 kV", + "temp_never_string": "10 °C", + } + ) + + assert config.my_percent_field == Percentage.from_percent(50.0) + assert config.my_power_field == Power.from_watts(200.0) + assert config.my_energy_field == Energy.from_watt_hours(200.0) + assert config.voltage_always_string == Voltage.from_kilovolts(250.0) + assert config.temp_never_string == Temperature.from_celsius(10.0) + + +def test_config_schema_load_from_mixed() -> None: + """Test that the values are correctly loaded from mixed.""" + config = Config.load( + { + "my_percent_field": "50 %", + "my_power_field": 200, + "my_energy_field": "200 Wh", + "voltage_always_string": 250_000, + "temp_never_string": "10 °C", + } + ) + + assert config.my_percent_field == Percentage.from_percent(50.0) + assert config.my_power_field == Power.from_watts(200.0) + assert config.my_energy_field == Energy.from_watt_hours(200.0) + assert config.voltage_always_string == Voltage.from_kilovolts(250.0) + assert config.temp_never_string == Temperature.from_celsius(10.0) + + +def test_config_schema_dump_default_float() -> None: + """Test that the values are correctly dumped.""" + config = Config( + my_percent_field=Percentage.from_percent(50.0), + my_power_field=Power.from_watts(200.0), + my_energy_field=Energy.from_watt_hours(200.0), + voltage_always_string=Voltage.from_kilovolts(250.0), + temp_never_string=Temperature.from_celsius(10.0), + ) + + dumped = config.dump(serialize_as_string_default=False) + + assert dumped == { + "my_percent_field": 50.0, + "my_power_field": 200.0, + "my_energy_field": 200.0, + "voltage_always_string": "250 kV", + "temp_never_string": 10.0, + } + + +def test_config_schema_dump_default_string() -> None: + """Test that the values are correctly dumped.""" + config = Config( + my_percent_field=Percentage.from_percent(50.0), + my_power_field=Power.from_watts(200.0), + my_energy_field=Energy.from_watt_hours(200.0), + voltage_always_string=Voltage.from_kilovolts(250.0), + temp_never_string=Temperature.from_celsius(10.0), + ) + + dumped = config.dump(serialize_as_string_default=True) + + assert dumped == { + "my_percent_field": "50 %", + "my_power_field": "200 W", + "my_energy_field": "200 Wh", + "voltage_always_string": "250 kV", + "temp_never_string": 10.0, + }