refactor: Do tag input validation with __code__ variables if available (#945)

This commit is contained in:
Juro Oravec 2025-02-02 21:47:34 +01:00 committed by GitHub
parent f52f12579b
commit c7aba40252
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 526 additions and 26 deletions

View file

@ -178,8 +178,9 @@ class NodeMeta(type):
# But cause we stripped the two parameters, then the error will be:
# `render() takes from 1 positional arguments but 2 were given`
args, kwargs = validate_params(
self.tag,
orig_render,
validation_signature,
self.tag,
resolved_params_without_invalid_kwargs,
invalid_kwargs,
)

View file

@ -18,30 +18,28 @@ from django_components.util.tag_parser import TagAttr, parse_tag
# For details see https://github.com/django-components/django-components/pull/902#discussion_r1913611633
# and following comments
def validate_params(
func: Callable[..., Any],
validation_signature: inspect.Signature,
tag: str,
signature: inspect.Signature,
params: List["TagParam"],
extra_kwargs: Optional[Dict[str, Any]] = None,
) -> Tuple[Tuple[Any, ...], Dict[str, Any]]:
"""
Validates a list of TagParam objects against this tag's function signature.
The validation preserves the order of parameters as they appeared in the template.
Raises `TypeError` if the parameters don't match tfuncsignature.
Raises `TypeError` if the parameters don't match the tag's signature.
We have to have a custom validation, because if we simply spread all args and kwargs,
into `BaseNode.render()`, then we won't be able to detect duplicate kwargs or other
errors.
"""
# Create a function that uses the given signature
def validator(*args: Any, **kwargs: Any) -> Tuple[Tuple[Any, ...], Dict[str, Any]]:
return args, kwargs
validator.__signature__ = signature # type: ignore[attr-defined]
# Call the validator with our args and kwargs in the same order as they appeared
# in the template, to let the Python interpreter validate on repeated kwargs.
supports_code_objects = func is not None and hasattr(func, "__code__") and hasattr(func.__code__, "co_varnames")
try:
# Returns args, kwargs
return apply_params_in_original_order(validator, params, extra_kwargs)
if supports_code_objects:
args, kwargs = _validate_params_with_code(func, params, extra_kwargs)
else:
args, kwargs = _validate_params_with_signature(validation_signature, params, extra_kwargs)
return args, kwargs
except TypeError as e:
raise TypeError(f"Invalid parameters for tag '{tag}': {str(e)}") from None
@ -227,8 +225,8 @@ def merge_repeated_kwargs(params: List[TagParam]) -> List[TagParam]:
return resolved_params
def apply_params_in_original_order(
fn: Callable[..., Any],
def _validate_params_with_signature(
signature: inspect.Signature,
params: List[TagParam],
extra_kwargs: Optional[Dict[str, Any]] = None,
) -> Any:
@ -258,8 +256,6 @@ def apply_params_in_original_order(
Returns the result of calling fn with the validated parameters
"""
signature = inspect.signature(fn)
# Track state as we process parameters
seen_kwargs = False # To detect positional args after kwargs
used_param_names = set() # To detect duplicate kwargs
@ -294,8 +290,8 @@ def apply_params_in_original_order(
# Process parameters in their original order
for param in params:
# This is a positional argument
if param.key is None:
# This is a positional argument
if seen_kwargs:
raise TypeError("positional argument follows keyword argument")
@ -323,20 +319,141 @@ def apply_params_in_original_order(
if param.key in used_param_names:
raise TypeError(f"got multiple values for argument '{param.key}'")
# Only validate kwarg names if the function doesn't accept **kwargs
# Validate kwarg names if the function doesn't accept **kwargs
if not has_var_keyword and param.key not in valid_params:
raise TypeError(f"got an unexpected keyword argument '{param.key}'")
validated_kwargs[param.key] = param.value
used_param_names.add(param.key)
# Add any extra kwargs - These are allowed only if the function accepts **kwargs
if extra_kwargs:
if not has_var_keyword:
first_key = next(iter(extra_kwargs))
raise TypeError(f"got an unexpected keyword argument '{first_key}'")
validated_kwargs.update(extra_kwargs)
# Check for missing required arguments and apply defaults
for param_name, signature_param in params_by_name.items():
if param_name in used_param_names or param_name in validated_kwargs:
continue
if signature_param.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD):
if signature_param.default == inspect.Parameter.empty:
raise TypeError(f"missing a required argument: '{param_name}'")
elif len(validated_args) <= next_positional_index:
validated_kwargs[param_name] = signature_param.default
elif signature_param.kind == inspect.Parameter.KEYWORD_ONLY:
if signature_param.default == inspect.Parameter.empty:
raise TypeError(f"missing a required argument: '{param_name}'")
else:
validated_kwargs[param_name] = signature_param.default
# Return args and kwargs
return validated_args, validated_kwargs
def _validate_params_with_code(
fn: Callable[..., Any],
params: List["TagParam"],
extra_kwargs: Optional[Dict[str, Any]] = None,
) -> Tuple[Tuple[Any, ...], Dict[str, Any]]:
"""
Validate and process function parameters using __code__ attributes for better performance.
This is the preferred implementation when the necessary attributes are available.
This implementation is about 3x faster than signature-based validation.
For context, see https://github.com/django-components/django-components/issues/935
"""
code = fn.__code__
defaults = fn.__defaults__ or ()
kwdefaults = getattr(fn, "__kwdefaults__", None) or {}
# Get parameter information from code object
param_names = code.co_varnames[: code.co_argcount + code.co_kwonlyargcount]
positional_count = code.co_argcount
kwonly_count = code.co_kwonlyargcount
has_var_positional = bool(code.co_flags & 0x04) # CO_VARARGS
has_var_keyword = bool(code.co_flags & 0x08) # CO_VARKEYWORDS
# Skip self and context parameters
skip_params = 2
param_names = param_names[skip_params:]
positional_count = max(0, positional_count - skip_params)
# Calculate required counts
num_defaults = len(defaults)
required_positional = positional_count - num_defaults
# Track state
seen_kwargs = False
used_param_names = set()
validated_args = []
validated_kwargs = {}
next_positional_index = 0
# Process parameters in order
for param in params:
if param.key is None:
# This is a positional argument
if seen_kwargs:
raise TypeError("positional argument follows keyword argument")
# Check position limit for non-variadic functions
if not has_var_positional and next_positional_index >= positional_count:
if positional_count == 0:
raise TypeError("takes 0 positional arguments but 1 was given")
raise TypeError(f"takes {positional_count} positional argument(s) but more were given")
# For non-variadic arguments, get parameter name
if next_positional_index < positional_count:
param_name = param_names[next_positional_index]
if param_name in used_param_names:
raise TypeError(f"got multiple values for argument '{param_name}'")
used_param_names.add(param_name)
validated_args.append(param.value)
next_positional_index += 1
else:
# This is a keyword argument
seen_kwargs = True
# Check for duplicate kwargs
if param.key in used_param_names:
raise TypeError(f"got multiple values for argument '{param.key}'")
# Validate kwarg names
is_valid_kwarg = param.key in param_names[: positional_count + kwonly_count] or ( # Regular param
has_var_keyword and param.key not in param_names
) # **kwargs param
if not is_valid_kwarg:
raise TypeError(f"got an unexpected keyword argument '{param.key}'")
validated_kwargs[param.key] = param.value
used_param_names.add(param.key)
# Add any extra kwargs
if extra_kwargs:
if not has_var_keyword:
first_key = next(iter(extra_kwargs))
raise TypeError(f"got an unexpected keyword argument '{first_key}'")
validated_kwargs.update(extra_kwargs)
# Final validation using signature.bind()
bound_args = signature.bind(*validated_args, **validated_kwargs)
bound_args.apply_defaults()
# Check for missing required arguments and apply defaults
for i, param_name in enumerate(param_names):
if param_name in used_param_names or param_name in validated_kwargs:
continue
# Actually call the function with validated parameters
return fn(*bound_args.args, **bound_args.kwargs)
if i < positional_count: # Positional parameter
if i < required_positional:
raise TypeError(f"missing a required argument: '{param_name}'")
elif len(validated_args) <= i:
default_index = i - required_positional
validated_kwargs[param_name] = defaults[default_index]
elif i < positional_count + kwonly_count: # Keyword-only parameter
if param_name not in kwdefaults:
raise TypeError(f"missing a required argument: '{param_name}'")
else:
validated_kwargs[param_name] = kwdefaults[param_name]
return tuple(validated_args), validated_kwargs