Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP: Pydantic v2 compat #2888

Closed
Closed
Show file tree
Hide file tree
Changes from 27 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
ee45b97
work so far
thejaminator May 20, 2023
4c13e84
separate
thejaminator May 20, 2023
95f979a
remove field map
thejaminator May 20, 2023
ddf7ade
yay test pass
thejaminator May 20, 2023
9734494
fix fields
thejaminator May 20, 2023
2b2c1a8
fix passing fields
thejaminator May 20, 2023
df87fb7
it worksgit stage .
thejaminator May 20, 2023
017e168
revert
thejaminator May 20, 2023
2103eab
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jun 25, 2023
064db83
remove compat file
thejaminator Jun 25, 2023
274f2b6
fix pydantic v2 update issues
thejaminator Jul 7, 2023
ae295f5
Merge remote-tracking branch 'origin/main' into pydantic-v2-compat
thejaminator Jul 7, 2023
f582d41
add test for #2782
thejaminator Jul 7, 2023
045856a
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jul 7, 2023
590166d
Merge branch 'main' into pydantic-v2-compat
patrick91 Jul 8, 2023
176dcbf
Merge remote-tracking branch 'origin/main' into pydantic-v2-compat
thejaminator Jul 11, 2023
5139f7a
mark pydantic v2 explicitly
thejaminator Jul 11, 2023
0e0d1a5
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jul 11, 2023
8fa12dc
add pytest markers for pydantic_v2
thejaminator Jul 11, 2023
c7a9b8c
try again
thejaminator Jul 11, 2023
6be92be
add ignore pydantic2
thejaminator Jul 12, 2023
fecd02d
add explicit dir
thejaminator Jul 12, 2023
883b8a4
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jul 12, 2023
e6fd48b
remove markers
thejaminator Jul 12, 2023
858998c
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jul 12, 2023
ae9eff1
add ignore for test
thejaminator Jul 12, 2023
42203c6
add hints
thejaminator Jul 13, 2023
3d97786
fix weird cli tests changes
thejaminator Jul 13, 2023
227736d
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jul 13, 2023
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
32 changes: 25 additions & 7 deletions noxfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,10 @@ def tests(session: Session) -> None:
"not django",
"-m",
"not starlite",
"-m",
"not pydantic",
"--ignore=tests/mypy",
"--ignore=tests/pyright",
"--ignore=tests/experimental/pydantic",
"--ignore=tests/experimental/pydantic2",
)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

otherwise pytest on the pydantic2 folder would go boom. since it tries to import stuff directly from pydantic v2.



Expand Down Expand Up @@ -90,25 +90,43 @@ def tests_litestar(session: Session) -> None:
)


@session(python=["3.11"], name="Pydantic tests", tags=["tests"])
# TODO: add pydantic 2.0 here :)
@session(python=["3.11"], name="Pydantic v1 tests", tags=["tests"])
@nox.parametrize("pydantic", ["1.10"])
def test_pydantic(session: Session, pydantic: str) -> None:
def test_pydantic_v1(session: Session, pydantic: str) -> None:
session.run_always("poetry", "install", external=True)

session._session.install(f"pydantic~={pydantic}") # type: ignore

session.run(
"pytest",
"tests/experimental/pydantic",
"--cov=strawberry",
"--cov-append",
"--cov-report=xml",
"-n",
"auto",
"--showlocals",
"-vv",
)


@session(python=["3.11"], name="Pydantic v2 tests", tags=["tests"])
@nox.parametrize("pydantic", ["2.0"])
def test_pydantic_v2(session: Session, pydantic: str) -> None:
session.run_always("poetry", "install", external=True)

session._session.install(f"pydantic~={pydantic}") # type: ignore

session.run(
"pytest",
"tests/experimental/pydantic2",
"--cov=strawberry",
"--cov-append",
"--cov-report=xml",
"-n",
"auto",
"--showlocals",
"-vv",
"-m",
"pydantic",
)


Expand Down
1 change: 0 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,6 @@ markers = [
"chalice",
"flask",
"starlite",
"pydantic",
"flaky",
"relay",
]
Expand Down
12 changes: 10 additions & 2 deletions strawberry/experimental/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
try:
from . import pydantic

__all__ = ["pydantic"]
except ImportError:
pass
else:
__all__ = ["pydantic"]
try:
from . import pydantic2

# Support for pydantic2 is highly experimental and the interface will change
# We don't recommend using it yet
__all__ = ["pydantic2"]
except ImportError as e:
pass
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

explicitly a separate module

11 changes: 11 additions & 0 deletions strawberry/experimental/pydantic2/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from .error_type import error_type
from .exceptions import UnregisteredTypeException
from .object_type import input, interface, type

__all__ = [
"error_type",
"UnregisteredTypeException",
"input",
"type",
"interface",
]
113 changes: 113 additions & 0 deletions strawberry/experimental/pydantic2/conversion.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
from __future__ import annotations

import copy
import dataclasses
from typing import TYPE_CHECKING, Any, Type, Union, cast

from strawberry.enum import EnumDefinition
from strawberry.type import StrawberryList, StrawberryOptional
from strawberry.union import StrawberryUnion

if TYPE_CHECKING:
from strawberry.field import StrawberryField
from strawberry.type import StrawberryType


def _convert_from_pydantic_to_strawberry_type(
type_: Union[StrawberryType, type], data_from_model=None, extra=None # noqa: ANN001
):
data = data_from_model if data_from_model is not None else extra

if isinstance(type_, StrawberryOptional):
if data is None:
return data
return _convert_from_pydantic_to_strawberry_type(
type_.of_type, data_from_model=data, extra=extra
)
if isinstance(type_, StrawberryUnion):
for option_type in type_.types:
if hasattr(option_type, "_pydantic_type"):
source_type = option_type._pydantic_type
else:
source_type = cast(type, option_type)
if isinstance(data, source_type):
return _convert_from_pydantic_to_strawberry_type(
option_type, data_from_model=data, extra=extra
)
if isinstance(type_, EnumDefinition):
return data
if isinstance(type_, StrawberryList):
items = []
for index, item in enumerate(data):
items.append(
_convert_from_pydantic_to_strawberry_type(
type_.of_type,
data_from_model=item,
extra=extra[index] if extra else None,
)
)

return items

if hasattr(type_, "_type_definition"):
# in the case of an interface, the concrete type may be more specific
# than the type in the field definition
# don't check _strawberry_input_type because inputs can't be interfaces
if hasattr(type(data), "_strawberry_type"):
type_ = type(data)._strawberry_type
if hasattr(type_, "from_pydantic"):
return type_.from_pydantic(data_from_model, extra)
return convert_pydantic_model_to_strawberry_class(
type_, model_instance=data_from_model, extra=extra
)

return data

def convert_pydantic_model_to_strawberry_class(
cls, *, model_instance=None, extra=None # noqa: ANN001
) -> Any:
extra = extra or {}
kwargs = {}

for field_ in cls._type_definition.fields:
field = cast("StrawberryField", field_)
python_name = field.python_name

data_from_extra = extra.get(python_name, None)
data_from_model = (
getattr(model_instance, python_name, None) if model_instance else None
)

# only convert and add fields to kwargs if they are present in the `__init__`
# method of the class
if field.init:
kwargs[python_name] = _convert_from_pydantic_to_strawberry_type(
field.type, data_from_model, extra=data_from_extra
)

return cls(**kwargs)


def convert_strawberry_class_to_pydantic_model(obj: Type) -> Any:
if hasattr(obj, "to_pydantic"):
return obj.to_pydantic()
elif dataclasses.is_dataclass(obj):
result = []
for f in dataclasses.fields(obj):
value = convert_strawberry_class_to_pydantic_model(getattr(obj, f.name))
result.append((f.name, value))
return dict(result)
elif isinstance(obj, (list, tuple)):
# Assume we can create an object of this type by passing in a
# generator (which is not true for namedtuples, not supported).
return type(obj)(convert_strawberry_class_to_pydantic_model(v) for v in obj)
elif isinstance(obj, dict):
return type(obj)(
(
convert_strawberry_class_to_pydantic_model(k),
convert_strawberry_class_to_pydantic_model(v),
)
for k, v in obj.items()
)
else:
return copy.deepcopy(obj)
37 changes: 37 additions & 0 deletions strawberry/experimental/pydantic2/conversion_types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
from __future__ import annotations

from typing import TYPE_CHECKING, Any, Dict, Optional, Type, TypeVar
from typing_extensions import Protocol

from pydantic import BaseModel

if TYPE_CHECKING:
from strawberry.types.types import TypeDefinition


PydanticModel = TypeVar("PydanticModel", bound=BaseModel)


class StrawberryTypeFromPydantic(Protocol[PydanticModel]):
"""This class does not exist in runtime.
It only makes the methods below visible for IDEs"""

def __init__(self, **kwargs: Any):
...

@staticmethod
def from_pydantic(
instance: PydanticModel, extra: Optional[Dict[str, Any]] = None
) -> StrawberryTypeFromPydantic[PydanticModel]:
...

def to_pydantic(self, **kwargs: Any) -> PydanticModel:
...

@property
def _type_definition(self) -> TypeDefinition:
...

@property
def _pydantic_type(self) -> Type[PydanticModel]:
...
Loading