1
0
Fork 0

Merging upstream version 2.8.2.

Signed-off-by: Daniel Baumann <daniel@debian.org>
This commit is contained in:
Daniel Baumann 2025-02-05 14:04:59 +01:00
parent 0a2b6bee40
commit 2886109347
Signed by: daniel
GPG key ID: FBB4F0E80A80222F
7 changed files with 276 additions and 45 deletions

1
.gitignore vendored
View file

@ -25,3 +25,4 @@ _build/
/.ghtopdep_cache/
/worktrees/
.ruff_cache/
.python-version

View file

@ -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

View file

@ -1 +1 @@
__version__ = '2.8.0'
__version__ = '2.8.2'

View file

@ -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)

View file

@ -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

View file

@ -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)

View file

@ -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)