diff --git a/frappe/__init__.py b/frappe/__init__.py index a783652f0e..6b9e282f56 100644 --- a/frappe/__init__.py +++ b/frappe/__init__.py @@ -716,25 +716,20 @@ xss_safe_methods = [] allowed_http_methods_for_whitelisted_func = {} -def validate_argument_types(func): - return func - from pydantic import validate_arguments as pyd_validator +def apply_validate_argument_types_wrapper(func): + @functools.wraps(func) + def wrapper(*args, **kwargs): + """Validate argument types of whitelisted functions. - def validator(*args, **kwargs): - return pyd_validator(func)(*args, **kwargs) + :param args: Function arguments. + :param kwargs: Function keyword arguments.""" + from frappe.utils.typing_validations import validate_argument_types - import sys + if getattr(local, "request", None): + validate_argument_types(func, args, kwargs) + return func(*args, **kwargs) - # from frappe.model.document import Document - # localns = { - # "Document": Document, - # } - - try: - return validator(func) - except NameError: - sys.stderr.write(f"{func} has unsupported type annotations") - return func + return wrapper def whitelist(allow_guest=False, xss_safe=False, methods=None): @@ -762,8 +757,10 @@ def whitelist(allow_guest=False, xss_safe=False, methods=None): # this is needed because functions can be compared, but not methods method = None if hasattr(fn, "__func__"): - method = fn + method = apply_validate_argument_types_wrapper(fn) fn = method.__func__ + else: + fn = apply_validate_argument_types_wrapper(fn) whitelisted.append(fn) allowed_http_methods_for_whitelisted_func[fn] = methods @@ -774,7 +771,7 @@ def whitelist(allow_guest=False, xss_safe=False, methods=None): if xss_safe: xss_safe_methods.append(fn) - return validate_argument_types(method or fn) + return method or fn return innerfn diff --git a/frappe/utils/typing_validations.py b/frappe/utils/typing_validations.py new file mode 100644 index 0000000000..921331fa4c --- /dev/null +++ b/frappe/utils/typing_validations.py @@ -0,0 +1,56 @@ +from inspect import isclass +from typing import Callable, ForwardRef, Sequence + + +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 == "types": + return obj + elif module in {"typing", "builtins"}: + return qualname + else: + return f"{module}.{qualname}" + + +def validate_argument_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 annotations := func.__annotations__: + # generate kwargs dict from args + arg_names = func.__code__.co_varnames[: func.__code__.co_argcount] + arg_values = args or func.__defaults__ or [] + prepared_args = dict(zip(arg_names, arg_values)) + prepared_args.update(kwargs) + + # 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 + + if isinstance(current_arg_type, Sequence): + current_arg_type = tuple( + x for x in current_arg_type.__args__ if not isinstance(x, (ForwardRef, str)) + ) + + if not isinstance(current_arg_value, current_arg_type): + raise TypeError( + f"Argument '{current_arg}' must be of type '{qualified_name(current_arg_type)}' but got '{qualified_name(current_arg_value)}'" + ) diff --git a/pyproject.toml b/pyproject.toml index b17ecdc211..66fef2160a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,7 +52,6 @@ dependencies = [ "pyOpenSSL~=22.1.0", "pycryptodome~=3.10.1", "pyotp~=2.6.0", - "pydantic~=1.10.2", "python-dateutil~=2.8.1", "pytz==2022.1", "rauth~=0.7.3",