From a573fe633e1b41b8aa61aba9a42e65897467b0db Mon Sep 17 00:00:00 2001 From: Ryan Wood Date: Tue, 28 Jan 2025 14:20:42 +0900 Subject: [PATCH] Added Time parsing to pendulum_dt --- pydantic_extra_types/pendulum_dt.py | 67 ++++++++++++++++++- tests/test_pendulum_dt.py | 100 +++++++++++++++++++++++++++- uv.lock | 2 +- 3 files changed, 164 insertions(+), 5 deletions(-) diff --git a/pydantic_extra_types/pendulum_dt.py b/pydantic_extra_types/pendulum_dt.py index f306529..d6b4f2f 100644 --- a/pydantic_extra_types/pendulum_dt.py +++ b/pydantic_extra_types/pendulum_dt.py @@ -8,12 +8,13 @@ from pendulum import Date as _Date from pendulum import DateTime as _DateTime from pendulum import Duration as _Duration + from pendulum import Time as _Time from pendulum import parse except ModuleNotFoundError as e: # pragma: no cover raise RuntimeError( 'The `pendulum_dt` module requires "pendulum" to be installed. You can install it with "pip install pendulum".' ) from e -from datetime import date, datetime, timedelta +from datetime import date, datetime, timedelta, time from typing import Any from pydantic import GetCoreSchemaHandler @@ -95,6 +96,68 @@ def _validate(cls, value: Any, handler: core_schema.ValidatorFunctionWrapHandler raise PydanticCustomError('value_error', 'value is not a valid datetime') from exc +class Time(_Time): + """A `pendulum.Time` object. At runtime, this type decomposes into pendulum.Time automatically. + This type exists because Pydantic throws a fit on unknown types. + + ```python + from pydantic import BaseModel + from pydantic_extra_types.pendulum_dt import Time + + + class test_model(BaseModel): + dt: Time + + + print(test_model(dt='00:00:00')) + + # > test_model(dt=Time(0, 0, 0)) + ``` + """ + + __slots__: list[str] = [] + + @classmethod + def __get_pydantic_core_schema__(cls, source: type[Any], handler: GetCoreSchemaHandler) -> core_schema.CoreSchema: + """Return a Pydantic CoreSchema with the Time validation + + Args: + source: The source type to be converted. + handler: The handler to get the CoreSchema. + + Returns: + A Pydantic CoreSchema with the Time validation. + """ + return core_schema.no_info_wrap_validator_function(cls._validate, core_schema.time_schema()) + + @classmethod + def _validate(cls, value: Any, handler: core_schema.ValidatorFunctionWrapHandler) -> Time: + """Validate the Time object and return it. + + Args: + value: The value to validate. + handler: The handler to get the CoreSchema. + + Returns: + The validated value or raises a PydanticCustomError. + """ + # if we are passed an existing instance, pass it straight through. + if isinstance(value, (_Time, time)): + return Time.instance(value, tz=value.tzinfo) + + # otherwise, parse it. + try: + parsed = parse(value, exact=True) + if isinstance(parsed, _DateTime): + dt = DateTime.instance(parsed) + return Time.instance(dt.time()) + if isinstance(parsed, _Time): + return Time.instance(parsed) + raise ValueError(f'value is not a valid time it is a {type(parsed)}') + except Exception as exc: + raise PydanticCustomError('value_error', 'value is not a valid time') from exc + + class Date(_Date): """A `pendulum.Date` object. At runtime, this type decomposes into pendulum.Date automatically. This type exists because Pydantic throws a fit on unknown types. @@ -149,7 +212,7 @@ def _validate(cls, value: Any, handler: core_schema.ValidatorFunctionWrapHandler parsed = parse(value) if isinstance(parsed, (_DateTime, _Date)): return Date(parsed.year, parsed.month, parsed.day) - raise ValueError('value is not a valid date it is a {type(parsed)}') + raise ValueError(f'value is not a valid date it is a {type(parsed)}') except Exception as exc: raise PydanticCustomError('value_error', 'value is not a valid date') from exc diff --git a/tests/test_pendulum_dt.py b/tests/test_pendulum_dt.py index 7635b5d..2bad4e4 100644 --- a/tests/test_pendulum_dt.py +++ b/tests/test_pendulum_dt.py @@ -1,11 +1,11 @@ -from datetime import date, datetime, timedelta +from datetime import date, datetime, timedelta, time from datetime import timezone as tz import pendulum import pytest from pydantic import BaseModel, TypeAdapter, ValidationError -from pydantic_extra_types.pendulum_dt import Date, DateTime, Duration +from pydantic_extra_types.pendulum_dt import Date, DateTime, Duration, Time UTC = tz.utc @@ -16,6 +16,10 @@ class DtModel(BaseModel): dt: DateTime +class TimeModel(BaseModel): + t: Time + + class DateTimeNonStrict(DateTime, strict=False): pass @@ -334,6 +338,95 @@ def test_pendulum_dt_non_strict_malformed(dt): DtModelNotStrict(dt=dt) +@pytest.mark.parametrize( + 'instance', + [ + pendulum.now().time(), + datetime.now().time(), + datetime.now(UTC).time(), + ], +) +def test_existing_time_instance(instance): + """Verifies that constructing a model with an existing pendulum time doesn't throw.""" + model = TimeModel(t=instance) + if isinstance(instance, pendulum.Time): + assert model.t == instance + t = model.t + else: + assert model.t.replace(tzinfo=UTC) == pendulum.instance(instance) # pendulum defaults to UTC + t = model.t + + assert t.hour == instance.hour + assert t.minute == instance.minute + assert t.second == instance.second + assert t.microsecond == instance.microsecond + assert isinstance(t, pendulum.Time) + assert type(t) is Time + if t.tzinfo != instance.tzinfo: + date = Date(2022, 1, 22) + assert t.tzinfo.utcoffset(DateTime.combine(date, t)) == instance.tzinfo.utcoffset(DateTime.combine(date, instance)) + + +@pytest.mark.parametrize( + 'dt', + [ + "17:53:12.266369", + "17:53:46", + ], +) +def test_pendulum_time_from_serialized(dt): + """Verifies that building an instance from serialized, well-formed strings decode properly.""" + dt_actual = pendulum.parse(dt, exact=True) + model = TimeModel(t=dt) + assert model.t == dt_actual.replace(tzinfo=UTC) + assert type(model.t) is Time + assert isinstance(model.t, pendulum.Time) + + +def get_invalid_dt_common(): + return [ + None, + 'malformed', + 'P10Y10M10D', + float('inf'), + float('-inf'), + 'inf', + '-inf', + 'INF', + '-INF', + '+inf', + 'Infinity', + '+Infinity', + '-Infinity', + 'INFINITY', + '+INFINITY', + '-INFINITY', + 'infinity', + '+infinity', + '-infinity', + float('nan'), + 'nan', + 'NaN', + 'NAN', + '+nan', + '-nan', + ] + + +dt_strict = get_invalid_dt_common() +dt_strict.append(pendulum.now().to_iso8601_string()[:5]) + + +@pytest.mark.parametrize( + 'dt', + dt_strict, +) +def test_pendulum_time_malformed(dt): + """Verifies that the instance fails to validate if malformed time is passed.""" + with pytest.raises(ValidationError): + TimeModel(t=dt) + + @pytest.mark.parametrize( 'invalid_value', [None, 'malformed', pendulum.today().to_iso8601_string()[:5], 'P10Y10M10D'], @@ -367,6 +460,9 @@ def test_pendulum_duration_malformed(delta_t): (Date, '2021-01-01', pendulum.Date), (Date, date(2021, 1, 1), pendulum.Date), (Date, pendulum.date(2021, 1, 1), pendulum.Date), + (Time, '12:00:00', pendulum.Time), + (Time, time(12, 0, 0), pendulum.Time), + (Time, pendulum.time(12, 0, 0), pendulum.Time), (DateTime, '2021-01-01T12:00:00', pendulum.DateTime), (DateTime, datetime(2021, 1, 1, 12, 0, 0), pendulum.DateTime), (DateTime, pendulum.datetime(2021, 1, 1, 12, 0, 0), pendulum.DateTime), diff --git a/uv.lock b/uv.lock index 765a14c..43208f4 100644 --- a/uv.lock +++ b/uv.lock @@ -477,7 +477,7 @@ wheels = [ [[package]] name = "pydantic-extra-types" -version = "2.10.1" +version = "2.10.2" source = { editable = "." } dependencies = [ { name = "pydantic" },