Skip to content

Commit

Permalink
Add parsing of pendulum_dt from unix time and non-strict parsing
Browse files Browse the repository at this point in the history
* improve type checking in pendulum_dt.py
* added tests
  • Loading branch information
07pepa committed Jun 11, 2024
1 parent 7097d98 commit a37529e
Show file tree
Hide file tree
Showing 3 changed files with 175 additions and 16 deletions.
38 changes: 27 additions & 11 deletions pydantic_extra_types/pendulum_dt.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,17 @@
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.
Expand Down Expand Up @@ -54,7 +64,7 @@ def __get_pydantic_core_schema__(cls, source: Type[Any], handler: GetCoreSchemaH
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.
Expand All @@ -68,12 +78,10 @@ def _validate(cls, value: Any, handler: core_schema.ValidatorFunctionWrapHandler
# if we are passed an existing instance, pass it straight through.
if isinstance(value, (_DateTime, datetime)):
return DateTime.instance(value)

# otherwise, parse it.
try:
value = parse(value, exact=True)
if not isinstance(value, _DateTime):
raise ValueError(f'value is not a valid datetime it is a {type(value)}')
# probably the best way to have feature parity with
# https://docs.pydantic.dev/latest/api/standard_library_types/#datetimedatetime
value = handler(value)
return DateTime(
value.year,
value.month,
Expand All @@ -84,8 +92,16 @@ def _validate(cls, value: Any, handler: core_schema.ValidatorFunctionWrapHandler
value.microsecond,
value.tzinfo,
)
except Exception as exc:
raise PydanticCustomError('value_error', 'value is not a valid timestamp') from exc
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):
Expand Down Expand Up @@ -123,7 +139,7 @@ def __get_pydantic_core_schema__(cls, source: Type[Any], handler: GetCoreSchemaH
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.
Expand Down Expand Up @@ -183,7 +199,7 @@ def __get_pydantic_core_schema__(cls, source: Type[Any], handler: GetCoreSchemaH
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.
Expand Down
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
151 changes: 146 additions & 5 deletions tests/test_pendulum_dt.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,21 @@

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

Expand Down Expand Up @@ -119,6 +129,91 @@ def test_pendulum_dt_from_serialized(dt):
assert isinstance(model.dt, pendulum.DateTime)


@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.
"""
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()],
Expand Down Expand Up @@ -155,22 +250,68 @@ def test_pendulum_duration_from_serialized(delta_t_str):
assert isinstance(model.delta_t, pendulum.Duration)


@pytest.mark.parametrize('dt', [None, 'malformed', pendulum.now().to_iso8601_string()[:5], 42, 'P10Y10M10D'])
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, 'P10Y10M10D'])
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(
Expand Down

0 comments on commit a37529e

Please sign in to comment.