Skip to content

Commit c07fe39

Browse files
committed
feat: Pydantic v2 upgrade
1 parent 4580406 commit c07fe39

16 files changed

Lines changed: 760 additions & 276 deletions

File tree

.github/workflows/ci.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ jobs:
5252
uses: actions/setup-python@v5
5353
with:
5454
# Keep in sync with version in .readthedocs.yml.
55-
python-version: "3.11"
55+
python-version: "3.12"
5656
cache: pip
5757
cache-dependency-path: docs-requirements.txt
5858
- name: Install dependencies

.readthedocs.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ build:
66
os: ubuntu-22.04
77
tools:
88
# Keep in sync with docs build CI job.
9-
python: "3.11"
9+
python: "3.12"
1010
python:
1111
install:
1212
- requirements: docs-requirements.txt

README.md

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,9 @@ support][pydantic-support] out-of-the-box. Subclasses of `Phantom` work with bot
125125
pydantic's validation and its schema generation.
126126

127127
```python
128+
import json
129+
130+
128131
class Name(str, Phantom, predicate=contained({"Jane", "Joe"})):
129132
@classmethod
130133
def __schema__(cls) -> Schema:
@@ -139,30 +142,30 @@ class Person(BaseModel):
139142
created: TZAware
140143

141144

142-
print(json.dumps(Person.schema(), indent=2))
145+
print(json.dumps(Person.model_json_schema(), indent=2))
143146
```
144147

145148
The code above outputs the following JSONSchema.
146149

147150
```json
148151
{
149-
"title": "Person",
150-
"type": "object",
151152
"properties": {
152153
"name": {
153-
"title": "Name",
154154
"description": "Either Jane or Joe",
155155
"format": "custom-name",
156+
"title": "Name",
156157
"type": "string"
157158
},
158159
"created": {
159-
"title": "TZAware",
160160
"description": "A date-time with timezone data.",
161-
"type": "string",
162-
"format": "date-time"
161+
"format": "date-time",
162+
"title": "TZAware",
163+
"type": "string"
163164
}
164165
},
165-
"required": ["name", "created"]
166+
"required": ["name", "created"],
167+
"title": "Person",
168+
"type": "object"
166169
}
167170
```
168171

@@ -213,7 +216,7 @@ $ make test-typing
213216
[typeguard]: https://github.com/agronholm/typeguard
214217
[beartype]: https://github.com/beartype/beartype
215218
[dbc]: https://en.wikipedia.org/wiki/Design_by_contract
216-
[pydantic]: https://pydantic-docs.helpmanual.io/
219+
[pydantic]: https://docs.pydantic.dev
217220
[pydantic-support]:
218221
https://phantom-types.readthedocs.io/en/stable/pages/pydantic-support.html
219222
[goose]: https://github.com/antonagestam/goose

docs-requirements.txt

Lines changed: 172 additions & 87 deletions
Large diffs are not rendered by default.

docs/pages/pydantic-support.rst

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
Pydantic Support
22
================
33

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

9-
.. _pydantic: https://pydantic-docs.helpmanual.io/
10+
.. _pydantic: https://docs.pydantic.dev/
1011

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

1415
.. code-block:: python
@@ -28,3 +29,9 @@ To make a phantom type compatible with pydantic, all you need to do is override
2829
As can be seen in the example, ``__schema__()`` implementations are expected to return a
2930
dict extending its ``super().__schema__()``, however this is not a requirement and any
3031
:class:`Schema <phantom.schema.Schema>`-compatible ``dict`` can be returned.
32+
33+
.. note::
34+
35+
phantom-types 3.0.2 supported Pydantic v1 via the ``__get_validators__()`` hook.
36+
As of phantom-types 3.1.0, only Pydantic v2 is supported. If you need Pydantic v1
37+
support, pin to ``phantom-types<3.1``.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ dependencies = [
5252

5353
[project.optional-dependencies]
5454
phonenumbers = ["phonenumbers>=8.12.41"]
55-
pydantic = ["pydantic>=1.9.0,<2"]
55+
pydantic = ["pydantic>=2.0.0,<3"]
5656
dateutil = ["python-dateutil>=2.8.2"]
5757
hypothesis = ["hypothesis[zoneinfo]>=6.68.0"]
5858
all = [

src/phantom/_base.py

Lines changed: 66 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,28 @@
11
from __future__ import annotations
22

33
import abc
4-
from collections.abc import Callable
4+
from abc import ABCMeta
55
from collections.abc import Iterable
6-
from collections.abc import Iterator
6+
from typing import TYPE_CHECKING
77
from typing import Any
88
from typing import ClassVar
99
from typing import Generic
1010
from typing import Protocol
1111
from typing import TypeVar
1212
from typing import runtime_checkable
1313

14+
if TYPE_CHECKING:
15+
try:
16+
from pydantic import GetCoreSchemaHandler
17+
from pydantic_core import CoreSchema
18+
from pydantic_core.core_schema import ValidatorFunctionWrapHandler
19+
except ImportError:
20+
pass
21+
1422
from typing_extensions import Self
1523

1624
from . import _hypothesis
25+
from ._utils.compat import require_pydantic
1726
from ._utils.misc import BoundType
1827
from ._utils.misc import UnresolvedClassAttribute
1928
from ._utils.misc import fully_qualified_name
@@ -43,6 +52,16 @@ def parse(cls, instance: object) -> Self: ...
4352
V = TypeVar("V", bound=SupportsParse)
4453

4554

55+
def _is_protocol(tp: type) -> bool:
56+
"""Check if type is a Protocol."""
57+
return isinstance(tp, type) and getattr(tp, "_is_protocol", False)
58+
59+
60+
def _is_abc(tp: type) -> bool:
61+
"""Check if type is an ABC."""
62+
return isinstance(tp, ABCMeta)
63+
64+
4665
class PhantomMeta(abc.ABCMeta):
4766
"""
4867
Metaclass that defers __instancecheck__ to derived classes and prevents actual
@@ -84,9 +103,51 @@ def parse(cls: type[Derived], instance: object) -> Derived:
84103
def __instancecheck__(cls, instance: object) -> bool: ...
85104

86105
@classmethod
87-
def __get_validators__(cls: type[Derived]) -> Iterator[Callable[[object], Derived]]:
88-
"""Hook that makes phantom types compatible with pydantic."""
89-
yield cls.parse
106+
def _validate(
107+
cls: type[Derived],
108+
value: Any,
109+
handler: ValidatorFunctionWrapHandler,
110+
) -> Derived:
111+
"""Pydantic V2 wrap validator."""
112+
require_pydantic()
113+
114+
from pydantic_core import PydanticCustomError
115+
116+
try:
117+
validated = handler(value)
118+
return cls.parse(validated)
119+
except Exception as exc:
120+
raise PydanticCustomError(
121+
"value_error",
122+
f"value is not a valid {cls.__name__}",
123+
) from exc
124+
125+
@classmethod
126+
def __get_pydantic_core_schema__(
127+
cls: type[Derived], source: type[Any], handler: GetCoreSchemaHandler
128+
) -> CoreSchema:
129+
"""Pydantic V2 hook for core schema generation."""
130+
require_pydantic()
131+
132+
from pydantic.errors import PydanticSchemaGenerationError
133+
from pydantic_core.core_schema import any_schema
134+
from pydantic_core.core_schema import is_instance_schema
135+
from pydantic_core.core_schema import no_info_wrap_validator_function
136+
137+
bound = getattr(cls, "__bound__", object)
138+
139+
bound_schema: CoreSchema
140+
if _is_protocol(bound) or (_is_abc(bound) and bound is not cls):
141+
# For protocols and ABCs, use is_instance_schema
142+
bound_schema = is_instance_schema(bound)
143+
else:
144+
# For concrete types, let Pydantic generate the schema
145+
try:
146+
bound_schema = handler.generate_schema(bound)
147+
except PydanticSchemaGenerationError:
148+
bound_schema = any_schema()
149+
150+
return no_info_wrap_validator_function(cls._validate, bound_schema)
90151

91152

92153
class AbstractInstanceCheck(TypeError): ...

src/phantom/_utils/compat.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from phantom.errors import MissingDependency
2+
3+
4+
def require_pydantic() -> None:
5+
try:
6+
import pydantic_core # noqa: F401
7+
except ImportError:
8+
raise MissingDependency(
9+
"pydantic>=2 is required for Pydantic schema generation. "
10+
"Install it with: pip install phantom-types[pydantic]"
11+
) from None

src/phantom/datetime.py

Lines changed: 21 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -20,38 +20,33 @@
2020
from .predicates.datetime import is_tz_naive
2121
from .schema import Schema
2222

23-
try:
24-
import dateutil.parser
25-
26-
parse_datetime_str = dateutil.parser.parse
27-
DateutilParseError = dateutil.parser.ParserError
28-
except ImportError as e:
29-
exception = e
30-
31-
def parse_datetime_str(
32-
*_: object,
33-
**__: object,
34-
) -> datetime.datetime:
35-
raise MissingDependency(
36-
"python-dateutil needs to be installed to use this type for parsing. It "
37-
"can be installed with the phantom-types[dateutil] extra."
38-
) from exception
23+
__all__ = ("TZAware", "TZNaive")
3924

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

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

44-
__all__ = ("TZAware", "TZNaive")
36+
try:
37+
return parse(value)
38+
except ParserError as exc:
39+
raise TypeError("Could not parse datetime from given string") from exc
4540

4641

4742
def parse_datetime(value: object) -> datetime.datetime:
4843
if isinstance(value, datetime.datetime):
4944
return value
45+
46+
# Do not allow stdlib datetime parsing
47+
# All string parsing must go through dateutil
5048
str_value = parse_str(value)
51-
try:
52-
return parse_datetime_str(str_value)
53-
except DateutilParseError as exc:
54-
raise TypeError("Could not parse datetime from given string") from exc
49+
return parse_datetime_str(str_value)
5550

5651

5752
class TZAware(datetime.datetime, Phantom, predicate=is_tz_aware):
@@ -76,6 +71,8 @@ def parse(cls, instance: object) -> TZAware:
7671
def __schema__(cls) -> Schema:
7772
return {
7873
**super().__schema__(),
74+
"type": "string",
75+
"format": "date-time",
7976
"description": "A date-time with timezone data.",
8077
}
8178

@@ -103,6 +100,7 @@ def parse(cls, instance: object) -> TZNaive:
103100
def __schema__(cls) -> Schema:
104101
return {
105102
**super().__schema__(),
103+
"type": "string",
106104
"description": "A date-time without timezone data.",
107105
"format": "date-time-naive",
108106
}

src/phantom/interval.py

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,17 @@ def parse(cls: type[Derived], instance: object) -> Derived:
163163
cls.__bound__(instance) if isinstance(instance, str) else instance
164164
)
165165

166+
@classmethod
167+
def _schema_bound(
168+
cls, value: FloatComparable[Any], infinity: float
169+
) -> int | float | None:
170+
"""Convert interval bound to schema-appropriate numeric type."""
171+
if value == infinity:
172+
return None
173+
if isinstance(cls.__bound__, type) and issubclass(cls.__bound__, int):
174+
return int(value) # type: ignore[call-overload, no-any-return]
175+
return float(value)
176+
166177

167178
def _format_limit(value: SupportsEq) -> str:
168179
if value == inf:
@@ -183,8 +194,8 @@ def __schema__(cls) -> Schema:
183194
f"A value in the exclusive range ({_format_limit(cls.__low__)}, "
184195
f"{_format_limit(cls.__high__)})."
185196
),
186-
"exclusiveMinimum": float(cls.__low__) if cls.__low__ != neg_inf else None,
187-
"exclusiveMaximum": float(cls.__high__) if cls.__high__ != inf else None,
197+
"exclusiveMinimum": cls._schema_bound(cls.__low__, neg_inf),
198+
"exclusiveMaximum": cls._schema_bound(cls.__high__, inf),
188199
}
189200

190201
@classmethod
@@ -215,8 +226,8 @@ def __schema__(cls) -> Schema:
215226
f"A value in the inclusive range [{_format_limit(cls.__low__)}, "
216227
f"{_format_limit(cls.__high__)}]."
217228
),
218-
"minimum": float(cls.__low__) if cls.__low__ != neg_inf else None,
219-
"maximum": float(cls.__high__) if cls.__high__ != inf else None,
229+
"minimum": cls._schema_bound(cls.__low__, neg_inf),
230+
"maximum": cls._schema_bound(cls.__high__, inf),
220231
}
221232

222233
@classmethod
@@ -243,8 +254,8 @@ def __schema__(cls) -> Schema:
243254
f"A value in the half-open range ({_format_limit(cls.__low__)}, "
244255
f"{_format_limit(cls.__high__)}]."
245256
),
246-
"exclusiveMinimum": float(cls.__low__) if cls.__low__ != neg_inf else None,
247-
"maximum": float(cls.__high__) if cls.__high__ != inf else None,
257+
"exclusiveMinimum": cls._schema_bound(cls.__low__, neg_inf),
258+
"maximum": cls._schema_bound(cls.__high__, inf),
248259
}
249260

250261
@classmethod
@@ -271,8 +282,8 @@ def __schema__(cls) -> Schema:
271282
f"A value in the half-open range [{_format_limit(cls.__low__)}, "
272283
f"{_format_limit(cls.__high__)})."
273284
),
274-
"minimum": float(cls.__low__) if cls.__low__ != neg_inf else None,
275-
"exclusiveMaximum": float(cls.__high__) if cls.__high__ != inf else None,
285+
"minimum": cls._schema_bound(cls.__low__, neg_inf),
286+
"exclusiveMaximum": cls._schema_bound(cls.__high__, inf),
276287
}
277288

278289
@classmethod

0 commit comments

Comments
 (0)