Skip to content

Commit

Permalink
✨ Support Pendulum Datetime to pydantic-extra-types (#110)
Browse files Browse the repository at this point in the history
* [#112] add pendulum dt support

* [#112] Add pendulum to testing requirements

* ♻️ update requirements

* 📝 fix documentation

* 🐛 add test case for JSON schema

---------

Co-authored-by: Yasser Tahiri <[email protected]>
  • Loading branch information
theunkn0wn1 and yezz123 authored Jan 25, 2024
1 parent 2c16086 commit 4f89f75
Show file tree
Hide file tree
Showing 7 changed files with 139 additions and 6 deletions.
74 changes: 74 additions & 0 deletions pydantic_extra_types/pendulum_dt.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
"""
Native Pendulum DateTime object implementation. This is a copy of the Pendulum DateTime object, but with a Pydantic
CoreSchema implementation. This allows Pydantic to validate the DateTime object.
"""

try:
from pendulum import DateTime as _DateTime
from pendulum import parse
except ModuleNotFoundError: # pragma: no cover
raise RuntimeError(
'The `pendulum_dt` module requires "pendulum" to be installed. You can install it with "pip install pendulum".'
)
from typing import Any, List, Type

from pydantic import GetCoreSchemaHandler
from pydantic_core import PydanticCustomError, core_schema


class DateTime(_DateTime):
"""
A `pendulum.DateTime` object. At runtime, this type decomposes into pendulum.DateTime automatically.
This type exists because Pydantic throws a fit on unknown types.
```python
from pydantic import BaseModel
from pydantic_extra_types.pendulum_dt import DateTime
class test_model(BaseModel):
dt: DateTime
print(test_model(dt='2021-01-01T00:00:00+00:00'))
#> test_model(dt=DateTime(2021, 1, 1, 0, 0, 0, tzinfo=FixedTimezone(0, name="+00:00")))
```
"""

__slots__: List[str] = []

@classmethod
def __get_pydantic_core_schema__(cls, source: Type[Any], handler: GetCoreSchemaHandler) -> core_schema.CoreSchema:
"""
Return a Pydantic CoreSchema with the Datetime validation
Args:
source: The source type to be converted.
handler: The handler to get the CoreSchema.
Returns:
A Pydantic CoreSchema with the Datetime validation.
"""
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:
"""
Validate the datetime 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, _DateTime):
return handler(value)

# otherwise, parse it.
try:
data = parse(value)
except Exception as exc:
raise PydanticCustomError('value_error', 'value is not a valid timestamp') from exc
return handler(data)
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ all = [
'phonenumbers>=8,<9',
'pycountry>=23,<24',
'python-ulid>=1,<2',
'pendulum>=3.0.0,<4.0.0'
]

[project.urls]
Expand Down
2 changes: 1 addition & 1 deletion requirements/linting.txt
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ pyupgrade==3.15.0
# via -r requirements/linting.in
pyyaml==6.0.1
# via pre-commit
ruff==0.1.11
ruff==0.1.14
# via -r requirements/linting.in
tokenize-rt==5.2.0
# via pyupgrade
Expand Down
17 changes: 13 additions & 4 deletions requirements/pyproject.txt
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,29 @@
#
annotated-types==0.6.0
# via pydantic
phonenumbers==8.13.27
pendulum==3.0.0
# via pydantic-extra-types (pyproject.toml)
phonenumbers==8.13.28
# via pydantic-extra-types (pyproject.toml)
pycountry==23.12.11
# via pydantic-extra-types (pyproject.toml)
pydantic==2.5.3
# via pydantic-extra-types (pyproject.toml)
pydantic-core==2.14.6
# via pydantic
python-dateutil==2.8.2
# via
# pendulum
# time-machine
python-ulid==1.1.0
# via pydantic-extra-types (pyproject.toml)
six==1.16.0
# via python-dateutil
time-machine==2.13.0
# via pendulum
typing-extensions==4.9.0
# via
# pydantic
# pydantic-core

# The following packages are considered to be unsafe in a requirements file:
# setuptools
tzdata==2023.4
# via pendulum
2 changes: 1 addition & 1 deletion requirements/testing.txt
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ mdurl==0.1.2
# via markdown-it-py
packaging==23.2
# via pytest
pluggy==1.3.0
pluggy==1.4.0
# via pytest
pygments==2.17.2
# via rich
Expand Down
10 changes: 10 additions & 0 deletions tests/test_json_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from pydantic_extra_types.isbn import ISBN
from pydantic_extra_types.mac_address import MacAddress
from pydantic_extra_types.payment import PaymentCardNumber
from pydantic_extra_types.pendulum_dt import DateTime
from pydantic_extra_types.ulid import ULID


Expand Down Expand Up @@ -190,6 +191,15 @@
'type': 'object',
},
),
(
DateTime,
{
'properties': {'x': {'format': 'date-time', 'title': 'X', 'type': 'string'}},
'required': ['x'],
'title': 'Model',
'type': 'object',
},
),
],
)
def test_json_schema(cls, expected):
Expand Down
39 changes: 39 additions & 0 deletions tests/test_pendulum_dt.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import pendulum
import pytest
from pydantic import BaseModel, ValidationError

from pydantic_extra_types.pendulum_dt import DateTime


class Model(BaseModel):
dt: DateTime


def test_pendulum_dt_existing_instance():
"""
Verifies that constructing a model with an existing pendulum dt doesn't throw.
"""
now = pendulum.now()
model = Model(dt=now)
assert model.dt == now


@pytest.mark.parametrize(
'dt', [pendulum.now().to_iso8601_string(), pendulum.now().to_w3c_string(), pendulum.now().to_iso8601_string()]
)
def test_pendulum_dt_from_serialized(dt):
"""
Verifies that building an instance from serialized, well-formed strings decode properly.
"""
dt_actual = pendulum.parse(dt)
model = Model(dt=dt)
assert model.dt == dt_actual


@pytest.mark.parametrize('dt', [None, 'malformed', pendulum.now().to_iso8601_string()[:5], 42])
def test_pendulum_dt_malformed(dt):
"""
Verifies that the instance fails to validate if malformed dt are passed.
"""
with pytest.raises(ValidationError):
Model(dt=dt)

0 comments on commit 4f89f75

Please sign in to comment.