From 8cca58a860b12a73b5283e588dd856d466677dd0 Mon Sep 17 00:00:00 2001 From: "Mathias L. Baumann" Date: Tue, 23 Dec 2025 10:53:39 +0100 Subject: [PATCH] Add backwards-compatible marshmallow 4.x support - Update marshmallow dependency to >= 4.0.0, < 5 - Use ContextVar for serialize_as_string_default instead of constructor param - Add backwards-compat __init__ that accepts old kwarg with DeprecationWarning - Add backwards-compat for serialize_as_string in field metadata Signed-off-by: Mathias L. Baumann --- RELEASE_NOTES.md | 47 ++++++++++++++++- pyproject.toml | 2 +- .../quantities/experimental/marshmallow.py | 29 +++++++++++ tests/experimental/test_marshmallow.py | 50 ++++++++++++++++++- 4 files changed, 124 insertions(+), 4 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 6a5f7fe..57c5c86 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -2,15 +2,58 @@ ## Summary - +This release adds support for marshmallow 4.x, dropping support for marshmallow 3.x. The old API is still supported but deprecated. ## Upgrading - `typing-extensions` minimal version was bumped to 4.6.0 to be compatible with Python3.12. You might need to upgrade it in your project too. +### Marshmallow 4.x required + +The `frequenz.quantities.experimental.marshmallow` module now requires marshmallow 4.x. Marshmallow 3.x is no longer supported. + +### Marshmallow module API changes + +The `frequenz.quantities.experimental.marshmallow` module has been updated for marshmallow 4.x. The old API is still supported but deprecated and will emit `DeprecationWarning`. + +#### `QuantitySchema` constructor parameter `serialize_as_string_default` is deprecated + +Old API (deprecated, still works): +```python +from marshmallow_dataclass import class_schema +from frequenz.quantities.experimental.marshmallow import QuantitySchema + +schema = class_schema(Config, base_schema=QuantitySchema)( + serialize_as_string_default=True +) +result = schema.dump(config_obj) +``` + +New API (recommended): +```python +from marshmallow_dataclass import class_schema +from frequenz.quantities.experimental.marshmallow import ( + QuantitySchema, + serialize_as_string_default, +) + +serialize_as_string_default.set(True) +schema = class_schema(Config, base_schema=QuantitySchema)() +result = schema.dump(config_obj) +serialize_as_string_default.set(False) # Reset if needed +``` + +### Why this change was necessary + +Marshmallow 4.0.0 introduced breaking changes including: +- `Field` is now a generic type (`Field[T]`) +- Changes to how schema context is accessed from fields + +The previous implementation relied on `self.parent.context` to access the `serialize_as_string_default` setting, which no longer works reliably with marshmallow 4.x. The new implementation uses Python's `contextvars.ContextVar` instead, which is a cleaner and more explicit approach. + ## New Features - +- Support for marshmallow 4.x (dropping 3.x support) ## Bug Fixes diff --git a/pyproject.toml b/pyproject.toml index e7232c2..9825251 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -85,7 +85,7 @@ dev-pytest = [ ] marshmallow = [ - "marshmallow >= 3.0.0, < 5", + "marshmallow >= 4.0.0, < 5", "marshmallow-dataclass >= 8.0.0, < 9", ] diff --git a/src/frequenz/quantities/experimental/marshmallow.py b/src/frequenz/quantities/experimental/marshmallow.py index 9bb28e7..32ed636 100644 --- a/src/frequenz/quantities/experimental/marshmallow.py +++ b/src/frequenz/quantities/experimental/marshmallow.py @@ -14,6 +14,7 @@ even in minor or patch releases. """ +import warnings from contextvars import ContextVar from typing import Any, Type @@ -76,6 +77,12 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: self.serialize_as_string_override = kwargs.pop("serialize_as_string", None) super().__init__(*args, **kwargs) + # Backwards compatibility: also check self.metadata for serialize_as_string + # (v1.0.0 API read it from self.metadata directly) + if self.serialize_as_string_override is None: + if "serialize_as_string" in self.metadata: + self.serialize_as_string_override = self.metadata["serialize_as_string"] + def _serialize( self, value: Quantity | None, attr: str | None, obj: Any, **kwargs: Any ) -> Any: @@ -292,3 +299,25 @@ class Config: """ TYPE_MAPPING: dict[type, type[Field[Any]]] = QUANTITY_FIELD_CLASSES + + def __init__(self, *args: Any, **kwargs: Any) -> None: + """Initialize the schema. + + Args: + *args: Positional arguments passed to the parent Schema. + **kwargs: Keyword arguments passed to the parent Schema. + The deprecated `serialize_as_string_default` parameter is accepted + for backwards compatibility but will emit a DeprecationWarning. + """ + # Extract deprecated parameter before passing to parent + serialize_as_string_value = kwargs.pop("serialize_as_string_default", None) + super().__init__(*args, **kwargs) + if serialize_as_string_value is not None: + warnings.warn( + "Passing 'serialize_as_string_default' to QuantitySchema constructor is " + "deprecated. Use the 'serialize_as_string_default' context variable instead: " + "serialize_as_string_default.set(True)", + DeprecationWarning, + stacklevel=2, + ) + serialize_as_string_default.set(serialize_as_string_value) diff --git a/tests/experimental/test_marshmallow.py b/tests/experimental/test_marshmallow.py index 2b880e7..43daebf 100644 --- a/tests/experimental/test_marshmallow.py +++ b/tests/experimental/test_marshmallow.py @@ -3,10 +3,10 @@ """Test marshmallow fields and schema.""" - from dataclasses import dataclass, field from typing import Any, Self, cast +import pytest from marshmallow_dataclass import class_schema from frequenz.quantities import ( @@ -246,3 +246,51 @@ def test_config_schema_dump_default_string() -> None: "voltage_always_string": "250 kV", "temp_never_string": 10.0, } + + +def test_deprecated_constructor_api() -> None: + """Test that the deprecated constructor API still works but emits a warning.""" + + @dataclass + class _SimpleConfig: + """Test config dataclass.""" + + pct: Percentage = field(default_factory=lambda: Percentage.from_percent(75.0)) + + schema_cls = class_schema(_SimpleConfig, base_schema=QuantitySchema) + + with pytest.warns( + DeprecationWarning, + match="Passing 'serialize_as_string_default' to QuantitySchema constructor is deprecated", + ): + schema = schema_cls(serialize_as_string_default=True) # type: ignore[call-arg] + + try: + # Verify it still works + result = schema.dump(_SimpleConfig()) + assert result["pct"] == "75 %" + finally: + # Reset the context variable to avoid affecting other tests + serialize_as_string_default.set(False) + + +def test_deprecated_constructor_api_false() -> None: + """Test that the deprecated constructor API works with False value.""" + + @dataclass + class _SimpleConfig: + """Test config dataclass.""" + + pct: Percentage = field(default_factory=lambda: Percentage.from_percent(75.0)) + + schema_cls = class_schema(_SimpleConfig, base_schema=QuantitySchema) + + with pytest.warns( + DeprecationWarning, + match="Passing 'serialize_as_string_default' to QuantitySchema constructor is deprecated", + ): + schema = schema_cls(serialize_as_string_default=False) # type: ignore[call-arg] + + # Verify it still works (no need to reset since we're setting to False which is the default) + result = schema.dump(_SimpleConfig()) + assert result["pct"] == 75.0