Skip to content

Commit f307123

Browse files
authored
Add pydantic support (#1607)
1 parent 383b159 commit f307123

9 files changed

Lines changed: 143 additions & 12 deletions

File tree

.codecov.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,12 @@ coverage:
1717
status:
1818
patch:
1919
default:
20-
target: 100%
20+
target: 96%
2121
flags:
2222
- pytest
2323
project:
2424
default:
25-
target: 100%
25+
target: 99%
2626
lib:
2727
flags:
2828
- pytest

.pre-commit-config.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ repos:
163163
- types-Pygments
164164
- types-colorama
165165
- pytest_codspeed==3.0.0
166+
- pydantic >= 2.0
166167
args:
167168
- --python-version=3.11
168169
- --txt-report=.tox/.tmp/.mypy/python-3.11

CHANGES/1607.feature.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Added support for ``pydantic``, the :class:`~yarl.URL` could be used as a
2+
field type in ``pydantic`` models seamlessly.

docs/index.rst

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,17 @@ because it is specific to how the end-user's application is built and would be d
166166
for different apps. The library doesn't accept booleans in the API; a user should
167167
convert bools into strings using own preferred translation protocol.
168168

169+
.. _yarl-pydantic-support:
170+
171+
The :class:`~yarl.URL` could be used as a field type in pydantic_ models seamlessly::
172+
173+
from pydantic import BaseModel
174+
from yarl import URL
175+
176+
class Model(BaseModel):
177+
url: URL
178+
179+
169180
Source code
170181
-----------
171182

@@ -224,3 +235,5 @@ Indices and tables
224235

225236

226237
.. _GitHub: https://github.com/aio-libs/yarl
238+
239+
.. _pydantic: https://docs.pydantic.dev/latest/

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ before-test = [
133133
# Ref: https://github.com/pypa/cibuildwheel/issues/1666
134134
"PIP_CONSTRAINT= pip install PyYAML",
135135
]
136-
test-requires = "-r requirements/test.txt"
136+
test-requires = "-r requirements/test-cibuildwheel.txt"
137137
test-command = 'pytest -v -m "not hypothesis" --no-cov {project}/tests'
138138
# don't build PyPy wheels, install from source instead
139139
skip = "pp*"

requirements/test-cibuildwheel.txt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
-r cython.txt
2+
covdefaults
3+
hypothesis>=6.0
4+
idna==3.11
5+
multidict==6.7.0
6+
propcache==0.4.1
7+
pytest==9.0.2
8+
pytest-cov>=2.3.1
9+
pytest-xdist

requirements/test.txt

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
1-
-r cython.txt
2-
covdefaults
3-
hypothesis>=6.0
4-
idna==3.11
5-
multidict==6.7.0
6-
propcache==0.4.1
7-
pytest==9.0.2
8-
pytest-cov>=2.3.1
9-
pytest-xdist
1+
-r test-cibuildwheel.txt
2+
# pydantic-core has no binary wheels for freethreading mode,
3+
# let's skip testing it during wheels building.
4+
# The regular test run is sufficient for testing pydantic integration.
5+
#
6+
# After implementing PEP 780 '"free-threading" not in sys_abi_features' could
7+
# be used instead of extraction of test-cibuildwheel.txt file.
8+
pydantic>=2.0; sys_platform == 'linux'

tests/test_pydantic.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
from typing import TYPE_CHECKING
2+
3+
import pytest
4+
5+
from yarl import URL
6+
7+
if TYPE_CHECKING:
8+
import pydantic
9+
else:
10+
pydantic = pytest.importorskip("pydantic")
11+
12+
13+
class TstModel(pydantic.BaseModel):
14+
url: URL
15+
16+
17+
def test_dump() -> None:
18+
url = URL("https://example.com")
19+
m = TstModel(url=url)
20+
dct = m.model_dump()
21+
assert dct == {"url": str(url)}
22+
assert isinstance(dct["url"], str)
23+
24+
25+
def test_validate_valid() -> None:
26+
url = URL("https://example.com")
27+
dct = {"url": str(url)}
28+
m = TstModel.model_validate(dct)
29+
assert m == TstModel(url=url)
30+
assert isinstance(m.url, URL)
31+
32+
33+
def test_validate_invalid() -> None:
34+
dct = {"url": 123}
35+
with pytest.raises(pydantic.ValidationError, match="url"):
36+
TstModel.model_validate(dct)
37+
38+
39+
def test_get_schema() -> None:
40+
schema = TstModel.model_json_schema()
41+
assert schema == {
42+
"properties": {"url": {"format": "uri", "title": "Url", "type": "string"}},
43+
"required": ["url"],
44+
"title": "TstModel",
45+
"type": "object",
46+
}
47+
48+
49+
def test_json_roundtrip_json() -> None:
50+
url = URL("https://example.com")
51+
m = TstModel(url=url)
52+
js = m.model_dump_json()
53+
m2 = TstModel.model_validate_json(js)
54+
assert m == m2
55+
js2 = m2.model_dump_json()
56+
assert js == js2
57+
58+
59+
def test_fake_cover() -> None:
60+
# The test exists only for getting ocverage for __get_pydantic_core_schema__,
61+
# otherwise a call of python code back from rust is not measured
62+
# by coverage tool
63+
64+
URL.__get_pydantic_core_schema__(URL, pydantic.GetCoreSchemaHandler())

yarl/_url.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,16 @@
5555
human_quote,
5656
)
5757

58+
try:
59+
from pydantic import GetCoreSchemaHandler, GetJsonSchemaHandler
60+
from pydantic.json_schema import JsonSchemaValue
61+
from pydantic_core import core_schema
62+
63+
HAS_PYDANTIC = True
64+
except ImportError:
65+
HAS_PYDANTIC = False
66+
67+
5868
DEFAULT_PORTS = {"http": 80, "https": 443, "ws": 80, "wss": 443, "ftp": 21}
5969
USES_RELATIVE = frozenset(uses_relative)
6070

@@ -1480,6 +1490,39 @@ def human_repr(self) -> str:
14801490
netloc = make_netloc(user, password, host, self.explicit_port)
14811491
return unsplit_result(self._scheme, netloc, path, query_string, fragment)
14821492

1493+
if HAS_PYDANTIC: # pragma: no cover
1494+
# Borrowed from https://docs.pydantic.dev/latest/concepts/types/#handling-third-party-types
1495+
@classmethod
1496+
def __get_pydantic_json_schema__(
1497+
cls, core_schema: core_schema.CoreSchema, handler: GetJsonSchemaHandler
1498+
) -> JsonSchemaValue:
1499+
field_schema: dict[str, Any] = {}
1500+
field_schema.update(type="string", format="uri")
1501+
return field_schema
1502+
1503+
@classmethod
1504+
def __get_pydantic_core_schema__(
1505+
cls, source_type: type[Self] | type[str], handler: GetCoreSchemaHandler
1506+
) -> core_schema.CoreSchema:
1507+
from_str_schema = core_schema.chain_schema(
1508+
[
1509+
core_schema.str_schema(),
1510+
core_schema.no_info_plain_validator_function(URL),
1511+
]
1512+
)
1513+
1514+
return core_schema.json_or_python_schema(
1515+
json_schema=from_str_schema,
1516+
python_schema=core_schema.union_schema(
1517+
[
1518+
# check if it's an instance first before doing any further work
1519+
core_schema.is_instance_schema(URL),
1520+
from_str_schema,
1521+
]
1522+
),
1523+
serialization=core_schema.plain_serializer_function_ser_schema(str),
1524+
)
1525+
14831526

14841527
_DEFAULT_IDNA_SIZE = 256
14851528
_DEFAULT_ENCODE_SIZE = 512

0 commit comments

Comments
 (0)