Skip to content

Commit

Permalink
Add serializers for *Timedelta types
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
rra committed Nov 13, 2024
1 parent 470e512 commit d2d5b7c
Show file tree
Hide file tree
Showing 3 changed files with 34 additions and 3 deletions.
7 changes: 7 additions & 0 deletions changelog.d/20241112_163814_rra_DM_47262.md
Original file line number Diff line number Diff line change
@@ -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.
18 changes: 15 additions & 3 deletions safir/src/safir/pydantic/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`.
Expand All @@ -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`.
Expand Down
12 changes: 12 additions & 0 deletions safir/tests/pydantic_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"})
Expand All @@ -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"})
Expand Down

0 comments on commit d2d5b7c

Please sign in to comment.