diff --git a/pydantic_extra_types/pendulum_dt.py b/pydantic_extra_types/pendulum_dt.py new file mode 100644 index 00000000..f507779d --- /dev/null +++ b/pydantic_extra_types/pendulum_dt.py @@ -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) diff --git a/pyproject.toml b/pyproject.toml index cc51c1ba..e2a2a180 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,6 +48,7 @@ all = [ 'phonenumbers>=8,<9', 'pycountry>=23,<24', 'python-ulid>=1,<2', + 'pendulum>=3.0.0,<4.0.0' ] [project.urls] diff --git a/requirements/linting.txt b/requirements/linting.txt index de749627..d4367dab 100644 --- a/requirements/linting.txt +++ b/requirements/linting.txt @@ -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 diff --git a/requirements/pyproject.txt b/requirements/pyproject.txt index 2b7d35c8..28e624be 100644 --- a/requirements/pyproject.txt +++ b/requirements/pyproject.txt @@ -6,7 +6,9 @@ # 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) @@ -14,12 +16,19 @@ 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 diff --git a/requirements/testing.txt b/requirements/testing.txt index 9d38f1f9..07387d57 100644 --- a/requirements/testing.txt +++ b/requirements/testing.txt @@ -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 diff --git a/tests/test_json_schema.py b/tests/test_json_schema.py index 8508e87a..9bc5cf0d 100644 --- a/tests/test_json_schema.py +++ b/tests/test_json_schema.py @@ -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 @@ -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): diff --git a/tests/test_pendulum_dt.py b/tests/test_pendulum_dt.py new file mode 100644 index 00000000..31306d77 --- /dev/null +++ b/tests/test_pendulum_dt.py @@ -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)