From c9cdacb4ceae3728414f8f7e46f6241da0ee949f Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Thu, 5 Feb 2026 16:20:41 +0530 Subject: [PATCH] feat: Optionally force type checking on whitelisted methods (#36744) --- frappe/__init__.py | 6 ++++-- frappe/tests/test_utils.py | 24 +++++++++++++++++++++ frappe/utils/typing_validations.py | 34 ++++++++++++++++++++++++------ 3 files changed, 56 insertions(+), 8 deletions(-) diff --git a/frappe/__init__.py b/frappe/__init__.py index 11f48eaaa9..9f38c7cbbc 100644 --- a/frappe/__init__.py +++ b/frappe/__init__.py @@ -414,13 +414,15 @@ def _in_request_or_test(): return getattr(local, "request", None) or in_test -def whitelist(allow_guest=False, xss_safe=False, methods=None): +def whitelist(allow_guest=False, xss_safe=False, methods=None, force_types=None): """ Decorator for whitelisting a function and making it accessible via HTTP. Standard request will be `/api/method/[path.to.method]` :param allow_guest: Allow non logged-in user to access this method. :param methods: Allowed http method to access the method. + :param force_types: Method should have type annotations. If unset, defaults to hooks + specification. Use as: @@ -438,7 +440,7 @@ def whitelist(allow_guest=False, xss_safe=False, methods=None): global whitelisted, guest_methods, xss_safe_methods, allowed_http_methods_for_whitelisted_func # validate argument types if request is present or in test context - fn = validate_argument_types(fn, apply_condition=_in_request_or_test) + fn = validate_argument_types(fn, apply_condition=_in_request_or_test, force_types=force_types) whitelisted.add(fn) allowed_http_methods_for_whitelisted_func[fn] = methods diff --git a/frappe/tests/test_utils.py b/frappe/tests/test_utils.py index cd074696e0..496083de18 100644 --- a/frappe/tests/test_utils.py +++ b/frappe/tests/test_utils.py @@ -1309,6 +1309,30 @@ class TestTypingValidations(IntegrationTestCase): report.toggle_disable(changed_value) report.toggle_disable(current_value) + def test_forced_types(self): + def func(a, b=None, **kwargs): + pass + + lax_types = frappe.whitelist(force_types=False)(func) + lax_types(1) # should run without error + + forced_types = frappe.whitelist(force_types=True)(func) + with self.assertRaises(frappe.FrappeTypeError): + forced_types(1) + + @frappe.whitelist(force_types=True) + def func(a: int, b=None, **kwargs): + pass + + with self.assertRaises(frappe.FrappeTypeError): + func(1) + + @frappe.whitelist(force_types=True) + def func(a: int, b: int | None = None, **kwargs): + pass + + func(1) # should run without error + class TestTBSanitization(IntegrationTestCase): def test_traceback_sanitzation(self): diff --git a/frappe/utils/typing_validations.py b/frappe/utils/typing_validations.py index ebd67bdeeb..6940870bb4 100644 --- a/frappe/utils/typing_validations.py +++ b/frappe/utils/typing_validations.py @@ -1,3 +1,4 @@ +import inspect from collections.abc import Callable from functools import lru_cache, wraps from inspect import _empty, isclass @@ -22,7 +23,13 @@ ForwardRefOrStr = ForwardRef | str FrappePydanticConfig = ConfigDict(arbitrary_types_allowed=True) -def validate_argument_types(func: Callable, apply_condition: Callable | None = None): +def validate_argument_types( + func: Callable, + apply_condition: Callable | None = None, + force_types: bool | None = None, +): + app = func.__module__.split(".")[0] + @wraps(func) def wrapper(*args, **kwargs): """Validate argument types of whitelisted functions. @@ -30,8 +37,14 @@ def validate_argument_types(func: Callable, apply_condition: Callable | None = N :param args: Function arguments. :param kwargs: Function keyword arguments.""" + nonlocal force_types + + # Resolve it only once + if force_types is None: + force_types = any(frappe.get_hooks("require_type_annotated_api_methods", app_name=app)) + if apply_condition is None or apply_condition(): - args, kwargs = transform_parameter_types(func, args, kwargs) + args, kwargs = transform_parameter_types(func, args, kwargs, force_types) return func(*args, **kwargs) @@ -88,13 +101,25 @@ def TypeAdapter(type_): raise e -def transform_parameter_types(func: Callable, args: tuple, kwargs: dict): +def transform_parameter_types(func: Callable, args: tuple, kwargs: dict, force_types=False): """ Validate the types of the arguments passed to a function with the type annotations defined on the function. """ annotations = func.__annotations__ + func_params = frappe._get_cached_signature_params(func)[0] + + if force_types: + for param_name, parameter in func_params.items(): + if parameter.kind in (inspect.Parameter.VAR_KEYWORD, inspect.Parameter.VAR_POSITIONAL): + continue + if param_name not in annotations: + module, qualname = func.__module__, func.__qualname__ + raise FrappeTypeError( + f"Argument '{param_name}' in '{module}.{qualname}' is missing type annotation. " + f"All arguments must have type annotations when type checking is enforced." + ) if ( not (args or kwargs) @@ -118,9 +143,6 @@ def transform_parameter_types(func: Callable, args: tuple, kwargs: dict): else: prepared_args = kwargs - # check if type hints dont match the default values - func_params = frappe._get_cached_signature_params(func)[0] - # check if the argument types are correct for current_arg, current_arg_type in annotations.items(): if current_arg not in prepared_args: