From d2d5b7c94e4ea2c8ccfc754fb174d862c973b61c Mon Sep 17 00:00:00 2001 From: Russ Allbery Date: Tue, 12 Nov 2024 16:40:17 -0800 Subject: [PATCH] Add serializers for *Timedelta types Add serializers to `HumanTimedelta` and `SecondsTimedelta` that serialize those Pydantic fields to a float number of seconds instead of ISO 8601 durations. This means those data types now can be round-tripped (serialized and then deserialized to the original value), whereas before they could not be. This also avoids ISO 8601 durations in service replies that use models including those types. Fix parsing of stringified floating-point number of seconds for `SecondsTimedelta`, which previously was truncating to an integer number of seconds. --- changelog.d/20241112_163814_rra_DM_47262.md | 7 +++++++ safir/src/safir/pydantic/_types.py | 18 +++++++++++++++--- safir/tests/pydantic_test.py | 12 ++++++++++++ 3 files changed, 34 insertions(+), 3 deletions(-) create mode 100644 changelog.d/20241112_163814_rra_DM_47262.md diff --git a/changelog.d/20241112_163814_rra_DM_47262.md b/changelog.d/20241112_163814_rra_DM_47262.md new file mode 100644 index 00000000..db5d5d53 --- /dev/null +++ b/changelog.d/20241112_163814_rra_DM_47262.md @@ -0,0 +1,7 @@ +### Backwards-incompatible changes + +- Add serializers to `HumanTimedelta` and `SecondsTimedelta` that serialize those Pydantic fields to a float number of seconds instead of ISO 8601 durations. This means those data types now can be round-tripped (serialized and then deserialized to the original value), whereas before they could not be. + +### Bug fixes + +- `SecondsTimedelta` now correctly validates an input stringified floating-point number of seconds instead of truncating it to an integer. diff --git a/safir/src/safir/pydantic/_types.py b/safir/src/safir/pydantic/_types.py index ecd3bbd1..205458c8 100644 --- a/safir/src/safir/pydantic/_types.py +++ b/safir/src/safir/pydantic/_types.py @@ -6,7 +6,12 @@ from datetime import timedelta from typing import Annotated, TypeAlias -from pydantic import AfterValidator, BeforeValidator, UrlConstraints +from pydantic import ( + AfterValidator, + BeforeValidator, + PlainSerializer, + UrlConstraints, +) from pydantic_core import Url from safir.datetime import parse_timedelta @@ -110,7 +115,11 @@ def _validate_human_timedelta(v: str | float | timedelta) -> float | timedelta: HumanTimedelta: TypeAlias = Annotated[ - timedelta, BeforeValidator(_validate_human_timedelta) + timedelta, + BeforeValidator(_validate_human_timedelta), + PlainSerializer( + lambda t: t.total_seconds(), return_type=float, when_used="json" + ), ] """Parse a human-readable string into a `datetime.timedelta`. @@ -133,7 +142,10 @@ def _validate_human_timedelta(v: str | float | timedelta) -> float | timedelta: SecondsTimedelta: TypeAlias = Annotated[ timedelta, - BeforeValidator(lambda v: v if not isinstance(v, str) else int(v)), + BeforeValidator(lambda v: v if not isinstance(v, str) else float(v)), + PlainSerializer( + lambda t: t.total_seconds(), return_type=float, when_used="json" + ), ] """Parse a float number of seconds into a `datetime.timedelta`. diff --git a/safir/tests/pydantic_test.py b/safir/tests/pydantic_test.py index e4eb2bd5..9418b986 100644 --- a/safir/tests/pydantic_test.py +++ b/safir/tests/pydantic_test.py @@ -145,14 +145,20 @@ class TestModel(BaseModel): model = TestModel.model_validate({"delta": timedelta(seconds=5)}) assert model.delta == timedelta(seconds=5) + assert model.model_dump(mode="python") == {"delta": timedelta(seconds=5)} + assert model.model_dump(mode="json") == {"delta": 5} model = TestModel.model_validate({"delta": "4h5m18s"}) assert model.delta == timedelta(hours=4, minutes=5, seconds=18) model = TestModel.model_validate({"delta": 600}) assert model.delta == timedelta(seconds=600) + assert model.model_dump(mode="python") == {"delta": timedelta(seconds=600)} model = TestModel.model_validate({"delta": 4.5}) assert model.delta.total_seconds() == 4.5 + assert model.model_dump(mode="json") == {"delta": 4.5} model = TestModel.model_validate({"delta": "300"}) assert model.delta == timedelta(seconds=300) + model = TestModel.model_validate({"delta": "10.5"}) + assert model.delta.total_seconds() == 10.5 with pytest.raises(ValidationError): TestModel.model_validate({"delta": "P1DT12H"}) @@ -164,12 +170,18 @@ class TestModel(BaseModel): model = TestModel.model_validate({"delta": timedelta(seconds=5)}) assert model.delta == timedelta(seconds=5) + assert model.model_dump(mode="python") == {"delta": timedelta(seconds=5)} + assert model.model_dump(mode="json") == {"delta": 5} model = TestModel.model_validate({"delta": 600}) assert model.delta == timedelta(seconds=600) + assert model.model_dump(mode="python") == {"delta": timedelta(seconds=600)} model = TestModel.model_validate({"delta": 4.5}) assert model.delta.total_seconds() == 4.5 + assert model.model_dump(mode="json") == {"delta": 4.5} model = TestModel.model_validate({"delta": "300"}) assert model.delta == timedelta(seconds=300) + model = TestModel.model_validate({"delta": "10.5"}) + assert model.delta.total_seconds() == 10.5 with pytest.raises(ValidationError): TestModel.model_validate({"delta": "P1DT12H"})