Skip to content

Commit 746de18

Browse files
07pepa07pepa
and
07pepa
authored
✨ Add parsing of pendulum_dt from unix time and non-strict parsing (#185)
* improve type checking in pendulum_dt.py * added tests Co-authored-by: 07pepa <pepe@wont_share.com>
1 parent 7097d98 commit 746de18

File tree

3 files changed

+175
-16
lines changed

3 files changed

+175
-16
lines changed

pydantic_extra_types/pendulum_dt.py

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,17 @@
1919
from pydantic_core import PydanticCustomError, core_schema
2020

2121

22-
class DateTime(_DateTime):
22+
class DateTimeSettings(type):
23+
def __new__(cls, name, bases, dct, **kwargs): # type: ignore[no-untyped-def]
24+
dct['strict'] = kwargs.pop('strict', True)
25+
return super().__new__(cls, name, bases, dct)
26+
27+
def __init__(cls, name, bases, dct, **kwargs): # type: ignore[no-untyped-def]
28+
super().__init__(name, bases, dct)
29+
cls.strict = kwargs.get('strict', True)
30+
31+
32+
class DateTime(_DateTime, metaclass=DateTimeSettings):
2333
"""
2434
A `pendulum.DateTime` object. At runtime, this type decomposes into pendulum.DateTime automatically.
2535
This type exists because Pydantic throws a fit on unknown types.
@@ -54,7 +64,7 @@ def __get_pydantic_core_schema__(cls, source: Type[Any], handler: GetCoreSchemaH
5464
return core_schema.no_info_wrap_validator_function(cls._validate, core_schema.datetime_schema())
5565

5666
@classmethod
57-
def _validate(cls, value: Any, handler: core_schema.ValidatorFunctionWrapHandler) -> Any:
67+
def _validate(cls, value: Any, handler: core_schema.ValidatorFunctionWrapHandler) -> 'DateTime':
5868
"""
5969
Validate the datetime object and return it.
6070
@@ -68,12 +78,10 @@ def _validate(cls, value: Any, handler: core_schema.ValidatorFunctionWrapHandler
6878
# if we are passed an existing instance, pass it straight through.
6979
if isinstance(value, (_DateTime, datetime)):
7080
return DateTime.instance(value)
71-
72-
# otherwise, parse it.
7381
try:
74-
value = parse(value, exact=True)
75-
if not isinstance(value, _DateTime):
76-
raise ValueError(f'value is not a valid datetime it is a {type(value)}')
82+
# probably the best way to have feature parity with
83+
# https://docs.pydantic.dev/latest/api/standard_library_types/#datetimedatetime
84+
value = handler(value)
7785
return DateTime(
7886
value.year,
7987
value.month,
@@ -84,8 +92,16 @@ def _validate(cls, value: Any, handler: core_schema.ValidatorFunctionWrapHandler
8492
value.microsecond,
8593
value.tzinfo,
8694
)
87-
except Exception as exc:
88-
raise PydanticCustomError('value_error', 'value is not a valid timestamp') from exc
95+
except ValueError:
96+
try:
97+
value = parse(value, strict=cls.strict)
98+
if isinstance(value, _DateTime):
99+
return DateTime.instance(value)
100+
raise ValueError(f'value is not a valid datetime it is a {type(value)}')
101+
except ValueError:
102+
raise
103+
except Exception as exc:
104+
raise PydanticCustomError('value_error', 'value is not a valid datetime') from exc
89105

90106

91107
class Date(_Date):
@@ -123,7 +139,7 @@ def __get_pydantic_core_schema__(cls, source: Type[Any], handler: GetCoreSchemaH
123139
return core_schema.no_info_wrap_validator_function(cls._validate, core_schema.date_schema())
124140

125141
@classmethod
126-
def _validate(cls, value: Any, handler: core_schema.ValidatorFunctionWrapHandler) -> Any:
142+
def _validate(cls, value: Any, handler: core_schema.ValidatorFunctionWrapHandler) -> 'Date':
127143
"""
128144
Validate the date object and return it.
129145
@@ -183,7 +199,7 @@ def __get_pydantic_core_schema__(cls, source: Type[Any], handler: GetCoreSchemaH
183199
return core_schema.no_info_wrap_validator_function(cls._validate, core_schema.timedelta_schema())
184200

185201
@classmethod
186-
def _validate(cls, value: Any, handler: core_schema.ValidatorFunctionWrapHandler) -> Any:
202+
def _validate(cls, value: Any, handler: core_schema.ValidatorFunctionWrapHandler) -> 'Duration':
187203
"""
188204
Validate the Duration object and return it.
189205

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,8 @@ filterwarnings = [
119119
'error',
120120
# This ignore will be removed when pycountry will drop py36 & support py311
121121
'ignore:::pkg_resources',
122+
# This ignore will be removed when pendulum fixes https://github.com/sdispater/pendulum/issues/834
123+
'ignore:datetime.datetime.utcfromtimestamp.*:DeprecationWarning'
122124
]
123125

124126
# configuring https://github.com/pydantic/hooky

tests/test_pendulum_dt.py

Lines changed: 146 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,21 @@
99

1010
UTC = tz.utc
1111

12+
DtTypeAdapter = TypeAdapter(datetime)
13+
1214

1315
class DtModel(BaseModel):
1416
dt: DateTime
1517

1618

19+
class DateTimeNonStrict(DateTime, strict=False):
20+
pass
21+
22+
23+
class DtModelNotStrict(BaseModel):
24+
dt: DateTimeNonStrict
25+
26+
1727
class DateModel(BaseModel):
1828
d: Date
1929

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

121131

132+
@pytest.mark.parametrize(
133+
'dt',
134+
[
135+
pendulum.now().to_iso8601_string(),
136+
pendulum.now().to_w3c_string(),
137+
'Sat Oct 11 17:13:46 UTC 2003', # date util parsing
138+
pendulum.now().to_iso8601_string()[:5], # actualy valid or pendulum.parse(dt, strict=False) would fail here
139+
],
140+
)
141+
def test_pendulum_dt_not_strict_from_serialized(dt):
142+
"""
143+
Verifies that building an instance from serialized, well-formed strings decode properly.
144+
"""
145+
dt_actual = pendulum.parse(dt, strict=False)
146+
model = DtModelNotStrict(dt=dt)
147+
assert model.dt == dt_actual
148+
assert type(model.dt) is DateTime
149+
assert isinstance(model.dt, pendulum.DateTime)
150+
151+
152+
@pytest.mark.parametrize(
153+
'dt',
154+
[
155+
pendulum.now().to_iso8601_string(),
156+
pendulum.now().to_w3c_string(),
157+
1718096578,
158+
1718096578.5,
159+
-5,
160+
-5.5,
161+
float('-0'),
162+
'1718096578',
163+
'1718096578.5',
164+
'-5',
165+
'-5.5',
166+
'-0',
167+
'-0.0',
168+
'+0.0',
169+
'+1718096578.5',
170+
float('-2e10') - 1.0,
171+
float('2e10') + 1.0,
172+
-2e10 - 1,
173+
2e10 + 1,
174+
],
175+
)
176+
def test_pendulum_dt_from_str_unix_timestamp(dt):
177+
"""
178+
Verifies that building an instance from serialized, well-formed strings decode properly.
179+
"""
180+
dt_actual = pendulum.instance(DtTypeAdapter.validate_python(dt))
181+
model = DtModel(dt=dt)
182+
assert model.dt == dt_actual
183+
assert type(model.dt) is DateTime
184+
assert isinstance(model.dt, pendulum.DateTime)
185+
186+
187+
@pytest.mark.parametrize(
188+
'dt',
189+
[
190+
1718096578,
191+
1718096578.5,
192+
-5,
193+
-5.5,
194+
float('-0'),
195+
'1718096578',
196+
'1718096578.5',
197+
'-5',
198+
'-5.5',
199+
'-0',
200+
'-0.0',
201+
'+0.0',
202+
'+1718096578.5',
203+
float('-2e10') - 1.0,
204+
float('2e10') + 1.0,
205+
-2e10 - 1,
206+
2e10 + 1,
207+
],
208+
)
209+
def test_pendulum_dt_from_str_unix_timestamp_is_utc(dt):
210+
"""
211+
Verifies that without timezone information, it is coerced to UTC. As in pendulum
212+
"""
213+
model = DtModel(dt=dt)
214+
assert model.dt.tzinfo.tzname(model.dt) == 'UTC'
215+
216+
122217
@pytest.mark.parametrize(
123218
'd',
124219
[pendulum.now().date().isoformat(), pendulum.now().to_w3c_string(), pendulum.now().to_iso8601_string()],
@@ -155,22 +250,68 @@ def test_pendulum_duration_from_serialized(delta_t_str):
155250
assert isinstance(model.delta_t, pendulum.Duration)
156251

157252

158-
@pytest.mark.parametrize('dt', [None, 'malformed', pendulum.now().to_iso8601_string()[:5], 42, 'P10Y10M10D'])
253+
def get_invalid_dt_common():
254+
return [
255+
None,
256+
'malformed',
257+
'P10Y10M10D',
258+
float('inf'),
259+
float('-inf'),
260+
'inf',
261+
'-inf',
262+
'INF',
263+
'-INF',
264+
'+inf',
265+
'Infinity',
266+
'+Infinity',
267+
'-Infinity',
268+
'INFINITY',
269+
'+INFINITY',
270+
'-INFINITY',
271+
'infinity',
272+
'+infinity',
273+
'-infinity',
274+
float('nan'),
275+
'nan',
276+
'NaN',
277+
'NAN',
278+
'+nan',
279+
'-nan',
280+
]
281+
282+
283+
dt_strict = get_invalid_dt_common()
284+
dt_strict.append(pendulum.now().to_iso8601_string()[:5])
285+
286+
287+
@pytest.mark.parametrize(
288+
'dt',
289+
dt_strict,
290+
)
159291
def test_pendulum_dt_malformed(dt):
160292
"""
161-
Verifies that the instance fails to validate if malformed dt are passed.
293+
Verifies that the instance fails to validate if malformed dt is passed.
162294
"""
163295
with pytest.raises(ValidationError):
164296
DtModel(dt=dt)
165297

166298

167-
@pytest.mark.parametrize('date', [None, 'malformed', pendulum.today().to_iso8601_string()[:5], 42, 'P10Y10M10D'])
168-
def test_pendulum_date_malformed(date):
299+
@pytest.mark.parametrize('dt', get_invalid_dt_common())
300+
def test_pendulum_dt_non_strict_malformed(dt):
301+
"""
302+
Verifies that the instance fails to validate if malformed dt are passed.
303+
"""
304+
with pytest.raises(ValidationError):
305+
DtModelNotStrict(dt=dt)
306+
307+
308+
@pytest.mark.parametrize('invalid_value', [None, 'malformed', pendulum.today().to_iso8601_string()[:5], 'P10Y10M10D'])
309+
def test_pendulum_date_malformed(invalid_value):
169310
"""
170311
Verifies that the instance fails to validate if malformed date are passed.
171312
"""
172313
with pytest.raises(ValidationError):
173-
DateModel(d=date)
314+
DateModel(d=invalid_value)
174315

175316

176317
@pytest.mark.parametrize(

0 commit comments

Comments
 (0)