refactor: remove use of eval for node validation (#944)

This commit is contained in:
Juro Oravec 2025-02-02 10:35:39 +01:00 committed by GitHub
parent de32d449d9
commit f52f12579b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 216 additions and 101 deletions

View file

@ -10,8 +10,6 @@ from django_components.util.logger import trace_node_msg
from django_components.util.misc import gen_id from django_components.util.misc import gen_id
from django_components.util.template_tag import ( from django_components.util.template_tag import (
TagAttr, TagAttr,
TagParam,
apply_params_in_original_order,
parse_template_tag, parse_template_tag,
resolve_params, resolve_params,
validate_params, validate_params,
@ -159,8 +157,7 @@ class NodeMeta(type):
# Validate the params against the signature # Validate the params against the signature
# #
# Unlike the call to `apply_params_in_original_order()` further below, this uses a signature # This uses a signature that has been stripped of the `self` and `context` parameters. E.g.
# that has been stripped of the `self` and `context` parameters. E.g.
# #
# `def render(name: str, **kwargs: Any) -> None` # `def render(name: str, **kwargs: Any) -> None`
# #
@ -180,21 +177,14 @@ class NodeMeta(type):
# #
# But cause we stripped the two parameters, then the error will be: # But cause we stripped the two parameters, then the error will be:
# `render() takes from 1 positional arguments but 2 were given` # `render() takes from 1 positional arguments but 2 were given`
validate_params(self.tag, validation_signature, resolved_params_without_invalid_kwargs, invalid_kwargs) args, kwargs = validate_params(
self.tag,
validation_signature,
resolved_params_without_invalid_kwargs,
invalid_kwargs,
)
# The code below calls the `orig_render()` function like so: output = orig_render(self, context, *args, **kwargs)
# `orig_render(self, context, arg1, arg2, kwarg1=val1, kwarg2=val2)`
#
# So it's called in the same order as what was passed to the template tag, e.g.
# `{% component arg1 arg2 kwarg1=val1 kwarg2=val2 %}`
#
# That's why we don't simply spread all args and kwargs as `*args, **kwargs`,
# because then Python's validation wouldn't catch such errors.
resolved_params_with_context = [
TagParam(key=None, value=self),
TagParam(key=None, value=context),
] + resolved_params_without_invalid_kwargs
output = apply_params_in_original_order(orig_render, resolved_params_with_context, invalid_kwargs)
trace_node_msg("RENDER", self.tag, self.node_id, msg="...Done!") trace_node_msg("RENDER", self.tag, self.node_id, msg="...Done!")
return output return output

View file

@ -4,9 +4,8 @@ This file is for logic that focuses on transforming the AST of template tags
""" """
import inspect import inspect
import sys
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Callable, Dict, Iterable, List, Mapping, NamedTuple, Optional, Set, Tuple, Union from typing import Any, Callable, Dict, Iterable, List, Mapping, NamedTuple, Optional, Set, Tuple
from django.template import Context, NodeList from django.template import Context, NodeList
from django.template.base import Parser, Token from django.template.base import Parser, Token
@ -23,7 +22,7 @@ def validate_params(
signature: inspect.Signature, signature: inspect.Signature,
params: List["TagParam"], params: List["TagParam"],
extra_kwargs: Optional[Dict[str, Any]] = None, extra_kwargs: Optional[Dict[str, Any]] = None,
) -> None: ) -> Tuple[Tuple[Any, ...], Dict[str, Any]]:
""" """
Validates a list of TagParam objects against this tag's function signature. Validates a list of TagParam objects against this tag's function signature.
@ -33,17 +32,16 @@ def validate_params(
""" """
# Create a function that uses the given signature # Create a function that uses the given signature
def validator(*args: Any, **kwargs: Any) -> None: def validator(*args: Any, **kwargs: Any) -> Tuple[Tuple[Any, ...], Dict[str, Any]]:
# Let Python do the signature validation return args, kwargs
bound = signature.bind(*args, **kwargs)
bound.apply_defaults()
validator.__signature__ = signature # type: ignore[attr-defined] validator.__signature__ = signature # type: ignore[attr-defined]
# Call the validator with our args and kwargs in the same order as they appeared # 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. # in the template, to let the Python interpreter validate on repeated kwargs.
try: try:
apply_params_in_original_order(validator, params, extra_kwargs) # Returns args, kwargs
return apply_params_in_original_order(validator, params, extra_kwargs)
except TypeError as e: except TypeError as e:
raise TypeError(f"Invalid parameters for tag '{tag}': {str(e)}") from None raise TypeError(f"Invalid parameters for tag '{tag}': {str(e)}") from None
@ -244,82 +242,101 @@ def apply_params_in_original_order(
{% component key1=value1 arg1 arg2 key2=value2 key3=value3 %} {% component key1=value1 arg1 arg2 key2=value2 key3=value3 %}
``` ```
Then `apply_params_in_original_order()` will call the `fn` like this: Then it will be as if the given function was called like this:
```
component(
key1=call_params[0], # kwarg 1
call_params[1], # arg 1
call_params[2], # arg 2
key2=call_params[3], # kwarg 2
key3=call_params[4], # kwarg 3
...
**extra_kwargs,
)
```
This way, this will be effectively the same as:
```python ```python
component(key1=value1, arg1, arg2, key2=value2, key3=value3, ..., **extra_kwargs) fn(key1=value1, arg1, arg2, key2=value2, key3=value3)
``` ```
The problem this works around is that, dynamically, args and kwargs in Python This function validates that the template tag's parameters match the function's signature
can be passed only with `*args` and `**kwargs`. But in such case, we're already and follow Python's function calling conventions. It will raise appropriate TypeError exceptions
grouping all args and kwargs, which may not represent the original order of the params for invalid parameter combinations, such as:
as they appeared in the template tag. - Too few/many arguments (for non-variadic functions)
- Duplicate keyword arguments
- Mixed positional/keyword argument errors
- Positional args after kwargs
If you need to pass kwargs that are not valid Python identifiers, e.g. `data-id`, `class`, `:href`, Returns the result of calling fn with the validated parameters
you can pass them in via `extra_kwargs`. These kwargs will be exempt from the validation, and will be
passed to the function as a dictionary spread.
""" """
# Generate a script like so: signature = inspect.signature(fn)
# ```py
# component( # Track state as we process parameters
# key1=call_params[0], seen_kwargs = False # To detect positional args after kwargs
# call_params[1], used_param_names = set() # To detect duplicate kwargs
# call_params[2], validated_args = []
# key2=call_params[3], validated_kwargs = {}
# key3=call_params[4],
# ... # Get list of valid parameter names and analyze signature
# **extra_kwargs, params_by_name = signature.parameters
# ) valid_params = list(params_by_name.keys())
# ```
# # Check if function accepts variable arguments (*args, **kwargs)
# NOTE: Instead of grouping params into args and kwargs, we preserve the original order has_var_positional = any(param.kind == inspect.Parameter.VAR_POSITIONAL for param in params_by_name.values())
# of the params as they appeared in the template. has_var_keyword = any(param.kind == inspect.Parameter.VAR_KEYWORD for param in params_by_name.values())
#
# NOTE: Because we use `eval()` here, we can't trust neither the param keys nor values. # Find the last positional parameter index (excluding *args)
# So we MUST NOT reference them directly in the exec script, otherwise we'd be at risk max_positional_index = 0
# of injection attack. for i, signature_param in enumerate(params_by_name.values()):
# if signature_param.kind in (
# Currently, the use of `eval()` is safe, because we control the input: inspect.Parameter.POSITIONAL_ONLY,
# - List with indices is used so that we don't have to reference directly or try to print the values. inspect.Parameter.POSITIONAL_OR_KEYWORD,
# and instead refer to them as `call_params[0]`, `call_params[1]`, etc. ):
# - List indices are safe, because we generate them. max_positional_index = i + 1
# - Kwarg names come from the user. But Python expects the kwargs to be valid identifiers. elif signature_param.kind == inspect.Parameter.VAR_POSITIONAL:
# So if a key is not a valid identifier, we'll raise an error. Before passing it to `eval()` # Don't count *args in max_positional_index
validator_call_script = "fn(" break
call_params: List[Union[List, Dict]] = [] # Parameter.KEYWORD_ONLY
for index, param in enumerate(params): # Parameter.VAR_KEYWORD
call_params.append(param.value) else:
break
next_positional_index = 0
# Process parameters in their original order
for param in params:
if param.key is None: if param.key is None:
validator_call_script += f"call_params[{index}], " # This is a positional argument
if seen_kwargs:
raise TypeError("positional argument follows keyword argument")
# Only check position limit for non-variadic functions
if not has_var_positional and next_positional_index >= max_positional_index:
if max_positional_index == 0:
raise TypeError(f"takes 0 positional arguments but {next_positional_index + 1} was given")
raise TypeError(f"takes {max_positional_index} positional argument(s) but more were given")
# For non-variadic arguments, get the parameter name this maps to
if next_positional_index < max_positional_index:
param_name = valid_params[next_positional_index]
# Check if this parameter was already provided as a kwarg
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: else:
validator_call_script += f"{param.key}=call_params[{index}], " # This is a keyword argument
seen_kwargs = True
validator_call_script += "**extra_kwargs, " # Check for duplicate kwargs
validator_call_script += ")" if param.key in used_param_names:
raise TypeError(f"got multiple values for argument '{param.key}'")
def applier(fn: Callable[..., Any]) -> Any: # Only validate kwarg names if the function doesn't accept **kwargs
locals = { if not has_var_keyword and param.key not in valid_params:
"fn": fn, raise TypeError(f"got an unexpected keyword argument '{param.key}'")
"call_params": call_params,
"extra_kwargs": extra_kwargs or {},
}
# NOTE: `eval()` changed API in Python 3.13
if sys.version_info >= (3, 13):
return eval(validator_call_script, globals={}, locals=locals)
else:
return eval(validator_call_script, {}, locals)
return applier(fn) validated_kwargs[param.key] = param.value
used_param_names.add(param.key)
# Add any extra kwargs
if extra_kwargs:
validated_kwargs.update(extra_kwargs)
# Final validation using signature.bind()
bound_args = signature.bind(*validated_args, **validated_kwargs)
bound_args.apply_defaults()
# Actually call the function with validated parameters
return fn(*bound_args.args, **bound_args.kwargs)

View file

@ -128,7 +128,7 @@ class HtmlAttrsTests(BaseTestCase):
template = Template(self.template_str) template = Template(self.template_str)
with self.assertRaisesMessage( with self.assertRaisesMessage(
TypeError, "Invalid parameters for tag 'html_attrs': too many positional arguments" TypeError, "Invalid parameters for tag 'html_attrs': takes 2 positional argument(s) but more were given"
): ):
template.render(Context({"class_var": "padding-top-8"})) template.render(Context({"class_var": "padding-top-8"}))
@ -269,7 +269,7 @@ class HtmlAttrsTests(BaseTestCase):
template = Template(self.template_str) template = Template(self.template_str)
with self.assertRaisesMessage( with self.assertRaisesMessage(
TypeError, "Invalid parameters for tag 'html_attrs': multiple values for argument 'attrs'" TypeError, "Invalid parameters for tag 'html_attrs': got multiple values for argument 'attrs'"
): ):
template.render(Context({"class_var": "padding-top-8"})) template.render(Context({"class_var": "padding-top-8"}))

View file

@ -737,7 +737,7 @@ class SpreadOperatorTests(BaseTestCase):
template1 = Template(template_str1) template1 = Template(template_str1)
with self.assertRaisesMessage(SyntaxError, "keyword argument repeated"): with self.assertRaisesMessage(TypeError, "got multiple values for argument 'x'"):
template1.render(context) template1.render(context)
# But, similarly to python, we can merge multiple **kwargs by instead # But, similarly to python, we can merge multiple **kwargs by instead

View file

@ -225,7 +225,7 @@ class NodeTests(BaseTestCase):
""" """
) )
with self.assertRaisesMessage( with self.assertRaisesMessage(
TypeError, "Invalid parameters for tag 'mytag': multiple values for argument 'name'" TypeError, "Invalid parameters for tag 'mytag': got multiple values for argument 'name'"
): ):
template6.render(Context({})) template6.render(Context({}))
@ -236,7 +236,7 @@ class NodeTests(BaseTestCase):
{% mytag count=1 name='John' 123 %} {% mytag count=1 name='John' 123 %}
""" """
) )
with self.assertRaisesMessage(SyntaxError, "positional argument follows keyword argument"): with self.assertRaisesMessage(TypeError, "positional argument follows keyword argument"):
template6.render(Context({})) template6.render(Context({}))
# Extra kwargs # Extra kwargs
@ -311,6 +311,62 @@ class NodeTests(BaseTestCase):
TestNode1.unregister(component_tags.register) TestNode1.unregister(component_tags.register)
def test_node_render_kwargs_only(self):
captured = None
class TestNode(BaseNode):
tag = "mytag"
def render(self, context: Context, **kwargs) -> str:
nonlocal captured
captured = kwargs
return ""
TestNode.register(component_tags.register)
# Test with various kwargs including non-identifier keys
template = Template(
"""
{% load component_tags %}
{% mytag
name='John'
age=25
data-id=123
class="header"
@click="handleClick"
v-if="isVisible"
%}
"""
)
template.render(Context({}))
# All kwargs should be accepted since the function accepts **kwargs
self.assertEqual(
captured,
{
"name": "John",
"age": 25,
"data-id": 123,
"class": "header",
"@click": "handleClick",
"v-if": "isVisible",
},
)
# Test with positional args (should fail since function only accepts kwargs)
template2 = Template(
"""
{% load component_tags %}
{% mytag "John" name="Mary" %}
"""
)
with self.assertRaisesMessage(
TypeError, "Invalid parameters for tag 'mytag': takes 0 positional arguments but 1 was given"
):
template2.render(Context({}))
TestNode.unregister(component_tags.register)
class DecoratorTests(BaseTestCase): class DecoratorTests(BaseTestCase):
def test_decorator_requires_tag(self): def test_decorator_requires_tag(self):
@ -488,7 +544,7 @@ class DecoratorTests(BaseTestCase):
""" """
) )
with self.assertRaisesMessage( with self.assertRaisesMessage(
TypeError, "Invalid parameters for tag 'mytag': multiple values for argument 'name'" TypeError, "Invalid parameters for tag 'mytag': got multiple values for argument 'name'"
): ):
template6.render(Context({})) template6.render(Context({}))
@ -499,7 +555,7 @@ class DecoratorTests(BaseTestCase):
{% mytag count=1 name='John' 123 %} {% mytag count=1 name='John' 123 %}
""" """
) )
with self.assertRaisesMessage(SyntaxError, "positional argument follows keyword argument"): with self.assertRaisesMessage(TypeError, "positional argument follows keyword argument"):
template6.render(Context({})) template6.render(Context({}))
# Extra kwargs # Extra kwargs
@ -568,3 +624,55 @@ class DecoratorTests(BaseTestCase):
) )
render._node.unregister(component_tags.register) # type: ignore[attr-defined] render._node.unregister(component_tags.register) # type: ignore[attr-defined]
def test_decorator_render_kwargs_only(self):
captured = None
@template_tag(component_tags.register, tag="mytag") # type: ignore
def render(node: BaseNode, context: Context, **kwargs) -> str:
nonlocal captured
captured = kwargs
return ""
# Test with various kwargs including non-identifier keys
template = Template(
"""
{% load component_tags %}
{% mytag
name='John'
age=25
data-id=123
class="header"
@click="handleClick"
v-if="isVisible"
%}
"""
)
template.render(Context({}))
# All kwargs should be accepted since the function accepts **kwargs
self.assertEqual(
captured,
{
"name": "John",
"age": 25,
"data-id": 123,
"class": "header",
"@click": "handleClick",
"v-if": "isVisible",
},
)
# Test with positional args (should fail since function only accepts kwargs)
template2 = Template(
"""
{% load component_tags %}
{% mytag "John" name="Mary" %}
"""
)
with self.assertRaisesMessage(
TypeError, "Invalid parameters for tag 'mytag': takes 0 positional arguments but 1 was given"
):
template2.render(Context({}))
render._node.unregister(component_tags.register) # type: ignore[attr-defined]