Skip to content
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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ jobs:
uses: actions/setup-python@v5
with:
# Keep in sync with version in .readthedocs.yml.
python-version: "3.11"
python-version: "3.12"
cache: pip
cache-dependency-path: docs-requirements.txt
- name: Install dependencies
Expand Down
2 changes: 1 addition & 1 deletion .readthedocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ build:
os: ubuntu-22.04
tools:
# Keep in sync with docs build CI job.
python: "3.11"
python: "3.12"
python:
install:
- requirements: docs-requirements.txt
Expand Down
21 changes: 12 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,9 @@ support][pydantic-support] out-of-the-box. Subclasses of `Phantom` work with bot
pydantic's validation and its schema generation.

```python
import json


class Name(str, Phantom, predicate=contained({"Jane", "Joe"})):
@classmethod
def __schema__(cls) -> Schema:
Expand All @@ -139,30 +142,30 @@ class Person(BaseModel):
created: TZAware


print(json.dumps(Person.schema(), indent=2))
print(json.dumps(Person.model_json_schema(), indent=2))
```

The code above outputs the following JSONSchema.

```json
{
"title": "Person",
"type": "object",
"properties": {
"name": {
"title": "Name",
"description": "Either Jane or Joe",
"format": "custom-name",
"title": "Name",
"type": "string"
},
"created": {
"title": "TZAware",
"description": "A date-time with timezone data.",
"type": "string",
"format": "date-time"
"format": "date-time",
"title": "TZAware",
"type": "string"
}
},
"required": ["name", "created"]
"required": ["name", "created"],
"title": "Person",
"type": "object"
}
```

Expand Down Expand Up @@ -213,7 +216,7 @@ $ make test-typing
[typeguard]: https://github.com/agronholm/typeguard
[beartype]: https://github.com/beartype/beartype
[dbc]: https://en.wikipedia.org/wiki/Design_by_contract
[pydantic]: https://pydantic-docs.helpmanual.io/
[pydantic]: https://docs.pydantic.dev
[pydantic-support]:
https://phantom-types.readthedocs.io/en/stable/pages/pydantic-support.html
[goose]: https://github.com/antonagestam/goose
Expand Down
259 changes: 172 additions & 87 deletions docs-requirements.txt

Large diffs are not rendered by default.

19 changes: 13 additions & 6 deletions docs/pages/pydantic-support.rst
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
Pydantic Support
================

phantom-types supports pydantic_ out of the box by providing a
:func:`__get_validators__() <phantom.Phantom.__get_validators__>` hook
on the base :class:`Phantom <phantom.Phantom>` class. Most of the shipped types also
implements full JSON Schema and OpenAPI support.
phantom-types supports pydantic_ out of the box by providing
:func:`__get_pydantic_core_schema__() <phantom.Phantom.__get_pydantic_core_schema__>`
and :func:`__get_pydantic_json_schema__() <phantom.schema.SchemaField.__get_pydantic_json_schema__>`
hooks on the base :class:`Phantom <phantom.Phantom>` class. Most of the shipped types also
implement full JSON Schema and OpenAPI support.

.. _pydantic: https://pydantic-docs.helpmanual.io/
.. _pydantic: https://docs.pydantic.dev/

To make a phantom type compatible with pydantic, all you need to do is override
To customize the JSON schema representation of a phantom type, override
:func:`Phantom.__schema__() <phantom.Phantom.__schema__>`:

.. code-block:: python
Expand All @@ -28,3 +29,9 @@ To make a phantom type compatible with pydantic, all you need to do is override
As can be seen in the example, ``__schema__()`` implementations are expected to return a
dict extending its ``super().__schema__()``, however this is not a requirement and any
:class:`Schema <phantom.schema.Schema>`-compatible ``dict`` can be returned.

.. note::

phantom-types 3.0.2 supported Pydantic v1 via the ``__get_validators__()`` hook.
As of phantom-types 3.1.0, only Pydantic v2 is supported. If you need Pydantic v1
support, pin to ``phantom-types<3.1``.
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ dependencies = [

[project.optional-dependencies]
phonenumbers = ["phonenumbers>=8.12.41"]
pydantic = ["pydantic>=1.9.0,<2"]
pydantic = ["pydantic>=2.0.0,<3"]
dateutil = ["python-dateutil>=2.8.2"]
hypothesis = ["hypothesis[zoneinfo]>=6.68.0"]
all = [
Expand Down
71 changes: 66 additions & 5 deletions src/phantom/_base.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,28 @@
from __future__ import annotations

import abc
from collections.abc import Callable
from abc import ABCMeta
from collections.abc import Iterable
from collections.abc import Iterator
from typing import TYPE_CHECKING
from typing import Any
from typing import ClassVar
from typing import Generic
from typing import Protocol
from typing import TypeVar
from typing import runtime_checkable

if TYPE_CHECKING:
try:
from pydantic import GetCoreSchemaHandler
from pydantic_core import CoreSchema
from pydantic_core.core_schema import ValidatorFunctionWrapHandler
except ImportError:
pass

from typing_extensions import Self

from . import _hypothesis
from ._utils.compat import require_pydantic
from ._utils.misc import BoundType
from ._utils.misc import UnresolvedClassAttribute
from ._utils.misc import fully_qualified_name
Expand Down Expand Up @@ -43,6 +52,16 @@ def parse(cls, instance: object) -> Self: ...
V = TypeVar("V", bound=SupportsParse)


def _is_protocol(tp: type) -> bool:
"""Check if type is a Protocol."""
return isinstance(tp, type) and getattr(tp, "_is_protocol", False)


def _is_abc(tp: type) -> bool:
"""Check if type is an ABC."""
return isinstance(tp, ABCMeta)


class PhantomMeta(abc.ABCMeta):
"""
Metaclass that defers __instancecheck__ to derived classes and prevents actual
Expand Down Expand Up @@ -84,9 +103,51 @@ def parse(cls: type[Derived], instance: object) -> Derived:
def __instancecheck__(cls, instance: object) -> bool: ...

@classmethod
def __get_validators__(cls: type[Derived]) -> Iterator[Callable[[object], Derived]]:
"""Hook that makes phantom types compatible with pydantic."""
yield cls.parse
def _validate(
cls: type[Derived],
value: Any,
handler: ValidatorFunctionWrapHandler,
) -> Derived:
"""Pydantic V2 wrap validator."""
require_pydantic()

from pydantic_core import PydanticCustomError

try:
validated = handler(value)
return cls.parse(validated)
except Exception as exc:
raise PydanticCustomError(
"value_error",
f"value is not a valid {cls.__name__}",
) from exc

@classmethod
def __get_pydantic_core_schema__(
cls: type[Derived], source: type[Any], handler: GetCoreSchemaHandler
) -> CoreSchema:
"""Pydantic V2 hook for core schema generation."""
require_pydantic()

from pydantic.errors import PydanticSchemaGenerationError
from pydantic_core.core_schema import any_schema
from pydantic_core.core_schema import is_instance_schema
from pydantic_core.core_schema import no_info_wrap_validator_function

bound = getattr(cls, "__bound__", object)

bound_schema: CoreSchema
if _is_protocol(bound) or (_is_abc(bound) and bound is not cls):
# For protocols and ABCs, use is_instance_schema
bound_schema = is_instance_schema(bound)
else:
# For concrete types, let Pydantic generate the schema
try:
bound_schema = handler.generate_schema(bound)
except PydanticSchemaGenerationError:
bound_schema = any_schema()

return no_info_wrap_validator_function(cls._validate, bound_schema)


class AbstractInstanceCheck(TypeError): ...
Expand Down
11 changes: 11 additions & 0 deletions src/phantom/_utils/compat.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from phantom.errors import MissingDependency


def require_pydantic() -> None:
try:
import pydantic_core # noqa: F401
except ImportError:
raise MissingDependency(
"pydantic>=2 is required for Pydantic schema generation. "
"Install it with: pip install phantom-types[pydantic]"
) from None
44 changes: 21 additions & 23 deletions src/phantom/datetime.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,38 +20,33 @@
from .predicates.datetime import is_tz_naive
from .schema import Schema

try:
import dateutil.parser

parse_datetime_str = dateutil.parser.parse
DateutilParseError = dateutil.parser.ParserError
except ImportError as e:
exception = e

def parse_datetime_str(
*_: object,
**__: object,
) -> datetime.datetime:
raise MissingDependency(
"python-dateutil needs to be installed to use this type for parsing. It "
"can be installed with the phantom-types[dateutil] extra."
) from exception
__all__ = ("TZAware", "TZNaive")

class DateutilParseError(Exception): # type: ignore[no-redef]
...

def parse_datetime_str(value: str) -> datetime.datetime:
try:
from dateutil.parser import ParserError
from dateutil.parser import parse
except ImportError as exc:
raise MissingDependency(
"python-dateutil needs to be installed to use this type for parsing. "
"It can be installed with the phantom-types[dateutil] extra."
) from exc

__all__ = ("TZAware", "TZNaive")
try:
return parse(value)
except ParserError as exc:
raise TypeError("Could not parse datetime from given string") from exc


def parse_datetime(value: object) -> datetime.datetime:
if isinstance(value, datetime.datetime):
return value

# Do not allow stdlib datetime parsing
# All string parsing must go through dateutil
str_value = parse_str(value)
try:
return parse_datetime_str(str_value)
except DateutilParseError as exc:
raise TypeError("Could not parse datetime from given string") from exc
return parse_datetime_str(str_value)


class TZAware(datetime.datetime, Phantom, predicate=is_tz_aware):
Expand All @@ -76,6 +71,8 @@ def parse(cls, instance: object) -> TZAware:
def __schema__(cls) -> Schema:
return {
**super().__schema__(),
"type": "string",
"format": "date-time",
"description": "A date-time with timezone data.",
}

Expand Down Expand Up @@ -103,6 +100,7 @@ def parse(cls, instance: object) -> TZNaive:
def __schema__(cls) -> Schema:
return {
**super().__schema__(),
"type": "string",
"description": "A date-time without timezone data.",
"format": "date-time-naive",
}
Expand Down
27 changes: 19 additions & 8 deletions src/phantom/interval.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,17 @@ def parse(cls: type[Derived], instance: object) -> Derived:
cls.__bound__(instance) if isinstance(instance, str) else instance
)

@classmethod
def _schema_bound(
cls, value: FloatComparable[Any], infinity: float
) -> int | float | None:
"""Convert interval bound to schema-appropriate numeric type."""
if value == infinity:
return None
if isinstance(cls.__bound__, type) and issubclass(cls.__bound__, int):
return int(value) # type: ignore[call-overload, no-any-return]
return float(value)


def _format_limit(value: SupportsEq) -> str:
if value == inf:
Expand All @@ -183,8 +194,8 @@ def __schema__(cls) -> Schema:
f"A value in the exclusive range ({_format_limit(cls.__low__)}, "
f"{_format_limit(cls.__high__)})."
),
"exclusiveMinimum": float(cls.__low__) if cls.__low__ != neg_inf else None,
"exclusiveMaximum": float(cls.__high__) if cls.__high__ != inf else None,
"exclusiveMinimum": cls._schema_bound(cls.__low__, neg_inf),
"exclusiveMaximum": cls._schema_bound(cls.__high__, inf),
}

@classmethod
Expand Down Expand Up @@ -215,8 +226,8 @@ def __schema__(cls) -> Schema:
f"A value in the inclusive range [{_format_limit(cls.__low__)}, "
f"{_format_limit(cls.__high__)}]."
),
"minimum": float(cls.__low__) if cls.__low__ != neg_inf else None,
"maximum": float(cls.__high__) if cls.__high__ != inf else None,
"minimum": cls._schema_bound(cls.__low__, neg_inf),
"maximum": cls._schema_bound(cls.__high__, inf),
}

@classmethod
Expand All @@ -243,8 +254,8 @@ def __schema__(cls) -> Schema:
f"A value in the half-open range ({_format_limit(cls.__low__)}, "
f"{_format_limit(cls.__high__)}]."
),
"exclusiveMinimum": float(cls.__low__) if cls.__low__ != neg_inf else None,
"maximum": float(cls.__high__) if cls.__high__ != inf else None,
"exclusiveMinimum": cls._schema_bound(cls.__low__, neg_inf),
"maximum": cls._schema_bound(cls.__high__, inf),
}

@classmethod
Expand All @@ -271,8 +282,8 @@ def __schema__(cls) -> Schema:
f"A value in the half-open range [{_format_limit(cls.__low__)}, "
f"{_format_limit(cls.__high__)})."
),
"minimum": float(cls.__low__) if cls.__low__ != neg_inf else None,
"exclusiveMaximum": float(cls.__high__) if cls.__high__ != inf else None,
"minimum": cls._schema_bound(cls.__low__, neg_inf),
"exclusiveMaximum": cls._schema_bound(cls.__high__, inf),
}

@classmethod
Expand Down
Loading