diff --git a/changelog.d/20240716_154320_rra_DM_45281.md b/changelog.d/20240716_154320_rra_DM_45281.md new file mode 100644 index 00000000..e0f76a96 --- /dev/null +++ b/changelog.d/20240716_154320_rra_DM_45281.md @@ -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`. diff --git a/docs/documenteer.toml b/docs/documenteer.toml index 9c842c2f..cb0801f9 100644 --- a/docs/documenteer.toml +++ b/docs/documenteer.toml @@ -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", diff --git a/docs/user-guide/datetime.rst b/docs/user-guide/datetime.rst index a7728ee5..29c88edc 100644 --- a/docs/user-guide/datetime.rst +++ b/docs/user-guide/datetime.rst @@ -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. @@ -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. diff --git a/docs/user-guide/pydantic.rst b/docs/user-guide/pydantic.rst index 43044c77..a745fcbe 100644 --- a/docs/user-guide/pydantic.rst +++ b/docs/user-guide/pydantic.rst @@ -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 =============================== diff --git a/src/safir/datetime.py b/src/safir/datetime.py index 801be9a3..740b742f 100644 --- a/src/safir/datetime.py +++ b/src/safir/datetime.py @@ -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 ---------- @@ -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: diff --git a/src/safir/pydantic.py b/src/safir/pydantic.py index 813de1e1..b1b3aff4 100644 --- a/src/safir/pydantic.py +++ b/src/safir/pydantic.py @@ -3,16 +3,20 @@ 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", @@ -20,6 +24,50 @@ ] +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. diff --git a/tests/pydantic_test.py b/tests/pydantic_test.py index 4fad7b46..26ca008d 100644 --- a/tests/pydantic_test.py +++ b/tests/pydantic_test.py @@ -15,6 +15,8 @@ from safir.pydantic import ( CamelCaseModel, + HumanTimedelta, + SecondsTimedelta, normalize_datetime, normalize_isodatetime, to_camel_case, @@ -22,6 +24,42 @@ ) +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