From 736c932de19e40d4f139d63c99acb170f8349cd4 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. --- changelog.d/20241112_163814_rra_DM_47262.md | 3 +++ safir/src/safir/pydantic/_types.py | 12 ++++++++++-- safir/tests/pydantic_test.py | 4 ++++ 3 files changed, 17 insertions(+), 2 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..383b5810 --- /dev/null +++ b/changelog.d/20241112_163814_rra_DM_47262.md @@ -0,0 +1,3 @@ +### 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. diff --git a/safir/src/safir/pydantic/_types.py b/safir/src/safir/pydantic/_types.py index ecd3bbd1..c0b45d2f 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,9 @@ 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), ] """Parse a human-readable string into a `datetime.timedelta`. @@ -134,6 +141,7 @@ 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)), + PlainSerializer(lambda t: t.total_seconds(), return_type=float), ] """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..b7ff546e 100644 --- a/safir/tests/pydantic_test.py +++ b/safir/tests/pydantic_test.py @@ -145,12 +145,14 @@ class TestModel(BaseModel): model = TestModel.model_validate({"delta": timedelta(seconds=5)}) assert model.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) 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) @@ -164,10 +166,12 @@ class TestModel(BaseModel): model = TestModel.model_validate({"delta": timedelta(seconds=5)}) assert model.delta == timedelta(seconds=5) + assert model.model_dump(mode="json") == {"delta": 5} model = TestModel.model_validate({"delta": 600}) assert model.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)