Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added Time parsing to pendulum_dt #288

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 65 additions & 2 deletions pydantic_extra_types/pendulum_dt.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,13 @@
from pendulum import Date as _Date
from pendulum import DateTime as _DateTime
from pendulum import Duration as _Duration
from pendulum import Time as _Time
from pendulum import parse
except ModuleNotFoundError as e: # pragma: no cover
raise RuntimeError(
'The `pendulum_dt` module requires "pendulum" to be installed. You can install it with "pip install pendulum".'
) from e
from datetime import date, datetime, timedelta
from datetime import date, datetime, timedelta, time
from typing import Any

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


class Time(_Time):
"""A `pendulum.Time` object. At runtime, this type decomposes into pendulum.Time automatically.
This type exists because Pydantic throws a fit on unknown types.

```python
from pydantic import BaseModel
from pydantic_extra_types.pendulum_dt import Time


class test_model(BaseModel):
dt: Time


print(test_model(dt='00:00:00'))

# > test_model(dt=Time(0, 0, 0))
```
"""

__slots__: list[str] = []

@classmethod
def __get_pydantic_core_schema__(cls, source: type[Any], handler: GetCoreSchemaHandler) -> core_schema.CoreSchema:
"""Return a Pydantic CoreSchema with the Time validation

Args:
source: The source type to be converted.
handler: The handler to get the CoreSchema.

Returns:
A Pydantic CoreSchema with the Time validation.
"""
return core_schema.no_info_wrap_validator_function(cls._validate, core_schema.time_schema())

@classmethod
def _validate(cls, value: Any, handler: core_schema.ValidatorFunctionWrapHandler) -> Time:
"""Validate the Time object and return it.

Args:
value: The value to validate.
handler: The handler to get the CoreSchema.

Returns:
The validated value or raises a PydanticCustomError.
"""
# if we are passed an existing instance, pass it straight through.
if isinstance(value, (_Time, time)):
return Time.instance(value, tz=value.tzinfo)

# otherwise, parse it.
try:
parsed = parse(value, exact=True)
if isinstance(parsed, _DateTime):
dt = DateTime.instance(parsed)
return Time.instance(dt.time())
if isinstance(parsed, _Time):
return Time.instance(parsed)
raise ValueError(f'value is not a valid time it is a {type(parsed)}')
except Exception as exc:
raise PydanticCustomError('value_error', 'value is not a valid time') from exc


class Date(_Date):
"""A `pendulum.Date` object. At runtime, this type decomposes into pendulum.Date automatically.
This type exists because Pydantic throws a fit on unknown types.
Expand Down Expand Up @@ -149,7 +212,7 @@ def _validate(cls, value: Any, handler: core_schema.ValidatorFunctionWrapHandler
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)}')
raise ValueError(f'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

Expand Down
100 changes: 98 additions & 2 deletions tests/test_pendulum_dt.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
from datetime import date, datetime, timedelta
from datetime import date, datetime, timedelta, time
from datetime import timezone as tz

import pendulum
import pytest
from pydantic import BaseModel, TypeAdapter, ValidationError

from pydantic_extra_types.pendulum_dt import Date, DateTime, Duration
from pydantic_extra_types.pendulum_dt import Date, DateTime, Duration, Time

UTC = tz.utc

Expand All @@ -16,6 +16,10 @@ class DtModel(BaseModel):
dt: DateTime


class TimeModel(BaseModel):
t: Time


class DateTimeNonStrict(DateTime, strict=False):
pass

Expand Down Expand Up @@ -334,6 +338,95 @@ def test_pendulum_dt_non_strict_malformed(dt):
DtModelNotStrict(dt=dt)


@pytest.mark.parametrize(
'instance',
[
pendulum.now().time(),
datetime.now().time(),
datetime.now(UTC).time(),
],
)
def test_existing_time_instance(instance):
"""Verifies that constructing a model with an existing pendulum time doesn't throw."""
model = TimeModel(t=instance)
if isinstance(instance, pendulum.Time):
assert model.t == instance
t = model.t
else:
assert model.t.replace(tzinfo=UTC) == pendulum.instance(instance) # pendulum defaults to UTC
t = model.t

assert t.hour == instance.hour
assert t.minute == instance.minute
assert t.second == instance.second
assert t.microsecond == instance.microsecond
assert isinstance(t, pendulum.Time)
assert type(t) is Time
if t.tzinfo != instance.tzinfo:
date = Date(2022, 1, 22)
assert t.tzinfo.utcoffset(DateTime.combine(date, t)) == instance.tzinfo.utcoffset(DateTime.combine(date, instance))


@pytest.mark.parametrize(
'dt',
[
"17:53:12.266369",
"17:53:46",
],
)
def test_pendulum_time_from_serialized(dt):
"""Verifies that building an instance from serialized, well-formed strings decode properly."""
dt_actual = pendulum.parse(dt, exact=True)
model = TimeModel(t=dt)
assert model.t == dt_actual.replace(tzinfo=UTC)
assert type(model.t) is Time
assert isinstance(model.t, pendulum.Time)


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_time_malformed(dt):
"""Verifies that the instance fails to validate if malformed time is passed."""
with pytest.raises(ValidationError):
TimeModel(t=dt)


@pytest.mark.parametrize(
'invalid_value',
[None, 'malformed', pendulum.today().to_iso8601_string()[:5], 'P10Y10M10D'],
Expand Down Expand Up @@ -367,6 +460,9 @@ def test_pendulum_duration_malformed(delta_t):
(Date, '2021-01-01', pendulum.Date),
(Date, date(2021, 1, 1), pendulum.Date),
(Date, pendulum.date(2021, 1, 1), pendulum.Date),
(Time, '12:00:00', pendulum.Time),
(Time, time(12, 0, 0), pendulum.Time),
(Time, pendulum.time(12, 0, 0), pendulum.Time),
(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),
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading