From 2886109347b9503303e99f283b914ff7495b76d0 Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Wed, 5 Feb 2025 14:04:59 +0100 Subject: [PATCH] Merging upstream version 2.8.2. Signed-off-by: Daniel Baumann --- .gitignore | 1 + HISTORY.md | 15 ++ pydantic_extra_types/__init__.py | 2 +- pydantic_extra_types/pendulum_dt.py | 74 +++++---- pyproject.toml | 2 + requirements/pyproject.txt | 2 +- tests/test_pendulum_dt.py | 225 ++++++++++++++++++++++++++-- 7 files changed, 276 insertions(+), 45 deletions(-) diff --git a/.gitignore b/.gitignore index 8b26510..7a4d678 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,4 @@ _build/ /.ghtopdep_cache/ /worktrees/ .ruff_cache/ +.python-version diff --git a/HISTORY.md b/HISTORY.md index 3101c27..b781e31 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -2,6 +2,21 @@ ## Latest Changes +## 2.8.2 + +* 🐛 Preserve timezone information when validating Pendulum DateTimes. [#189](https://github.com/pydantic/pydantic-extra-types/pull/189) by [@chrisguidry +](https://github.com/chrisguidry) + +## 2.8.1 + +### Bug Fixes + +* 🐛 Fix Pendulum date time object to have correct typing. [#184](https://github.com/pydantic/pydantic-extra-types/pull/184) by [@07pepa](https://github.com/07pepa) + +### Types + +* ✨ Add parsing of pendulum_dt from unix time and non-strict parsing. [#185](https://github.com/pydantic/pydantic-extra-types/pull/185) by [@07pepa](https://github.com/07pepa) + ## 2.8.0 ### Refactor diff --git a/pydantic_extra_types/__init__.py b/pydantic_extra_types/__init__.py index f2df444..964a32a 100644 --- a/pydantic_extra_types/__init__.py +++ b/pydantic_extra_types/__init__.py @@ -1 +1 @@ -__version__ = '2.8.0' +__version__ = '2.8.2' diff --git a/pydantic_extra_types/pendulum_dt.py b/pydantic_extra_types/pendulum_dt.py index 633b02d..f75c95b 100644 --- a/pydantic_extra_types/pendulum_dt.py +++ b/pydantic_extra_types/pendulum_dt.py @@ -3,8 +3,6 @@ Native Pendulum DateTime object implementation. This is a copy of the Pendulum D CoreSchema implementation. This allows Pydantic to validate the DateTime object. """ -import pendulum - try: from pendulum import Date as _Date from pendulum import DateTime as _DateTime @@ -21,7 +19,17 @@ from pydantic import GetCoreSchemaHandler from pydantic_core import PydanticCustomError, core_schema -class DateTime(_DateTime): +class DateTimeSettings(type): + def __new__(cls, name, bases, dct, **kwargs): # type: ignore[no-untyped-def] + dct['strict'] = kwargs.pop('strict', True) + return super().__new__(cls, name, bases, dct) + + def __init__(cls, name, bases, dct, **kwargs): # type: ignore[no-untyped-def] + super().__init__(name, bases, dct) + cls.strict = kwargs.get('strict', True) + + +class DateTime(_DateTime, metaclass=DateTimeSettings): """ A `pendulum.DateTime` object. At runtime, this type decomposes into pendulum.DateTime automatically. This type exists because Pydantic throws a fit on unknown types. @@ -56,7 +64,7 @@ class DateTime(_DateTime): return core_schema.no_info_wrap_validator_function(cls._validate, core_schema.datetime_schema()) @classmethod - def _validate(cls, value: Any, handler: core_schema.ValidatorFunctionWrapHandler) -> Any: + def _validate(cls, value: Any, handler: core_schema.ValidatorFunctionWrapHandler) -> 'DateTime': """ Validate the datetime object and return it. @@ -68,18 +76,23 @@ class DateTime(_DateTime): The validated value or raises a PydanticCustomError. """ # if we are passed an existing instance, pass it straight through. - if isinstance(value, _DateTime): - return handler(value) - - if isinstance(value, datetime): - return handler(DateTime.instance(value)) - - # otherwise, parse it. + if isinstance(value, (_DateTime, datetime)): + return DateTime.instance(value) try: - data = parse(value) - except Exception as exc: - raise PydanticCustomError('value_error', 'value is not a valid timestamp') from exc - return handler(data) + # probably the best way to have feature parity with + # https://docs.pydantic.dev/latest/api/standard_library_types/#datetimedatetime + value = handler(value) + return DateTime.instance(value) + except ValueError: + try: + value = parse(value, strict=cls.strict) + if isinstance(value, _DateTime): + return DateTime.instance(value) + raise ValueError(f'value is not a valid datetime it is a {type(value)}') + except ValueError: + raise + except Exception as exc: + raise PydanticCustomError('value_error', 'value is not a valid datetime') from exc class Date(_Date): @@ -117,7 +130,7 @@ class Date(_Date): return core_schema.no_info_wrap_validator_function(cls._validate, core_schema.date_schema()) @classmethod - def _validate(cls, value: Any, handler: core_schema.ValidatorFunctionWrapHandler) -> Any: + def _validate(cls, value: Any, handler: core_schema.ValidatorFunctionWrapHandler) -> 'Date': """ Validate the date object and return it. @@ -129,18 +142,17 @@ class Date(_Date): The validated value or raises a PydanticCustomError. """ # if we are passed an existing instance, pass it straight through. - if isinstance(value, _Date): - return handler(value) - - if isinstance(value, date): - return handler(pendulum.instance(value)) + if isinstance(value, (_Date, date)): + return Date(value.year, value.month, value.day) # otherwise, parse it. try: - data = parse(value) + 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)}') except Exception as exc: raise PydanticCustomError('value_error', 'value is not a valid date') from exc - return handler(data) class Duration(_Duration): @@ -178,7 +190,7 @@ class Duration(_Duration): return core_schema.no_info_wrap_validator_function(cls._validate, core_schema.timedelta_schema()) @classmethod - def _validate(cls, value: Any, handler: core_schema.ValidatorFunctionWrapHandler) -> Any: + def _validate(cls, value: Any, handler: core_schema.ValidatorFunctionWrapHandler) -> 'Duration': """ Validate the Duration object and return it. @@ -190,15 +202,13 @@ class Duration(_Duration): The validated value or raises a PydanticCustomError. """ # if we are passed an existing instance, pass it straight through. - if isinstance(value, _Duration): - return handler(value) + if isinstance(value, (_Duration, timedelta)): + return Duration(seconds=value.total_seconds()) - if isinstance(value, timedelta): - return handler(_Duration(seconds=value.total_seconds())) - - # otherwise, parse it. try: - data = parse(value) + parsed = parse(value, exact=True) + if not isinstance(parsed, timedelta): + raise ValueError(f'value is not a valid duration it is a {type(parsed)}') + return Duration(seconds=parsed.total_seconds()) except Exception as exc: raise PydanticCustomError('value_error', 'value is not a valid duration') from exc - return handler(data) diff --git a/pyproject.toml b/pyproject.toml index 5ccb844..26f15d0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -119,6 +119,8 @@ filterwarnings = [ 'error', # This ignore will be removed when pycountry will drop py36 & support py311 'ignore:::pkg_resources', + # This ignore will be removed when pendulum fixes https://github.com/sdispater/pendulum/issues/834 + 'ignore:datetime.datetime.utcfromtimestamp.*:DeprecationWarning' ] # configuring https://github.com/pydantic/hooky diff --git a/requirements/pyproject.txt b/requirements/pyproject.txt index 5b77472..1f30461 100644 --- a/requirements/pyproject.txt +++ b/requirements/pyproject.txt @@ -4,7 +4,7 @@ # # pip-compile --extra=all --no-emit-index-url --output-file=requirements/pyproject.txt pyproject.toml # -annotated-types==0.6.0 +annotated-types==0.7.0 # via pydantic pendulum==3.0.0 # via pydantic-extra-types (pyproject.toml) diff --git a/tests/test_pendulum_dt.py b/tests/test_pendulum_dt.py index 2cd0545..817b3b9 100644 --- a/tests/test_pendulum_dt.py +++ b/tests/test_pendulum_dt.py @@ -3,17 +3,27 @@ from datetime import timezone as tz import pendulum import pytest -from pydantic import BaseModel, ValidationError +from pydantic import BaseModel, TypeAdapter, ValidationError from pydantic_extra_types.pendulum_dt import Date, DateTime, Duration UTC = tz.utc +DtTypeAdapter = TypeAdapter(datetime) + class DtModel(BaseModel): dt: DateTime +class DateTimeNonStrict(DateTime, strict=False): + pass + + +class DtModelNotStrict(BaseModel): + dt: DateTimeNonStrict + + class DateModel(BaseModel): d: Date @@ -51,6 +61,8 @@ def test_existing_instance(instance): assert dt.minute == instance.minute assert dt.second == instance.second assert dt.microsecond == instance.microsecond + assert isinstance(dt, pendulum.DateTime) + assert type(dt) is DateTime if dt.tzinfo != instance.tzinfo: assert dt.tzinfo.utcoffset(dt) == instance.tzinfo.utcoffset(instance) @@ -75,6 +87,8 @@ def test_pendulum_date_existing_instance(instance): assert d.day == instance.day assert d.month == instance.month assert d.year == instance.year + assert isinstance(d, pendulum.Date) + assert type(d) is Date @pytest.mark.parametrize( @@ -93,6 +107,8 @@ def test_duration_timedelta__existing_instance(instance): model = DurationModel(delta_t=instance) assert model.delta_t.total_seconds() == instance.total_seconds() + assert isinstance(model.delta_t, pendulum.Duration) + assert model.delta_t @pytest.mark.parametrize( @@ -100,7 +116,6 @@ def test_duration_timedelta__existing_instance(instance): [ pendulum.now().to_iso8601_string(), pendulum.now().to_w3c_string(), - pendulum.now().to_iso8601_string(), ], ) def test_pendulum_dt_from_serialized(dt): @@ -110,15 +125,134 @@ def test_pendulum_dt_from_serialized(dt): dt_actual = pendulum.parse(dt) model = DtModel(dt=dt) assert model.dt == dt_actual + assert type(model.dt) is DateTime + assert isinstance(model.dt, pendulum.DateTime) -def test_pendulum_date_from_serialized(): +@pytest.mark.parametrize( + 'dt', + [ + pendulum.now().to_iso8601_string(), + pendulum.now().to_w3c_string(), + ], +) +def test_pendulum_dt_from_serialized_preserves_timezones(dt): + """ + Verifies that building an instance from serialized, well-formed strings decode + properly and preserves the timezone information across all of the Pendulum DateTime + properties. Regression test for pydantic/pydantic-extra-types#188. + """ + dt_actual = pendulum.parse(dt) + model = DtModel(dt=dt) + assert model.dt == dt_actual + assert type(model.dt) is DateTime + assert isinstance(model.dt, pendulum.DateTime) + assert model.dt.tzinfo is not None + assert model.dt.tzinfo.utcoffset(model.dt) == dt_actual.tzinfo.utcoffset(dt_actual) + assert model.dt.tz is not None + assert model.dt.tz.utcoffset(model.dt) == dt_actual.tz.utcoffset(dt_actual) + assert model.dt.timezone is not None + assert model.dt.timezone.utcoffset(model.dt) == dt_actual.timezone.utcoffset(dt_actual) + + +@pytest.mark.parametrize( + 'dt', + [ + pendulum.now().to_iso8601_string(), + pendulum.now().to_w3c_string(), + 'Sat Oct 11 17:13:46 UTC 2003', # date util parsing + pendulum.now().to_iso8601_string()[:5], # actualy valid or pendulum.parse(dt, strict=False) would fail here + ], +) +def test_pendulum_dt_not_strict_from_serialized(dt): """ Verifies that building an instance from serialized, well-formed strings decode properly. """ - date_actual = pendulum.parse('2024-03-18').date() - model = DateModel(d='2024-03-18') + dt_actual = pendulum.parse(dt, strict=False) + model = DtModelNotStrict(dt=dt) + assert model.dt == dt_actual + assert type(model.dt) is DateTime + assert isinstance(model.dt, pendulum.DateTime) + + +@pytest.mark.parametrize( + 'dt', + [ + pendulum.now().to_iso8601_string(), + pendulum.now().to_w3c_string(), + 1718096578, + 1718096578.5, + -5, + -5.5, + float('-0'), + '1718096578', + '1718096578.5', + '-5', + '-5.5', + '-0', + '-0.0', + '+0.0', + '+1718096578.5', + float('-2e10') - 1.0, + float('2e10') + 1.0, + -2e10 - 1, + 2e10 + 1, + ], +) +def test_pendulum_dt_from_str_unix_timestamp(dt): + """ + Verifies that building an instance from serialized, well-formed strings decode properly. + """ + dt_actual = pendulum.instance(DtTypeAdapter.validate_python(dt)) + model = DtModel(dt=dt) + assert model.dt == dt_actual + assert type(model.dt) is DateTime + assert isinstance(model.dt, pendulum.DateTime) + + +@pytest.mark.parametrize( + 'dt', + [ + 1718096578, + 1718096578.5, + -5, + -5.5, + float('-0'), + '1718096578', + '1718096578.5', + '-5', + '-5.5', + '-0', + '-0.0', + '+0.0', + '+1718096578.5', + float('-2e10') - 1.0, + float('2e10') + 1.0, + -2e10 - 1, + 2e10 + 1, + ], +) +def test_pendulum_dt_from_str_unix_timestamp_is_utc(dt): + """ + Verifies that without timezone information, it is coerced to UTC. As in pendulum + """ + model = DtModel(dt=dt) + assert model.dt.tzinfo.tzname(model.dt) == 'UTC' + + +@pytest.mark.parametrize( + 'd', + [pendulum.now().date().isoformat(), pendulum.now().to_w3c_string(), pendulum.now().to_iso8601_string()], +) +def test_pendulum_date_from_serialized(d): + """ + Verifies that building an instance from serialized, well-formed strings decode properly. + """ + date_actual = pendulum.parse(d).date() + model = DateModel(d=d) assert model.d == date_actual + assert type(model.d) is Date + assert isinstance(model.d, pendulum.Date) @pytest.mark.parametrize( @@ -138,29 +272,77 @@ def test_pendulum_duration_from_serialized(delta_t_str): true_delta_t = pendulum.parse(delta_t_str) model = DurationModel(delta_t=delta_t_str) assert model.delta_t == true_delta_t + assert type(model.delta_t) is Duration + assert isinstance(model.delta_t, pendulum.Duration) -@pytest.mark.parametrize('dt', [None, 'malformed', pendulum.now().to_iso8601_string()[:5], 42]) +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_dt_malformed(dt): """ - Verifies that the instance fails to validate if malformed dt are passed. + Verifies that the instance fails to validate if malformed dt is passed. """ with pytest.raises(ValidationError): DtModel(dt=dt) -@pytest.mark.parametrize('date', [None, 'malformed', pendulum.today().to_iso8601_string()[:5], 42]) -def test_pendulum_date_malformed(date): +@pytest.mark.parametrize('dt', get_invalid_dt_common()) +def test_pendulum_dt_non_strict_malformed(dt): + """ + Verifies that the instance fails to validate if malformed dt are passed. + """ + with pytest.raises(ValidationError): + DtModelNotStrict(dt=dt) + + +@pytest.mark.parametrize('invalid_value', [None, 'malformed', pendulum.today().to_iso8601_string()[:5], 'P10Y10M10D']) +def test_pendulum_date_malformed(invalid_value): """ Verifies that the instance fails to validate if malformed date are passed. """ with pytest.raises(ValidationError): - DateModel(d=date) + DateModel(d=invalid_value) @pytest.mark.parametrize( 'delta_t', - [None, 'malformed', pendulum.today().to_iso8601_string()[:5], 42, '12m'], + [None, 'malformed', pendulum.today().to_iso8601_string()[:5], 42, '12m', '2021-01-01T12:00:00'], ) def test_pendulum_duration_malformed(delta_t): """ @@ -168,3 +350,24 @@ def test_pendulum_duration_malformed(delta_t): """ with pytest.raises(ValidationError): DurationModel(delta_t=delta_t) + + +@pytest.mark.parametrize( + 'input_type, value, is_instance', + [ + (Date, '2021-01-01', pendulum.Date), + (Date, date(2021, 1, 1), pendulum.Date), + (Date, pendulum.date(2021, 1, 1), pendulum.Date), + (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), + (Duration, 'P1DT25H', pendulum.Duration), + (Duration, timedelta(days=1, hours=25), pendulum.Duration), + (Duration, pendulum.duration(days=1, hours=25), pendulum.Duration), + ], +) +def test_date_type_adapter(input_type: type, value, is_instance: type): + validated = TypeAdapter(input_type).validate_python(value) + assert type(validated) is input_type + assert isinstance(validated, input_type) + assert isinstance(validated, is_instance)