267 lines
11 KiB
Python
267 lines
11 KiB
Python
import sys
|
|
import typing
|
|
from typing import (
|
|
TYPE_CHECKING,
|
|
Any,
|
|
ClassVar,
|
|
Dict,
|
|
Generic,
|
|
Iterator,
|
|
List,
|
|
Mapping,
|
|
Optional,
|
|
Tuple,
|
|
Type,
|
|
TypeVar,
|
|
Union,
|
|
cast,
|
|
get_type_hints,
|
|
)
|
|
|
|
from .class_validators import gather_all_validators
|
|
from .fields import DeferredType
|
|
from .main import BaseModel, create_model
|
|
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')
|
|
TypeVarType = Any # since mypy doesn't allow the use of TypeVar as a type
|
|
|
|
|
|
class GenericModel(BaseModel):
|
|
__slots__ = ()
|
|
__concrete__: ClassVar[bool] = False
|
|
|
|
if TYPE_CHECKING:
|
|
# Putting this in a TYPE_CHECKING block allows us to replace `if Generic not in cls.__bases__` with
|
|
# `not hasattr(cls, "__parameters__")`. This means we don't need to force non-concrete subclasses of
|
|
# `GenericModel` to also inherit from `Generic`, which would require changes to the use of `create_model` below.
|
|
__parameters__: ClassVar[Tuple[TypeVarType, ...]]
|
|
|
|
# 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
|
|
if cls.__concrete__ and Generic not in cls.__bases__:
|
|
raise TypeError('Cannot parameterize a concrete instantiation of a generic model')
|
|
if not isinstance(params, tuple):
|
|
params = (params,)
|
|
if cls is GenericModel and any(isinstance(param, TypeVar) for param in params):
|
|
raise TypeError('Type parameters should be placed on typing.Generic, not GenericModel')
|
|
if not hasattr(cls, '__parameters__'):
|
|
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
|
|
|
|
# Create new model with original model as parent inserting fields with DeferredType.
|
|
model_name = cls.__concrete_name__(params)
|
|
validators = gather_all_validators(cls)
|
|
|
|
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}
|
|
|
|
fields = {k: (DeferredType(), cls.__fields__[k].field_info) for k in instance_type_hints if k in cls.__fields__}
|
|
|
|
model_module, called_globally = get_caller_frame_info()
|
|
created_model = cast(
|
|
Type[GenericModel], # casting ensures mypy is aware of the __concrete__ and __parameters__ attributes
|
|
create_model(
|
|
model_name,
|
|
__module__=model_module or cls.__module__,
|
|
__base__=cls,
|
|
__config__=None,
|
|
__validators__=validators,
|
|
**fields,
|
|
),
|
|
)
|
|
|
|
if called_globally: # create global reference and therefore allow pickling
|
|
object_by_reference = None
|
|
reference_name = model_name
|
|
reference_module_globals = sys.modules[created_model.__module__].__dict__
|
|
while object_by_reference is not created_model:
|
|
object_by_reference = reference_module_globals.setdefault(reference_name, created_model)
|
|
reference_name += '_'
|
|
|
|
created_model.Config = cls.Config
|
|
|
|
# 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
|
|
|
|
# Recursively walk class type hints and replace generic typevars
|
|
# with concrete types that were passed.
|
|
_prepare_model_fields(created_model, fields, instance_type_hints, typevars_map)
|
|
|
|
return created_model
|
|
|
|
@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.
|
|
"""
|
|
param_names = [display_as_type(param) for param in params]
|
|
params_component = ', '.join(param_names)
|
|
return f'{cls.__name__}[{params_component}]'
|
|
|
|
|
|
def replace_types(type_: Any, type_map: Mapping[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
|
|
:param 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:
|
|
actual = len(parameters)
|
|
expected = len(cls.__parameters__)
|
|
if actual != expected:
|
|
description = 'many' if actual > expected else 'few'
|
|
raise TypeError(f'Too {description} parameters for {cls.__name__}; actual {actual}, expected {expected}')
|
|
|
|
|
|
DictValues: Type[Any] = {}.values().__class__
|
|
|
|
|
|
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, (DictValues, list)):
|
|
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 get_caller_frame_info() -> Tuple[Optional[str], bool]:
|
|
"""
|
|
Used inside a function to check whether it was called globally
|
|
|
|
Will only work against non-compiled code, therefore used only in pydantic.generics
|
|
|
|
:returns Tuple[module_name, called_globally]
|
|
"""
|
|
try:
|
|
previous_caller_frame = sys._getframe(2)
|
|
except ValueError as e:
|
|
raise RuntimeError('This function must be used inside another function') from e
|
|
except AttributeError: # sys module does not have _getframe function, so there's nothing we can do about it
|
|
return None, False
|
|
frame_globals = previous_caller_frame.f_globals
|
|
return frame_globals.get('__name__'), previous_caller_frame.f_locals is frame_globals
|
|
|
|
|
|
def _prepare_model_fields(
|
|
created_model: Type[GenericModel],
|
|
fields: Mapping[str, Any],
|
|
instance_type_hints: Mapping[str, type],
|
|
typevars_map: Mapping[Any, type],
|
|
) -> None:
|
|
"""
|
|
Replace DeferredType fields with concrete type hints and prepare them.
|
|
"""
|
|
|
|
for key, field in created_model.__fields__.items():
|
|
if key not in fields:
|
|
assert field.type_.__class__ is not DeferredType
|
|
# https://github.com/nedbat/coveragepy/issues/198
|
|
continue # pragma: no cover
|
|
|
|
assert field.type_.__class__ is DeferredType, field.type_.__class__
|
|
|
|
field_type_hint = instance_type_hints[key]
|
|
concrete_type = replace_types(field_type_hint, typevars_map)
|
|
field.type_ = concrete_type
|
|
field.outer_type_ = concrete_type
|
|
field.prepare()
|
|
created_model.__annotations__[key] = concrete_type
|