feat: Optionally force type checking on whitelisted methods (#36744)

This commit is contained in:
Ankush Menat 2026-02-05 16:20:41 +05:30 committed by GitHub
parent c5d68908e3
commit c9cdacb4ce
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 56 additions and 8 deletions

View file

@ -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

View file

@ -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):

View file

@ -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: