Skip to content

Commit

Permalink
Handle X | Y union in GenericModel (#4977)
Browse files Browse the repository at this point in the history
* Fix X | Y union syntax breaks GenericModel (#4146)

* Update changes/4146-thenx.md

Co-authored-by: ⬢ Samuel Colvin <s@muelcolvin.com>

* Improve tests

* Recreate newstyle union via typing._UnionGenericAlias

* Add basic pep-604 union args order caching detection test

---------

Co-authored-by: ⬢ Samuel Colvin <s@muelcolvin.com>
  • Loading branch information
thenx and samuelcolvin committed Feb 6, 2023
1 parent 0bc7cd3 commit a699707
Show file tree
Hide file tree
Showing 3 changed files with 113 additions and 0 deletions.
1 change: 1 addition & 0 deletions changes/4146-thenx.md
@@ -0,0 +1 @@
Fix `X | Y` union syntax breaking `GenericModel`
8 changes: 8 additions & 0 deletions pydantic/generics.py
@@ -1,4 +1,5 @@
import sys
import types
import typing
from typing import (
TYPE_CHECKING,
Expand Down Expand Up @@ -26,6 +27,9 @@
from .typing import display_as_type, get_all_type_hints, get_args, get_origin, typing_base
from .utils import LimitedDict, all_identical, lenient_issubclass

if sys.version_info >= (3, 10):
from typing import _UnionGenericAlias

GenericModelT = TypeVar('GenericModelT', bound='GenericModel')
TypeVarType = Any # since mypy doesn't allow the use of TypeVar as a type

Expand Down Expand Up @@ -268,6 +272,10 @@ def replace_types(type_: Any, type_map: Mapping[Any, Any]) -> Any:
# See: https://www.python.org/dev/peps/pep-0585
origin_type = getattr(typing, type_._name)
assert origin_type is not None
# PEP-604 syntax (Ex.: list | str) is represented with a types.UnionType object that does not have __getitem__.
# We also cannot use isinstance() since we have to compare types.
if sys.version_info >= (3, 10) and origin_type is types.UnionType: # noqa: E721
return _UnionGenericAlias(origin_type, resolved_type_args)
return origin_type[resolved_type_args]

# We handle pydantic generic models separately as they don't have the same
Expand Down
104 changes: 104 additions & 0 deletions tests/test_generics.py
Expand Up @@ -735,6 +735,27 @@ class Model(GenericModel, Generic[t]):
assert type(float_or_int_model(data='1').data) is float


@pytest.mark.skipif(sys.version_info < (3, 10), reason='pep-604 syntax (Ex.: list | int) was added in python3.10')
def test_generic_model_caching_detect_order_of_union_args_basic_with_pep_604_syntax(create_module):
# Basic variant of https://github.com/pydantic/pydantic/issues/4474 with pep-604 syntax.
@create_module
def module():
from typing import Generic, TypeVar

from pydantic.generics import GenericModel

t = TypeVar('t')

class Model(GenericModel, Generic[t]):
data: t

int_or_float_model = Model[int | float]
float_or_int_model = Model[float | int]

assert type(int_or_float_model(data='1').data) is int
assert type(float_or_int_model(data='1').data) is float


@pytest.mark.skip(
reason="""
Depends on similar issue in CPython itself: https://github.com/python/cpython/issues/86483
Expand Down Expand Up @@ -854,6 +875,27 @@ class Model(GenericModel, Generic[T]):
assert replace_types(list[Union[str, list, T]], {T: int}) == list[Union[str, list, int]]


@pytest.mark.skipif(sys.version_info < (3, 10), reason='pep-604 syntax (Ex.: list | int) was added in python3.10')
def test_replace_types_with_pep_604_syntax() -> None:
T = TypeVar('T')

class Model(GenericModel, Generic[T]):
a: T

assert replace_types(T | None, {T: int}) == int | None
assert replace_types(T | int | str, {T: float}) == float | int | str
assert replace_types(list[T] | None, {T: int}) == list[int] | None
assert replace_types(List[str | list | T], {T: int}) == List[str | list | int]
assert replace_types(list[str | list | T], {T: int}) == list[str | list | int]
assert replace_types(list[str | list | list[T]], {T: int}) == list[str | list | list[int]]
assert replace_types(list[Model[T] | None] | None, {T: T}) == list[Model[T] | None] | None
assert (
replace_types(T | list[T | list[T | list[T | None] | None] | None] | None, {T: int})
== int | list[int | list[int | list[int | None] | None] | None] | None
)
assert replace_types(list[list[list[T | None]]], {T: int}) == list[list[list[int | None]]]


def test_replace_types_with_user_defined_generic_type_field():
"""Test that using user defined generic types as generic model fields are handled correctly."""

Expand Down Expand Up @@ -916,6 +958,68 @@ class NormalModel(BaseModel):
assert inner_model.__concrete__ is True


@pytest.mark.skipif(sys.version_info < (3, 10), reason='pep-604 syntax (Ex.: list | int) was added in python3.10')
def test_wrapping_resolved_generic_with_pep_604_syntax() -> None:
T = TypeVar('T')

class InnerModel(GenericModel, Generic[T]):
generic: list[T] | None

class OuterModel(BaseModel):
wrapper: InnerModel[int]

with pytest.raises(ValidationError):
OuterModel(wrapper={'generic': ['string_instead_of_int']})
assert OuterModel(wrapper={'generic': [1]}).dict() == {'wrapper': {'generic': [1]}}


@pytest.mark.skipif(sys.version_info < (3, 10), reason='pep-604 syntax (Ex.: list | int) was added in python3.10')
def test_type_propagation_in_deep_generic_with_pep_604_syntax() -> None:
T = TypeVar('T')

class InnerModel(GenericModel, Generic[T]):
generic: list[T] | None

class OuterModel(GenericModel, Generic[T]):
wrapper: InnerModel[T] | None

with pytest.raises(ValidationError):
OuterModel[int](wrapper={'generic': ['string_instead_of_int']})
assert OuterModel[int](wrapper={'generic': [1]}) == {'wrapper': {'generic': [1]}}


@pytest.mark.skipif(sys.version_info < (3, 10), reason='pep-604 syntax (Ex.: list | int) was added in python3.10')
def test_deep_generic_with_pep_604_syntax() -> None:
T = TypeVar('T')
S = TypeVar('S')
R = TypeVar('R')

class OuterModel(GenericModel, Generic[T, S, R]):
a: Dict[R, list[T] | None]
b: S | R | None
c: R
d: float

class InnerModel(GenericModel, Generic[T, R]):
c: list[T] | None
d: list[R] | None

class NormalModel(BaseModel):
e: int
f: str

inner_model = InnerModel[int, str]
generic_model = OuterModel[inner_model, NormalModel, int]

inner_models = [inner_model(c=[1], d=['a'])]
generic_model(a={1: inner_models, 2: None}, b=None, c=1, d=1.5)
generic_model(a={}, b=NormalModel(e=1, f='a'), c=1, d=1.5)
generic_model(a={}, b=1, c=1, d=1.5)

assert InnerModel.__concrete__ is False
assert inner_model.__concrete__ is True


def test_deep_generic_with_inner_typevar():
T = TypeVar('T')

Expand Down

0 comments on commit a699707

Please sign in to comment.