From a69970783213f65ad8523009f343d0cf698ffde7 Mon Sep 17 00:00:00 2001 From: Slava Date: Mon, 6 Feb 2023 18:17:11 +0300 Subject: [PATCH] Handle X | Y union in GenericModel (#4977) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix X | Y union syntax breaks GenericModel (#4146) * Update changes/4146-thenx.md Co-authored-by: ⬢ Samuel Colvin * Improve tests * Recreate newstyle union via typing._UnionGenericAlias * Add basic pep-604 union args order caching detection test --------- Co-authored-by: ⬢ Samuel Colvin --- changes/4146-thenx.md | 1 + pydantic/generics.py | 8 ++++ tests/test_generics.py | 104 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 113 insertions(+) create mode 100644 changes/4146-thenx.md diff --git a/changes/4146-thenx.md b/changes/4146-thenx.md new file mode 100644 index 0000000000..81ec65384a --- /dev/null +++ b/changes/4146-thenx.md @@ -0,0 +1 @@ +Fix `X | Y` union syntax breaking `GenericModel` diff --git a/pydantic/generics.py b/pydantic/generics.py index ece421e8ba..e97e43510e 100644 --- a/pydantic/generics.py +++ b/pydantic/generics.py @@ -1,4 +1,5 @@ import sys +import types import typing from typing import ( TYPE_CHECKING, @@ -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 @@ -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 diff --git a/tests/test_generics.py b/tests/test_generics.py index 371272b1ca..98eb5a1517 100644 --- a/tests/test_generics.py +++ b/tests/test_generics.py @@ -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 @@ -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.""" @@ -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')