refactor: transform_parameter_types

- Switch to Pydantic which is under continuous development and can
  support more types
- Equivalent Pydantic API will try to transform data if possible
- The previous point makes it such that we don't need to explicitly try
  to parse each stringified int in app code since Pydantic can do this
- Drop typeguard since it did not handle 3.10+ native typing definitions
This commit is contained in:
Gavin D'souza 2022-12-01 11:38:44 +05:30
parent 73b0971a26
commit 4fe260e09e
4 changed files with 63 additions and 8 deletions

View file

@ -723,10 +723,10 @@ def apply_validate_argument_types_wrapper(func):
:param args: Function arguments. :param args: Function arguments.
:param kwargs: Function keyword 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: 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) return func(*args, **kwargs)

View file

@ -922,7 +922,7 @@ class TestMiscUtils(FrappeTestCase):
class TestTypingValidations(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): def test_validate_whitelisted_api(self):
from inspect import signature from inspect import signature

View file

@ -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 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 = { SLACK_DICT = {
bool: (int, bool, float), 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 Validate the types of the arguments passed to a function with the type annotations
defined on the function. defined on the function.
""" """
if annotations := func.__annotations__: if annotations := func.__annotations__:
new_args, new_kwargs = list(args), kwargs
# generate kwargs dict from args # generate kwargs dict from args
arg_names = func.__code__.co_varnames[: func.__code__.co_argcount] 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: elif param_def.default != current_arg_type:
current_arg_type = Union[current_arg_type, type(param_def.default)] 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

View file

@ -51,6 +51,7 @@ dependencies = [
"psycopg2-binary~=2.9.1", "psycopg2-binary~=2.9.1",
"pyOpenSSL~=22.1.0", "pyOpenSSL~=22.1.0",
"pycryptodome~=3.10.1", "pycryptodome~=3.10.1",
"pydantic~=1.10.2",
"pyotp~=2.6.0", "pyotp~=2.6.0",
"python-dateutil~=2.8.1", "python-dateutil~=2.8.1",
"pytz==2022.1", "pytz==2022.1",
@ -66,7 +67,6 @@ dependencies = [
"tenacity~=8.0.1", "tenacity~=8.0.1",
"terminaltables~=3.1.0", "terminaltables~=3.1.0",
"traceback-with-variables~=2.0.4", "traceback-with-variables~=2.0.4",
"typeguard~=2.13.3",
"xlrd~=2.0.1", "xlrd~=2.0.1",
"zxcvbn-python~=4.4.24", "zxcvbn-python~=4.4.24",
"markdownify~=0.11.2", "markdownify~=0.11.2",