From 6b8d2babaaca420d20aab52bfe5f42cf5e382f1d Mon Sep 17 00:00:00 2001 From: Davis Kirkendall Date: Wed, 30 Dec 2020 19:23:55 +0100 Subject: [PATCH] Fix #947, #1483, #1247: allow inner type vars to be present in parent generic classes (#1989) * Fix #947, fix #1483, fix #1247 allow inner type vars to be present in parent generic classes - Rename generics._is_type to _is_generic in response to comment: https://github.com/samuelcolvin/pydantic/pull/1989#discussion_r503400391 - Add more explicit type assertion in generics test - Add generics tests and unify naming - Move deep generic tests all into same place in code - Unify naming convention in deep generic tests using naming of existing tests - Add tests for multiple inheritance and multiple type vars - Add generic tests for type hint resolution cases - Fix edge cases for handling parameters in generic models - Resolve parameters correctly - Add tests for special cases like callables - Add returning generic type directly if parameters and arguments are identical. * Apply review comments - Add docstrings - Small refactor `generics.__concrete_name__` - Small refactor of `generics.resolve_type_hints` - Change `is_identity_typevars_map` to more generic and clearer `all_identical` and move into utils. - rename "resolve_type_hint" to "replace_types" so as to not get confused with "resolve_annotations" from `pydantic.typing` * Fix generics test coverage * Update pydantic/generics.py, remove unneeded annotation mypy understands list comprehensions as they are now Co-authored-by: Eric Jolibois Co-authored-by: Eric Jolibois --- changes/947-daviskirk.md | 1 + pydantic/generics.py | 154 ++++++++++++++----- pydantic/typing.py | 44 +++++- pydantic/utils.py | 21 ++- tests/test_generics.py | 325 +++++++++++++++++++++++++++++++++++---- tests/test_utils.py | 25 ++- 6 files changed, 496 insertions(+), 74 deletions(-) create mode 100644 changes/947-daviskirk.md diff --git a/changes/947-daviskirk.md b/changes/947-daviskirk.md new file mode 100644 index 0000000..d50a1a1 --- /dev/null +++ b/changes/947-daviskirk.md @@ -0,0 +1 @@ +Fix bug where generic models with fields where the typevar is nested in another type `a: List[T]` are considered to be concrete. This allows these models to be subclassed and composed as expected. diff --git a/pydantic/generics.py b/pydantic/generics.py index 76be759..be09b50 100644 --- a/pydantic/generics.py +++ b/pydantic/generics.py @@ -1,10 +1,14 @@ import sys +import typing from typing import ( TYPE_CHECKING, Any, ClassVar, Dict, Generic, + Iterable, + Iterator, + List, Optional, Tuple, Type, @@ -17,8 +21,8 @@ from typing import ( from .class_validators import gather_all_validators from .fields import FieldInfo, ModelField from .main import BaseModel, create_model -from .typing import get_origin -from .utils import lenient_issubclass +from .typing import display_as_type, get_args, get_origin, typing_base +from .utils import all_identical, lenient_issubclass _generic_types_cache: Dict[Tuple[Type[Any], Union[Any, Tuple[Any, ...]]], Type[BaseModel]] = {} GenericModelT = TypeVar('GenericModelT', bound='GenericModel') @@ -37,6 +41,16 @@ class GenericModel(BaseModel): # Setting the return type as Type[Any] instead of Type[BaseModel] prevents PyCharm warnings def __class_getitem__(cls: Type[GenericModelT], params: Union[Type[Any], Tuple[Type[Any], ...]]) -> Type[Any]: + """Instantiates a new class from a generic class `cls` and type variables `params`. + + :param params: Tuple of types the class . Given a generic class + `Model` with 2 type variables and a concrete model `Model[str, int]`, + the value `(str, int)` would be passed to `params`. + :return: New model class inheriting from `cls` with instantiated + types described by `params`. If no parameters are given, `cls` is + returned as is. + + """ cached = _generic_types_cache.get((cls, params)) if cached is not None: return cached @@ -50,16 +64,24 @@ class GenericModel(BaseModel): raise TypeError(f'Type {cls.__name__} must inherit from typing.Generic before being parameterized') check_parameters_count(cls, params) + # Build map from generic typevars to passed params typevars_map: Dict[TypeVarType, Type[Any]] = dict(zip(cls.__parameters__, params)) + if all_identical(typevars_map.keys(), typevars_map.values()) and typevars_map: + return cls # if arguments are equal to parameters it's the same object + + # Recursively walk class type hints and replace generic typevars + # with concrete types that were passed. type_hints = get_type_hints(cls).items() instance_type_hints = {k: v for k, v in type_hints if get_origin(v) is not ClassVar} concrete_type_hints: Dict[str, Type[Any]] = { - k: resolve_type_hint(v, typevars_map) for k, v in instance_type_hints.items() + k: replace_types(v, typevars_map) for k, v in instance_type_hints.items() } + # Create new model with original model as parent inserting fields with + # updated type hints. model_name = cls.__concrete_name__(params) validators = gather_all_validators(cls) - fields = _build_generic_fields(cls.__fields__, concrete_type_hints, typevars_map) + fields = _build_generic_fields(cls.__fields__, concrete_type_hints) model_module, called_globally = get_caller_frame_info() created_model = cast( Type[GenericModel], # casting ensures mypy is aware of the __concrete__ and __parameters__ attributes @@ -82,12 +104,20 @@ class GenericModel(BaseModel): reference_name += '_' created_model.Config = cls.Config - concrete = all(not _is_typevar(v) for v in concrete_type_hints.values()) - created_model.__concrete__ = concrete - if not concrete: - parameters = tuple(v for v in concrete_type_hints.values() if _is_typevar(v)) - parameters = tuple({k: None for k in parameters}.keys()) # get unique params while maintaining order - created_model.__parameters__ = parameters + + # Find any typevars that are still present in the model. + # If none are left, the model is fully "concrete", otherwise the new + # class is a generic class as well taking the found typevars as + # parameters. + new_params = tuple( + {param: None for param in iter_contained_typevars(typevars_map.values())} + ) # use dict as ordered set + created_model.__concrete__ = not new_params + if new_params: + created_model.__parameters__ = new_params + + # Save created model in cache so we don't end up creating duplicate + # models that should be identical. _generic_types_cache[(cls, params)] = created_model if len(params) == 1: _generic_types_cache[(cls, params[0])] = created_model @@ -95,19 +125,74 @@ class GenericModel(BaseModel): @classmethod def __concrete_name__(cls: Type[Any], params: Tuple[Type[Any], ...]) -> str: + """Compute class name for child classes. + + :param params: Tuple of types the class . Given a generic class + `Model` with 2 type variables and a concrete model `Model[str, int]`, + the value `(str, int)` would be passed to `params`. + :return: String representing a the new class where `params` are + passed to `cls` as type variables. + + This method can be overridden to achieve a custom naming scheme for GenericModels. """ - This method can be overridden to achieve a custom naming scheme for GenericModels - """ - param_names = [param.__name__ if hasattr(param, '__name__') else str(param) for param in params] + param_names = [display_as_type(param) for param in params] params_component = ', '.join(param_names) return f'{cls.__name__}[{params_component}]' -def resolve_type_hint(type_: Any, typevars_map: Dict[Any, Any]) -> Type[Any]: - if get_origin(type_) and getattr(type_, '__parameters__', None): - concrete_type_args = tuple([typevars_map[x] for x in type_.__parameters__]) - return type_[concrete_type_args] - return typevars_map.get(type_, type_) +def replace_types(type_: Any, type_map: Dict[Any, Any]) -> Any: + """Return type with all occurances of `type_map` keys recursively replaced with their values. + + :param type_: Any type, class or generic alias + :type_map: Mapping from `TypeVar` instance to concrete types. + :return: New type representing the basic structure of `type_` with all + `typevar_map` keys recursively replaced. + + >>> replace_types(Tuple[str, Union[List[str], float]], {str: int}) + Tuple[int, Union[List[int], float]] + + """ + if not type_map: + return type_ + + type_args = get_args(type_) + origin_type = get_origin(type_) + + # Having type args is a good indicator that this is a typing module + # class instantiation or a generic alias of some sort. + if type_args: + resolved_type_args = tuple(replace_types(arg, type_map) for arg in type_args) + if all_identical(type_args, resolved_type_args): + # If all arguments are the same, there is no need to modify the + # type or create a new object at all + return type_ + if origin_type is not None and isinstance(type_, typing_base) and not isinstance(origin_type, typing_base): + # In python < 3.9 generic aliases don't exist so any of these like `list`, + # `type` or `collections.abc.Callable` need to be translated. + # See: https://www.python.org/dev/peps/pep-0585 + origin_type = getattr(typing, type_._name) + return origin_type[resolved_type_args] + + # We handle pydantic generic models separately as they don't have the same + # semantics as "typing" classes or generic aliases + if not origin_type and lenient_issubclass(type_, GenericModel) and not type_.__concrete__: + type_args = type_.__parameters__ + resolved_type_args = tuple(replace_types(t, type_map) for t in type_args) + if all_identical(type_args, resolved_type_args): + return type_ + return type_[resolved_type_args] + + # Handle special case for typehints that can have lists as arguments. + # `typing.Callable[[int, str], int]` is an example for this. + if isinstance(type_, (List, list)): + resolved_list = list(replace_types(element, type_map) for element in type_) + if all_identical(type_, resolved_list): + return type_ + return resolved_list + + # If all else fails, we try to resolve the type directly and otherwise just + # return the input with no modifications. + return type_map.get(type_, type_) def check_parameters_count(cls: Type[GenericModel], parameters: Tuple[Any, ...]) -> None: @@ -118,27 +203,26 @@ def check_parameters_count(cls: Type[GenericModel], parameters: Tuple[Any, ...]) raise TypeError(f'Too {description} parameters for {cls.__name__}; actual {actual}, expected {expected}') +def iter_contained_typevars(v: Any) -> Iterator[TypeVarType]: + """Recursively iterate through all subtypes and type args of `v` and yield any typevars that are found.""" + if isinstance(v, TypeVar): + yield v + elif hasattr(v, '__parameters__') and not get_origin(v) and lenient_issubclass(v, GenericModel): + yield from v.__parameters__ + elif isinstance(v, Iterable): + for var in v: + yield from iter_contained_typevars(var) + else: + args = get_args(v) + for arg in args: + yield from iter_contained_typevars(arg) + + def _build_generic_fields( raw_fields: Dict[str, ModelField], concrete_type_hints: Dict[str, Type[Any]], - typevars_map: Dict[TypeVarType, Type[Any]], ) -> Dict[str, Tuple[Type[Any], FieldInfo]]: - return { - k: (_parameterize_generic_field(v, typevars_map), raw_fields[k].field_info) - for k, v in concrete_type_hints.items() - if k in raw_fields - } - - -def _parameterize_generic_field(field_type: Type[Any], typevars_map: Dict[TypeVarType, Type[Any]]) -> Type[Any]: - if lenient_issubclass(field_type, GenericModel) and not field_type.__concrete__: - parameters = tuple(typevars_map.get(param, param) for param in field_type.__parameters__) - field_type = field_type[parameters] - return field_type - - -def _is_typevar(v: Any) -> bool: - return isinstance(v, TypeVar) + return {k: (v, raw_fields[k].field_info) for k, v in concrete_type_hints.items() if k in raw_fields} def get_caller_frame_info() -> Tuple[Optional[str], bool]: diff --git a/pydantic/typing.py b/pydantic/typing.py index e71228f..16d27fc 100644 --- a/pydantic/typing.py +++ b/pydantic/typing.py @@ -25,6 +25,12 @@ try: except ImportError: from typing import _Final as typing_base # type: ignore +try: + from typing import GenericAlias # type: ignore +except ImportError: + # python < 3.9 does not have GenericAlias (list[int], tuple[str, ...] and so on) + GenericAlias = () + if sys.version_info < (3, 7): if TYPE_CHECKING: @@ -74,7 +80,7 @@ else: AnyCallable = TypingCallable[..., Any] NoArgAnyCallable = TypingCallable[[], Any] -if sys.version_info < (3, 8): +if sys.version_info < (3, 8): # noqa: C901 if TYPE_CHECKING: from typing_extensions import Literal else: # due to different mypy warnings raised during CI for python 3.7 and 3.8 @@ -83,8 +89,34 @@ if sys.version_info < (3, 8): except ImportError: Literal = None - def get_args(t: Type[Any]) -> Tuple[Any, ...]: - return getattr(t, '__args__', ()) + if sys.version_info < (3, 7): + + def get_args(t: Type[Any]) -> Tuple[Any, ...]: + """Simplest get_args compatability layer possible. + + The Python 3.6 typing module does not have `_GenericAlias` so + this won't work for everything. In particular this will not + support the `generics` module (we don't support generic models in + python 3.6). + + """ + return getattr(t, '__args__', ()) + + else: + from typing import _GenericAlias + + def get_args(t: Type[Any]) -> Tuple[Any, ...]: + """Compatability version of get_args for python 3.7. + + Mostly compatible with the python 3.8 `typing` module version + and able to handle almost all use cases. + """ + if isinstance(t, _GenericAlias): + res = t.__args__ + if t.__origin__ is Callable and res and res[0] is not Ellipsis: + res = (list(res[:-1]), res[-1]) + return res + return getattr(t, '__args__', ()) def get_origin(t: Type[Any]) -> Optional[Type[Any]]: return getattr(t, '__origin__', None) @@ -179,7 +211,7 @@ NoneType = None.__class__ def display_as_type(v: Type[Any]) -> str: - if not isinstance(v, typing_base) and not isinstance(v, type): + if not isinstance(v, typing_base) and not isinstance(v, GenericAlias) and not isinstance(v, type): v = v.__class__ if isinstance(v, type) and issubclass(v, Enum): @@ -190,6 +222,10 @@ def display_as_type(v: Type[Any]) -> str: else: return 'enum' + if isinstance(v, GenericAlias): + # Generic alias are constructs like `list[int]` + return str(v).replace('typing.', '') + try: return v.__name__ except AttributeError: diff --git a/pydantic/utils.py b/pydantic/utils.py index c75f4e1..1a2c2cb 100644 --- a/pydantic/utils.py +++ b/pydantic/utils.py @@ -2,7 +2,7 @@ import warnings import weakref from collections import OrderedDict, defaultdict, deque from copy import deepcopy -from itertools import islice +from itertools import islice, zip_longest from types import BuiltinFunctionType, CodeType, FunctionType, GeneratorType, LambdaType, ModuleType from typing import ( TYPE_CHECKING, @@ -11,6 +11,7 @@ from typing import ( Callable, Dict, Generator, + Iterable, Iterator, List, Mapping, @@ -639,3 +640,21 @@ def is_valid_private_name(name: str) -> bool: '__orig_bases__', '__qualname__', } + + +_EMPTY = object() + + +def all_identical(left: Iterable[Any], right: Iterable[Any]) -> bool: + """Check that the items of `left` are the same objects as those in `right`. + + >>> a, b = object(), object() + >>> all_identical([a, b, a], [a, b, a]) + True + >>> all_identical([a, b, [a]], [a, b, [a]]) # new list object, while "equal" is not "identical" + False + """ + for left_item, right_item in zip_longest(left, right, fillvalue=_EMPTY): + if left_item is not right_item: + return False + return True diff --git a/tests/test_generics.py b/tests/test_generics.py index ecd39b8..07847e4 100644 --- a/tests/test_generics.py +++ b/tests/test_generics.py @@ -1,11 +1,11 @@ import sys from enum import Enum -from typing import Any, ClassVar, Dict, Generic, List, Optional, Tuple, Type, TypeVar, Union +from typing import Any, Callable, ClassVar, Dict, Generic, List, Optional, Sequence, Tuple, Type, TypeVar, Union import pytest from pydantic import BaseModel, Field, ValidationError, root_validator, validator -from pydantic.generics import GenericModel, _generic_types_cache +from pydantic.generics import GenericModel, _generic_types_cache, iter_contained_typevars, replace_types skip_36 = pytest.mark.skipif(sys.version_info < (3, 7), reason='generics only supported for python 3.7 and above') @@ -17,7 +17,10 @@ def test_generic_name(): class Result(GenericModel, Generic[data_type]): data: data_type - assert Result[List[int]].__name__ == 'Result[typing.List[int]]' + if sys.version_info >= (3, 9): + assert Result[list[int]].__name__ == 'Result[list[int]]' + assert Result[List[int]].__name__ == 'Result[List[int]]' + assert Result[int].__name__ == 'Result[int]' @skip_36 @@ -248,35 +251,6 @@ def test_generic_config(): result.data = 2 -@skip_36 -def test_deep_generic(): - T = TypeVar('T') - S = TypeVar('S') - R = TypeVar('R') - - class OuterModel(GenericModel, Generic[T, S, R]): - a: Dict[R, Optional[List[T]]] - b: Optional[Union[S, R]] - c: R - d: float - - class InnerModel(GenericModel, Generic[T, R]): - c: T - d: R - - 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) - - @skip_36 def test_enum_generic(): T = TypeVar('T') @@ -498,6 +472,26 @@ def test_partial_specification(): ] +@skip_36 +def test_partial_specification_with_inner_typevar(): + AT = TypeVar('AT') + BT = TypeVar('BT') + + class Model(GenericModel, Generic[AT, BT]): + a: List[AT] + b: List[BT] + + partial_model = Model[str, BT] + assert partial_model.__concrete__ is False + concrete_model = partial_model[int] + assert concrete_model.__concrete__ is True + + # nested resolution of partial models should work as expected + nested_resolved = concrete_model(a=[123], b=['456']) + assert nested_resolved.a == ['123'] + assert nested_resolved.b == [456] + + @skip_36 def test_partial_specification_name(): AT = TypeVar('AT') @@ -681,7 +675,11 @@ def test_generic_model_from_function_pickle_fail(create_module): @skip_36 -def test_generic_model_redefined_without_cache_fail(create_module): +def test_generic_model_redefined_without_cache_fail(create_module, monkeypatch): + + # match identity checker otherwise we never get to the redefinition check + monkeypatch.setattr('pydantic.generics.all_identical', lambda left, right: False) + @create_module def module(): from typing import Generic, TypeVar @@ -756,3 +754,264 @@ def test_get_caller_frame_info_when_sys_getframe_undefined(): assert get_caller_frame_info() == (None, False) finally: # just to make sure we always setting original attribute back sys._getframe = getframe + + +@skip_36 +def test_iter_contained_typevars(): + T = TypeVar('T') + T2 = TypeVar('T2') + + class Model(GenericModel, Generic[T]): + a: T + + assert list(iter_contained_typevars(Model[T])) == [T] + assert list(iter_contained_typevars(Optional[List[Union[str, Model[T]]]])) == [T] + assert list(iter_contained_typevars(Optional[List[Union[str, Model[int]]]])) == [] + assert list(iter_contained_typevars(Optional[List[Union[str, Model[T], Callable[[T2, T], str]]]])) == [T, T2, T] + + +@skip_36 +def test_nested_identity_parameterization(): + T = TypeVar('T') + T2 = TypeVar('T2') + + class Model(GenericModel, Generic[T]): + a: T + + assert Model[T][T][T] is Model + assert Model[T] is Model + assert Model[T2] is not Model + + +@skip_36 +def test_replace_types(): + T = TypeVar('T') + + class Model(GenericModel, Generic[T]): + a: T + + assert replace_types(T, {T: int}) is int + assert replace_types(List[Union[str, list, T]], {T: int}) == List[Union[str, list, int]] + assert replace_types(Callable, {T: int}) == Callable + assert replace_types(Callable[[int, str, T], T], {T: int}) == Callable[[int, str, int], int] + assert replace_types(T, {}) is T + assert replace_types(Model[List[T]], {T: int}) == Model[List[T]][int] + assert replace_types(T, {}) is T + assert replace_types(Type[T], {T: int}) == Type[int] + assert replace_types(Model[T], {T: T}) == Model[T] + + if sys.version_info >= (3, 9): + # Check generic aliases (subscripted builtin types) to make sure they + # resolve correctly (don't get translated to typing versions for + # example) + assert replace_types(list[Union[str, list, T]], {T: int}) == list[Union[str, list, int]] + + +@skip_36 +def test_replace_types_identity_on_unchanged(): + T = TypeVar('T') + U = TypeVar('U') + + type_ = List[Union[str, Callable[[list], Optional[str]], U]] + assert replace_types(type_, {T: int}) is type_ + + +@skip_36 +def test_deep_generic(): + T = TypeVar('T') + S = TypeVar('S') + R = TypeVar('R') + + class OuterModel(GenericModel, Generic[T, S, R]): + a: Dict[R, Optional[List[T]]] + b: Optional[Union[S, R]] + c: R + d: float + + class InnerModel(GenericModel, Generic[T, R]): + c: T + d: R + + 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 + + +@skip_36 +def test_deep_generic_with_inner_typevar(): + T = TypeVar('T') + + class OuterModel(GenericModel, Generic[T]): + a: List[T] + + class InnerModel(OuterModel[T], Generic[T]): + pass + + assert InnerModel[int].__concrete__ is True + assert InnerModel.__concrete__ is False + + with pytest.raises(ValidationError): + InnerModel[int](a=['wrong']) + assert InnerModel[int](a=['1']).a == [1] + + +@skip_36 +def test_deep_generic_with_referenced_generic(): + T = TypeVar('T') + R = TypeVar('R') + + class ReferencedModel(GenericModel, Generic[R]): + a: R + + class OuterModel(GenericModel, Generic[T]): + a: ReferencedModel[T] + + class InnerModel(OuterModel[T], Generic[T]): + pass + + assert InnerModel[int].__concrete__ is True + assert InnerModel.__concrete__ is False + + with pytest.raises(ValidationError): + InnerModel[int](a={'a': 'wrong'}) + assert InnerModel[int](a={'a': 1}).a.a == 1 + + +@skip_36 +def test_deep_generic_with_referenced_inner_generic(): + T = TypeVar('T') + + class ReferencedModel(GenericModel, Generic[T]): + a: T + + class OuterModel(GenericModel, Generic[T]): + a: Optional[List[Union[ReferencedModel[T], str]]] + + class InnerModel(OuterModel[T], Generic[T]): + pass + + assert InnerModel[int].__concrete__ is True + assert InnerModel.__concrete__ is False + + with pytest.raises(ValidationError): + InnerModel[int](a=['s', {'a': 'wrong'}]) + assert InnerModel[int](a=['s', {'a': 1}]).a[1].a == 1 + + assert InnerModel[int].__fields__['a'].outer_type_ == List[Union[ReferencedModel[int], str]] + assert (InnerModel[int].__fields__['a'].sub_fields[0].sub_fields[0].outer_type_.__fields__['a'].outer_type_) == int + + +@skip_36 +def test_deep_generic_with_multiple_typevars(): + T = TypeVar('T') + U = TypeVar('U') + + class OuterModel(GenericModel, Generic[T]): + data: List[T] + + class InnerModel(OuterModel[T], Generic[U, T]): + extra: U + + ConcreteInnerModel = InnerModel[int, float] + assert ConcreteInnerModel.__fields__['data'].outer_type_ == List[float] + assert ConcreteInnerModel.__fields__['extra'].outer_type_ == int + + assert ConcreteInnerModel(data=['1'], extra='2').dict() == {'data': [1.0], 'extra': 2} + + +@skip_36 +def test_deep_generic_with_multiple_inheritance(): + K = TypeVar('K') + V = TypeVar('V') + T = TypeVar('T') + + class OuterModelA(GenericModel, Generic[K, V]): + data: Dict[K, V] + + class OuterModelB(GenericModel, Generic[T]): + stuff: List[T] + + class InnerModel(OuterModelA[K, V], OuterModelB[T], Generic[K, V, T]): + extra: int + + ConcreteInnerModel = InnerModel[int, float, str] + + assert ConcreteInnerModel.__fields__['data'].outer_type_ == Dict[int, float] + assert ConcreteInnerModel.__fields__['stuff'].outer_type_ == List[str] + assert ConcreteInnerModel.__fields__['extra'].outer_type_ == int + + ConcreteInnerModel(data={1.1: '5'}, stuff=[123], extra=5).dict() == { + 'data': {1: 5}, + 'stuff': ['123'], + 'extra': 5, + } + + +@skip_36 +def test_generic_with_referenced_generic_type_1(): + T = TypeVar('T') + + class ModelWithType(GenericModel, Generic[T]): + # Type resolves to type origin of "type" which is non-subscriptible for + # python < 3.9 so we want to make sure it works for other versions + some_type: Type[T] + + class ReferenceModel(GenericModel, Generic[T]): + abstract_base_with_type: ModelWithType[T] + + ReferenceModel[int] + + +@skip_36 +def test_generic_with_referenced_nested_typevar(): + T = TypeVar('T') + + class ModelWithType(GenericModel, Generic[T]): + # Type resolves to type origin of "collections.abc.Sequence" which is + # non-subscriptible for + # python < 3.9 so we want to make sure it works for other versions + some_type: Sequence[T] + + class ReferenceModel(GenericModel, Generic[T]): + abstract_base_with_type: ModelWithType[T] + + ReferenceModel[int] + + +@skip_36 +def test_generic_with_callable(): + T = TypeVar('T') + + class Model(GenericModel, Generic[T]): + # Callable is a test for any type that accepts a list as an argument + some_callable: Callable[[Optional[int], T], None] + + Model[str].__concrete__ is True + Model.__concrete__ is False + + +@skip_36 +def test_generic_with_partial_callable(): + T = TypeVar('T') + U = TypeVar('U') + + class Model(GenericModel, Generic[T, U]): + t: T + u: U + # Callable is a test for any type that accepts a list as an argument + some_callable: Callable[[Optional[int], str], None] + + Model[str, U].__concrete__ is False + Model[str, U].__parameters__ == [U] + Model[str, int].__concrete__ is False diff --git a/tests/test_utils.py b/tests/test_utils.py index 8dd736c..1850c0f 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -27,6 +27,7 @@ from pydantic.utils import ( BUILTIN_COLLECTIONS, ClassAttribute, ValueItems, + all_identical, deep_update, get_model, import_string, @@ -60,11 +61,18 @@ def test_import_no_attr(): assert exc_info.value.args[0] == 'Module "os" does not define a "foobar" attribute' -@pytest.mark.parametrize('value,expected', ((str, 'str'), ('string', 'str'), (Union[str, int], 'Union[str, int]'))) +@pytest.mark.parametrize( + 'value,expected', ((str, 'str'), ('string', 'str'), (Union[str, int], 'Union[str, int]'), (list, 'list')) +) def test_display_as_type(value, expected): assert display_as_type(value) == expected +@pytest.mark.skipif(sys.version_info < (3, 9), reason='generic aliases are not available in python < 3.9') +def test_display_as_type_generic_alias(): + assert display_as_type(list[[Union[str, int]]]) == 'list[[Union[str, int]]]' + + def test_display_as_type_enum(): class SubField(Enum): a = 1 @@ -446,3 +454,18 @@ def test_resolve_annotations_no_module(): # TODO: is there a better test for this, can this case really happen? fr = ForwardRef('Foo') assert resolve_annotations({'Foo': ForwardRef('Foo')}, None) == {'Foo': fr} + + +def test_all_identical(): + a, b = object(), object() + c = [b] + assert all_identical([a, b], [a, b]) is True + assert all_identical([a, b], [a, b]) is True + assert all_identical([a, b, b], [a, b, b]) is True + assert all_identical([a, c, b], [a, c, b]) is True + + assert all_identical([], [a]) is False, 'Expected iterables with different lengths to evaluate to `False`' + assert all_identical([a], []) is False, 'Expected iterables with different lengths to evaluate to `False`' + assert ( + all_identical([a, [b], b], [a, [b], b]) is False + ), 'New list objects are different objects and should therefor not be identical.'