diff --git a/frappe/__init__.py b/frappe/__init__.py index 6e98b46f04..fff4f475f1 100644 --- a/frappe/__init__.py +++ b/frappe/__init__.py @@ -723,10 +723,10 @@ def apply_validate_argument_types_wrapper(func): :param args: Function arguments. :param kwargs: Function keyword arguments.""" - from frappe.utils.typing_validations import validate_argument_types + from frappe.utils.typing_validations import transform_parameter_types if getattr(local, "request", None) or local.flags.in_test: - validate_argument_types(func, args, kwargs) + args, kwargs = transform_parameter_types(func, args, kwargs) return func(*args, **kwargs) diff --git a/frappe/tests/test_utils.py b/frappe/tests/test_utils.py index 2e22b28731..cd03e8285f 100644 --- a/frappe/tests/test_utils.py +++ b/frappe/tests/test_utils.py @@ -922,7 +922,7 @@ class TestMiscUtils(FrappeTestCase): class TestTypingValidations(FrappeTestCase): - ERR_REGEX = "^type of .* must be .*; got (object|list) instead$" + ERR_REGEX = f"^Argument '.*' should be of type '.*' but got '.*' instead.$" def test_validate_whitelisted_api(self): from inspect import signature diff --git a/frappe/utils/typing_validations.py b/frappe/utils/typing_validations.py index b0eae6b7b8..4967882db5 100644 --- a/frappe/utils/typing_validations.py +++ b/frappe/utils/typing_validations.py @@ -1,20 +1,57 @@ -from inspect import _empty, signature +from inspect import _empty, isclass, signature +from types import EllipsisType from typing import Callable, ForwardRef, Union -from typeguard import check_type +from pydantic import parse_obj_as +from pydantic.error_wrappers import ValidationError as PyValidationError SLACK_DICT = { bool: (int, bool, float), } -def validate_argument_types(func: Callable, args: tuple, kwargs: dict): +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 TypeError( + f"Argument '{arg_name}' should be of type '{qualified_name(arg_type)}' but got " + f"'{qualified_name(arg_value)}' instead." + ) from current_exception + + +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 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] @@ -60,4 +97,22 @@ def validate_argument_types(func: Callable, args: tuple, kwargs: dict): elif param_def.default != current_arg_type: current_arg_type = Union[current_arg_type, type(param_def.default)] - check_type(current_arg, current_arg_value, current_arg_type) + try: + current_arg_value_after = parse_obj_as( + current_arg_type, current_arg_value, type_name=current_arg + ) + except 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) + + else: + 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 + + return args, kwargs diff --git a/pyproject.toml b/pyproject.toml index e2dbfd5443..6b9be6e90a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,6 +51,7 @@ dependencies = [ "psycopg2-binary~=2.9.1", "pyOpenSSL~=22.1.0", "pycryptodome~=3.10.1", + "pydantic~=1.10.2", "pyotp~=2.6.0", "python-dateutil~=2.8.1", "pytz==2022.1", @@ -66,7 +67,6 @@ dependencies = [ "tenacity~=8.0.1", "terminaltables~=3.1.0", "traceback-with-variables~=2.0.4", - "typeguard~=2.13.3", "xlrd~=2.0.1", "zxcvbn-python~=4.4.24", "markdownify~=0.11.2",