Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

DM-45281: Add new timedelta data types for Pydantic models #269

Merged
merged 1 commit into from
Jul 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions changelog.d/20240716_154320_rra_DM_45281.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
### New features

- Add new `safir.pydantic.SecondsDatetime` and `safir.pydantic.HumanDatetime` types for use in Pydantic models. These behave the same as `datetime.timedelta` fields but use custom validation. Both support a stringified number of seconds as input, and the latter also supports the interval strings parsed by `safir.datetime.parse_timedelta`.
4 changes: 4 additions & 0 deletions docs/documenteer.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ nitpick_ignore = [
["py:obj", "JobMetadata.id"],
["py:class", "pydantic.BaseModel"],
["py:class", "BaseModel"],
# sphinx-automodapi apparently doesn't recognize TypeAlias as an object
# that should have generated documentation, even with include-all-objects.
["py:obj", "safir.pydantic.HumanTimedelta"],
["py:obj", "safir.pydantic.SecondsTimedelta"],
]
extensions = [
"sphinxcontrib.autodoc_pydantic",
Expand Down
30 changes: 6 additions & 24 deletions docs/user-guide/datetime.rst
Original file line number Diff line number Diff line change
Expand Up @@ -76,11 +76,13 @@ Safir therefore also provides `safir.datetime.format_datetime_for_logging`, whic
As the name of the function indicates, this function should only be used when formatting dates for logging and other human display.
Dates that may need to be parsed again by another program should use `~safir.datetime.isodatetime` instead.

.. _datetime-timedelta:

Parsing time intervals
======================

Pydantic by default supports specifying `datetime.timedelta` fields as either a floating-point number of seconds or as an ISO 8601 duration.
The syntax for ISO 8601 durations is unambiguous, but it's obscure and not widely used.
The syntax for ISO 8601 durations is unambiguous but obscure.
For example, ``P23DT23H`` represents a duration of 23 days and 23 hours.

Safir provides a function, `safir.datetime.parse_timedelta` that parses an alternative syntax for specifying durations that's easier for humans to read and is similar to the syntax supported by other languages and libraries.
Expand All @@ -95,26 +97,6 @@ The supported abbreviations are:

So, for example, the duration mentioned above could be given as ``23d23h`` or ``23days 23hours``.

To accept this syntax as input for a Pydantic model, use a field validator such as the following:

.. code-block:: python

from pydantic import BaseModel, field_validator
from safir.datetime import parse_timedelta


class Someething(BaseModel):
lifetime: timedelta = Field(..., title="Lifetime")

# ... other fields

@field_validator("lifetime", mode="before")
@classmethod
def _validate_lifetime(
cls, v: str | float | timedelta
) -> float | timedelta:
if not isinstance(v, str):
return v
return parse_timedelta(v)

This disables the built-in Pydantic support for ISO 8601 durations in favor of the syntax shown above.
To accept this syntax as input for a Pydantic model, declare the field to have the type `safir.pydantic.HumanTimedelta`.
This will automatically convert input strings using the `~safir.datetime.parse_timedelta` function.
See :ref:`pydantic-timedelta` for more information.
33 changes: 33 additions & 0 deletions docs/user-guide/pydantic.rst
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,39 @@ This function only accepts ``YYYY-MM-DDTHH:MM[:SS]Z`` as the input format.
The ``Z`` time zone prefix indicating UTC is mandatory.
It is called the same way as `~safir.pydantic.normalize_datetime`.

.. _pydantic-timedelta:

Normalizing timedelta fields
============================

The default Pydantic validation for `datetime.timedelta` fields accepts either a floating-point number of seconds or an ISO 8601 duration as a string.
The syntax for ISO 8601 durations is unambiguous but obscure.
For example, ``P23DT23H`` represents a duration of 23 days and 23 hours.

Safir provides two alternate data types for Pydantic models.
Both of these types represent normal `~datetime.timedelta` objects with some Pydantic validation rules attached.
They can be used in Python source exactly like `~datetime.timedelta` objects.

The type `safir.pydantic.SecondsTimedelta` accepts only a floating-point number of seconds, but allows it to be given as a string.
For example, input of either ``300`` or ``"300"`` becomes a `~datetime.timedelta` object representing five minutes (300 seconds).

The type `safir.pydantic.HumanTimedelta` accepts those formats as well as the time interval strings parsed by `safir.datetime.parse_timedelta`.
For example, the string ``3h5m23s`` becomes a `~datetime.timedelta` object representing three hours, five minutes, and 23 seconds.
See :ref:`datetime-timedelta` for the full supported syntax.

These can be used like any other type in a model and perform their validation automatically.
For example:

.. code-block:: python

from pydantic import BaseModel
from safir.pydantic import HumanTimedelta, SecondsTimedelta


class Model(BaseModel):
timeout: SecondsTimedelta
lifetime: HumanTimedelta

Accepting camel-case attributes
===============================

Expand Down
26 changes: 4 additions & 22 deletions src/safir/datetime.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,9 +170,10 @@ def parse_timedelta(text: str) -> timedelta:
valid strings are ``8d`` (8 days), ``4h 3minutes`` (four hours and three
minutes), and ``5w4d`` (five weeks and four days).

This function can be as a before-mode validator for Pydantic
`~datetime.timedelta` fields, replacing Pydantic's default ISO 8601
duration support.
If you want to accept strings of this type as input to a
`~datetime.timedelta` field in a Pydantic model, use the
`~safir.pydantic.HumanTimedelta` type as the field type. It uses this
function to parse input strings.

Parameters
----------
Expand All @@ -188,25 +189,6 @@ def parse_timedelta(text: str) -> timedelta:
------
ValueError
Raised if the string is not in a valid format.

Examples
--------
To accept a `~datetime.timedelta` in this format in a Pydantic model, use
a Pydantic field validator such as the following:

.. code-block:: python

@field_validator("lifetime", mode="before")
@classmethod
def _validate_lifetime(
cls, v: str | float | timedelta
) -> float | timedelta:
if not isinstance(v, str):
return v
return parse_timedelta(v)

This will disable the Pydantic support for ISO 8601 durations and expect
the format parsed by this function instead.
"""
m = _TIMEDELTA_PATTERN.match(text.strip())
if m is None:
Expand Down
54 changes: 51 additions & 3 deletions src/safir/pydantic.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,71 @@
from __future__ import annotations

from collections.abc import Callable
from datetime import UTC, datetime
from typing import Any, ParamSpec, TypeVar
from datetime import UTC, datetime, timedelta
from typing import Annotated, Any, ParamSpec, TypeAlias, TypeVar

from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel, BeforeValidator, ConfigDict

from .datetime import parse_timedelta

P = ParamSpec("P")
T = TypeVar("T")

__all__ = [
"CamelCaseModel",
"HumanTimedelta",
"SecondsTimedelta",
"normalize_datetime",
"normalize_isodatetime",
"to_camel_case",
"validate_exactly_one_of",
]


def _validate_human_timedelta(v: str | float | timedelta) -> float | timedelta:
if not isinstance(v, str):
return v
try:
return float(v)
except ValueError:
return parse_timedelta(v)


HumanTimedelta: TypeAlias = Annotated[
timedelta, BeforeValidator(_validate_human_timedelta)
]
"""Parse a human-readable string into a `datetime.timedelta`.

Accepts as input an integer or float (or stringified integer or float) number
of seconds, an already-parsed `~datetime.timedelta`, or a string consisting of
one or more sequences of numbers and duration abbreviations, separated by
optional whitespace. Whitespace at the beginning and end of the string is
ignored. The supported abbreviations are:

- Week: ``weeks``, ``week``, ``w``
- Day: ``days``, ``day``, ``d``
- Hour: ``hours``, ``hour``, ``hr``, ``h``
- Minute: ``minutes``, ``minute``, ``mins``, ``min``, ``m``
- Second: ``seconds``, ``second``, ``secs``, ``sec``, ``s``

If several are present, they must be given in the above order. Example
valid strings are ``8d`` (8 days), ``4h 3minutes`` (four hours and three
minutes), and ``5w4d`` (five weeks and four days).
"""

SecondsTimedelta: TypeAlias = Annotated[
timedelta,
BeforeValidator(lambda v: v if not isinstance(v, str) else int(v)),
]
"""Parse a float number of seconds into a `datetime.timedelta`.

Accepts as input an integer or float (or stringified integer or float) number
of seconds or an already-parsed `~datetime.timedelta`. Compared to the
built-in Pydantic handling of `~datetime.timedelta`, an integer number of
seconds as a string is accepted, and ISO 8601 durations are not supported.
"""


def normalize_datetime(v: Any) -> datetime | None:
"""Pydantic field validator for datetime fields.

Expand Down
38 changes: 38 additions & 0 deletions tests/pydantic_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,51 @@

from safir.pydantic import (
CamelCaseModel,
HumanTimedelta,
SecondsTimedelta,
normalize_datetime,
normalize_isodatetime,
to_camel_case,
validate_exactly_one_of,
)


def test_human_timedelta() -> None:
class TestModel(BaseModel):
delta: HumanTimedelta

model = TestModel.model_validate({"delta": timedelta(seconds=5)})
assert model.delta == timedelta(seconds=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
model = TestModel.model_validate({"delta": "300"})
assert model.delta == timedelta(seconds=300)

with pytest.raises(ValidationError):
TestModel.model_validate({"delta": "P1DT12H"})


def test_seconds_timedelta() -> None:
class TestModel(BaseModel):
delta: SecondsTimedelta

model = TestModel.model_validate({"delta": timedelta(seconds=5)})
assert model.delta == timedelta(seconds=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
model = TestModel.model_validate({"delta": "300"})
assert model.delta == timedelta(seconds=300)

with pytest.raises(ValidationError):
TestModel.model_validate({"delta": "P1DT12H"})


def test_normalize_datetime() -> None:
class TestModel(BaseModel):
time: datetime | None
Expand Down