Skip to content

Commit ab03254

Browse files
yezz123Woody1193
andauthored
✨ Add time parsing to pendulum (#331)
* Added Time parsing to pendulum_dt * ♻️ fix formating issue --------- Co-authored-by: Ryan Wood <[email protected]>
1 parent 5ed9422 commit ab03254

File tree

3 files changed

+166
-5
lines changed

3 files changed

+166
-5
lines changed

pydantic_extra_types/pendulum_dt.py

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,13 @@
88
from pendulum import Date as _Date
99
from pendulum import DateTime as _DateTime
1010
from pendulum import Duration as _Duration
11+
from pendulum import Time as _Time
1112
from pendulum import parse
1213
except ModuleNotFoundError as e: # pragma: no cover
1314
raise RuntimeError(
1415
'The `pendulum_dt` module requires "pendulum" to be installed. You can install it with "pip install pendulum".'
1516
) from e
16-
from datetime import date, datetime, timedelta
17+
from datetime import date, datetime, time, timedelta
1718
from typing import Any
1819

1920
from pydantic import GetCoreSchemaHandler
@@ -95,6 +96,68 @@ def _validate(cls, value: Any, handler: core_schema.ValidatorFunctionWrapHandler
9596
raise PydanticCustomError('value_error', 'value is not a valid datetime') from exc
9697

9798

99+
class Time(_Time):
100+
"""A `pendulum.Time` object. At runtime, this type decomposes into pendulum.Time automatically.
101+
This type exists because Pydantic throws a fit on unknown types.
102+
103+
```python
104+
from pydantic import BaseModel
105+
from pydantic_extra_types.pendulum_dt import Time
106+
107+
108+
class test_model(BaseModel):
109+
dt: Time
110+
111+
112+
print(test_model(dt='00:00:00'))
113+
114+
# > test_model(dt=Time(0, 0, 0))
115+
```
116+
"""
117+
118+
__slots__: list[str] = []
119+
120+
@classmethod
121+
def __get_pydantic_core_schema__(cls, source: type[Any], handler: GetCoreSchemaHandler) -> core_schema.CoreSchema:
122+
"""Return a Pydantic CoreSchema with the Time validation
123+
124+
Args:
125+
source: The source type to be converted.
126+
handler: The handler to get the CoreSchema.
127+
128+
Returns:
129+
A Pydantic CoreSchema with the Time validation.
130+
"""
131+
return core_schema.no_info_wrap_validator_function(cls._validate, core_schema.time_schema())
132+
133+
@classmethod
134+
def _validate(cls, value: Any, handler: core_schema.ValidatorFunctionWrapHandler) -> Time:
135+
"""Validate the Time object and return it.
136+
137+
Args:
138+
value: The value to validate.
139+
handler: The handler to get the CoreSchema.
140+
141+
Returns:
142+
The validated value or raises a PydanticCustomError.
143+
"""
144+
# if we are passed an existing instance, pass it straight through.
145+
if isinstance(value, (_Time, time)):
146+
return Time.instance(value, tz=value.tzinfo)
147+
148+
# otherwise, parse it.
149+
try:
150+
parsed = parse(value, exact=True)
151+
if isinstance(parsed, _DateTime):
152+
dt = DateTime.instance(parsed)
153+
return Time.instance(dt.time())
154+
if isinstance(parsed, _Time):
155+
return Time.instance(parsed)
156+
raise ValueError(f'value is not a valid time it is a {type(parsed)}')
157+
except Exception as exc:
158+
raise PydanticCustomError('value_error', 'value is not a valid time') from exc
159+
160+
98161
class Date(_Date):
99162
"""A `pendulum.Date` object. At runtime, this type decomposes into pendulum.Date automatically.
100163
This type exists because Pydantic throws a fit on unknown types.
@@ -149,7 +212,7 @@ def _validate(cls, value: Any, handler: core_schema.ValidatorFunctionWrapHandler
149212
parsed = parse(value)
150213
if isinstance(parsed, (_DateTime, _Date)):
151214
return Date(parsed.year, parsed.month, parsed.day)
152-
raise ValueError('value is not a valid date it is a {type(parsed)}')
215+
raise ValueError(f'value is not a valid date it is a {type(parsed)}')
153216
except Exception as exc:
154217
raise PydanticCustomError('value_error', 'value is not a valid date') from exc
155218

tests/test_pendulum_dt.py

Lines changed: 100 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1-
from datetime import date, datetime, timedelta
1+
from datetime import date, datetime, time, timedelta
22
from datetime import timezone as tz
33

44
import pendulum
55
import pytest
66
from pydantic import BaseModel, TypeAdapter, ValidationError
77

8-
from pydantic_extra_types.pendulum_dt import Date, DateTime, Duration
8+
from pydantic_extra_types.pendulum_dt import Date, DateTime, Duration, Time
99

1010
UTC = tz.utc
1111

@@ -16,6 +16,10 @@ class DtModel(BaseModel):
1616
dt: DateTime
1717

1818

19+
class TimeModel(BaseModel):
20+
t: Time
21+
22+
1923
class DateTimeNonStrict(DateTime, strict=False):
2024
pass
2125

@@ -362,6 +366,97 @@ def test_pendulum_dt_non_strict_malformed(dt):
362366
DtModelNotStrict(dt=dt)
363367

364368

369+
@pytest.mark.parametrize(
370+
'instance',
371+
[
372+
pendulum.now().time(),
373+
datetime.now().time(),
374+
datetime.now(UTC).time(),
375+
],
376+
)
377+
def test_existing_time_instance(instance):
378+
"""Verifies that constructing a model with an existing pendulum time doesn't throw."""
379+
model = TimeModel(t=instance)
380+
if isinstance(instance, pendulum.Time):
381+
assert model.t == instance
382+
t = model.t
383+
else:
384+
assert model.t.replace(tzinfo=UTC) == pendulum.instance(instance) # pendulum defaults to UTC
385+
t = model.t
386+
387+
assert t.hour == instance.hour
388+
assert t.minute == instance.minute
389+
assert t.second == instance.second
390+
assert t.microsecond == instance.microsecond
391+
assert isinstance(t, pendulum.Time)
392+
assert type(t) is Time
393+
if t.tzinfo != instance.tzinfo:
394+
date = Date(2022, 1, 22)
395+
assert t.tzinfo.utcoffset(DateTime.combine(date, t)) == instance.tzinfo.utcoffset(
396+
DateTime.combine(date, instance)
397+
)
398+
399+
400+
@pytest.mark.parametrize(
401+
'dt',
402+
[
403+
'17:53:12.266369',
404+
'17:53:46',
405+
],
406+
)
407+
def test_pendulum_time_from_serialized(dt):
408+
"""Verifies that building an instance from serialized, well-formed strings decode properly."""
409+
dt_actual = pendulum.parse(dt, exact=True)
410+
model = TimeModel(t=dt)
411+
assert model.t == dt_actual.replace(tzinfo=UTC)
412+
assert type(model.t) is Time
413+
assert isinstance(model.t, pendulum.Time)
414+
415+
416+
def get_invalid_dt_common():
417+
return [
418+
None,
419+
'malformed',
420+
'P10Y10M10D',
421+
float('inf'),
422+
float('-inf'),
423+
'inf',
424+
'-inf',
425+
'INF',
426+
'-INF',
427+
'+inf',
428+
'Infinity',
429+
'+Infinity',
430+
'-Infinity',
431+
'INFINITY',
432+
'+INFINITY',
433+
'-INFINITY',
434+
'infinity',
435+
'+infinity',
436+
'-infinity',
437+
float('nan'),
438+
'nan',
439+
'NaN',
440+
'NAN',
441+
'+nan',
442+
'-nan',
443+
]
444+
445+
446+
dt_strict = get_invalid_dt_common()
447+
dt_strict.append(pendulum.now().to_iso8601_string()[:5])
448+
449+
450+
@pytest.mark.parametrize(
451+
'dt',
452+
dt_strict,
453+
)
454+
def test_pendulum_time_malformed(dt):
455+
"""Verifies that the instance fails to validate if malformed time is passed."""
456+
with pytest.raises(ValidationError):
457+
TimeModel(t=dt)
458+
459+
365460
@pytest.mark.parametrize(
366461
'invalid_value',
367462
[None, 'malformed', pendulum.today().to_iso8601_string()[:5], 'P10Y10M10D'],
@@ -395,6 +490,9 @@ def test_pendulum_duration_malformed(delta_t):
395490
(Date, '2021-01-01', pendulum.Date),
396491
(Date, date(2021, 1, 1), pendulum.Date),
397492
(Date, pendulum.date(2021, 1, 1), pendulum.Date),
493+
(Time, '12:00:00', pendulum.Time),
494+
(Time, time(12, 0, 0), pendulum.Time),
495+
(Time, pendulum.time(12, 0, 0), pendulum.Time),
398496
(DateTime, '2021-01-01T12:00:00', pendulum.DateTime),
399497
(DateTime, datetime(2021, 1, 1, 12, 0, 0), pendulum.DateTime),
400498
(DateTime, pendulum.datetime(2021, 1, 1, 12, 0, 0), pendulum.DateTime),

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)