* Rename API for ease of public use * Add validation condition parameter * Move function to utils module instead of frappe namespace
175 lines
5.3 KiB
Python
175 lines
5.3 KiB
Python
from functools import lru_cache, wraps
|
|
from inspect import _empty, isclass, signature
|
|
from types import EllipsisType
|
|
from typing import Any, Callable, ForwardRef, TypeVar, Union
|
|
|
|
from pydantic.config import BaseConfig
|
|
from pydantic.error_wrappers import ValidationError as PyValidationError
|
|
from pydantic.tools import NameFactory, _generate_parsing_type_name
|
|
|
|
from frappe.exceptions import FrappeTypeError
|
|
|
|
SLACK_DICT = {
|
|
bool: (int, bool, float),
|
|
}
|
|
T = TypeVar("T")
|
|
|
|
|
|
class FrappePydanticConfig:
|
|
arbitrary_types_allowed = True
|
|
|
|
|
|
def validate_argument_types(func: Callable, apply_condition: Callable = lambda: True):
|
|
@wraps(func)
|
|
def wrapper(*args, **kwargs):
|
|
"""Validate argument types of whitelisted functions.
|
|
|
|
:param args: Function arguments.
|
|
:param kwargs: Function keyword arguments."""
|
|
|
|
if apply_condition():
|
|
args, kwargs = transform_parameter_types(func, args, kwargs)
|
|
|
|
return func(*args, **kwargs)
|
|
|
|
return wrapper
|
|
|
|
|
|
def qualified_name(obj) -> str:
|
|
"""
|
|
Return the qualified name (e.g. package.module.Type) for the given object.
|
|
|
|
Builtins and types from the :mod:typing package get special treatment by having the module
|
|
name stripped from the generated name.
|
|
|
|
"""
|
|
discovered_type = obj if isclass(obj) else type(obj)
|
|
module, qualname = discovered_type.__module__, discovered_type.__qualname__
|
|
|
|
if module in {"typing", "types"}:
|
|
return obj
|
|
elif module in {"builtins"}:
|
|
return qualname
|
|
else:
|
|
return f"{module}.{qualname}"
|
|
|
|
|
|
def raise_type_error(
|
|
arg_name: str, arg_type: type, arg_value: object, current_exception: Exception = None
|
|
):
|
|
"""
|
|
Raise a TypeError with a message that includes the name of the argument, the expected type
|
|
and the actual type of the value passed.
|
|
|
|
"""
|
|
raise FrappeTypeError(
|
|
f"Argument '{arg_name}' should be of type '{qualified_name(arg_type)}' but got "
|
|
f"'{qualified_name(arg_value)}' instead."
|
|
) from current_exception
|
|
|
|
|
|
@lru_cache(maxsize=2048)
|
|
def _get_parsing_type(
|
|
type_: Any, *, type_name: NameFactory | None = None, config: type[BaseConfig] = None
|
|
) -> Any:
|
|
# Note: this is a copy of pydantic.tools._get_parsing_type with the addition of allowing a config argument
|
|
from pydantic.main import create_model
|
|
|
|
if type_name is None:
|
|
type_name = _generate_parsing_type_name
|
|
if not isinstance(type_name, str):
|
|
type_name = type_name(type_)
|
|
return create_model(type_name, __root__=(type_, ...), __config__=config)
|
|
|
|
|
|
def parse_obj_as(
|
|
type_: type[T],
|
|
obj: Any,
|
|
*,
|
|
type_name: NameFactory | None = None,
|
|
config: type[BaseConfig] | None = None,
|
|
) -> T:
|
|
# Note: This is a copy of pydantic.tools.parse_obj_as with the addition of allowing a config argument
|
|
model_type = _get_parsing_type(type_, type_name=type_name, config=config) # type: ignore[arg-type]
|
|
return model_type(__root__=obj).__root__
|
|
|
|
|
|
def transform_parameter_types(func: Callable, args: tuple, kwargs: dict):
|
|
"""
|
|
Validate the types of the arguments passed to a function with the type annotations
|
|
defined on the function.
|
|
|
|
"""
|
|
if not (args or kwargs) or not func.__annotations__:
|
|
return args, kwargs
|
|
|
|
annotations = func.__annotations__
|
|
new_args, new_kwargs = list(args), kwargs
|
|
|
|
# generate kwargs dict from args
|
|
arg_names = func.__code__.co_varnames[: func.__code__.co_argcount]
|
|
|
|
if not args:
|
|
prepared_args = kwargs
|
|
|
|
elif kwargs:
|
|
arg_values = args or func.__defaults__ or []
|
|
prepared_args = dict(zip(arg_names, arg_values))
|
|
prepared_args.update(kwargs)
|
|
|
|
else:
|
|
prepared_args = dict(zip(arg_names, args))
|
|
|
|
# check if type hints dont match the default values
|
|
func_signature = signature(func)
|
|
func_params = dict(func_signature.parameters)
|
|
|
|
# check if the argument types are correct
|
|
for current_arg, current_arg_type in annotations.items():
|
|
if current_arg not in prepared_args:
|
|
continue
|
|
|
|
current_arg_value = prepared_args[current_arg]
|
|
|
|
# if the type is a ForwardRef or str, ignore it
|
|
if isinstance(current_arg_type, (ForwardRef, str)):
|
|
continue
|
|
elif any(isinstance(x, (ForwardRef, str)) for x in getattr(current_arg_type, "__args__", [])):
|
|
continue
|
|
|
|
# allow slack for Frappe types
|
|
if current_arg_type in SLACK_DICT:
|
|
current_arg_type = SLACK_DICT[current_arg_type]
|
|
|
|
param_def = func_params.get(current_arg)
|
|
|
|
# add default value's type in acceptable types
|
|
if param_def.default is not _empty:
|
|
if isinstance(current_arg_type, tuple):
|
|
if type(param_def.default) not in current_arg_type:
|
|
current_arg_type += (type(param_def.default),)
|
|
current_arg_type = Union[current_arg_type]
|
|
|
|
elif param_def.default != current_arg_type:
|
|
current_arg_type = Union[current_arg_type, type(param_def.default)]
|
|
elif isinstance(current_arg_type, tuple):
|
|
current_arg_type = Union[current_arg_type]
|
|
|
|
# validate the type set using pydantic - raise a TypeError if Validation is raised or Ellipsis is returned
|
|
try:
|
|
current_arg_value_after = parse_obj_as(
|
|
current_arg_type, current_arg_value, type_name=current_arg, config=FrappePydanticConfig
|
|
)
|
|
except (TypeError, PyValidationError) as e:
|
|
raise_type_error(current_arg, current_arg_type, current_arg_value, current_exception=e)
|
|
|
|
if isinstance(current_arg_value_after, EllipsisType):
|
|
raise_type_error(current_arg, current_arg_type, current_arg_value)
|
|
|
|
# update the args and kwargs with possibly casted value
|
|
if current_arg in kwargs:
|
|
new_kwargs[current_arg] = current_arg_value_after
|
|
else:
|
|
new_args[arg_names.index(current_arg)] = current_arg_value_after
|
|
|
|
return new_args, new_kwargs
|