django-components/src/django_components/expression.py
Juro Oravec 7ed4fd88f9
refactor: Patch Template.compile_nodelist with custom template parser (#908)
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-01-15 22:34:32 +01:00

258 lines
9.2 KiB
Python

import re
from typing import TYPE_CHECKING, Any, Dict, List
from django.template import Context, Node, NodeList, TemplateSyntaxError
from django.template.base import Parser, VariableNode
from django_components.util.template_parser import parse_template
if TYPE_CHECKING:
from django_components.util.template_tag import TagParam
class DynamicFilterExpression:
"""
To make working with Django templates easier, we allow to use (nested) template tags `{% %}`
inside of strings that are passed to our template tags, e.g.:
```django
{% component "my_comp" value_from_tag="{% gen_dict %}" %}
```
We call this the "dynamic" or "nested" expression.
A string is marked as a dynamic expression only if it contains any one
of `{{ }}`, `{% %}`, or `{# #}`.
If the expression consists of a single tag, with no extra text, we return the tag's
value directly. E.g.:
```django
{% component "my_comp" value_from_tag="{% gen_dict %}" %}
```
will pass a dictionary to the component input `value_from_tag`.
But if the text already contains spaces or more tags, e.g.
`{% component "my_comp" value_from_tag=" {% gen_dict %} " %}`
Then we treat it as a regular template and pass it as string.
"""
def __init__(self, parser: Parser, expr_str: str) -> None:
if not is_dynamic_expression(expr_str):
raise TemplateSyntaxError(f"Not a valid dynamic expression: '{expr_str}'")
# Drop the leading and trailing quote
self.expr = expr_str[1:-1]
# Copy the Parser, and pass through the tags and filters available
# in the current context. Thus, if user calls `{% load %}` inside
# the expression, it won't spill outside.
tokens = parse_template(self.expr)
expr_parser = Parser(tokens=tokens)
expr_parser.tags = {**parser.tags}
expr_parser.filters = {**parser.filters}
self.nodelist = expr_parser.parse()
def resolve(self, context: Context) -> Any:
# If the expression consists of a single node, we return the node's value
# directly, skipping stringification that would occur by rendering the node
# via nodelist.
#
# This make is possible to pass values from the nested tag expressions
# and use them as component inputs.
# E.g. below, the value of `value_from_tag` kwarg would be a dictionary,
# not a string.
#
# `{% component "my_comp" value_from_tag="{% gen_dict %}" %}`
#
# But if it already container spaces, e.g.
#
# `{% component "my_comp" value_from_tag=" {% gen_dict %} " %}`
#
# Then we'd treat it as a regular template and pass it as string.
if len(self.nodelist) == 1:
node = self.nodelist[0]
# Handle `{{ }}` tags, where we need to access the expression directly
# to avoid it being stringified
if isinstance(node, VariableNode):
return node.filter_expression.resolve(context)
else:
# For any other tags `{% %}`, we're at a mercy of the authors, and
# we don't know if the result comes out stringified or not.
return node.render(context)
else:
# Lastly, if there's multiple nodes, we render it to a string
#
# NOTE: When rendering a NodeList, it expects that each node is a string.
# However, we want to support tags that return non-string results, so we can pass
# them as inputs to components. So we wrap the nodes in `StringifiedNode`
nodelist = NodeList(StringifiedNode(node) for node in self.nodelist)
return nodelist.render(context)
class StringifiedNode(Node):
def __init__(self, wrapped_node: Node) -> None:
super().__init__()
self.wrapped_node = wrapped_node
def render(self, context: Context) -> str:
result = self.wrapped_node.render(context)
return str(result)
def is_aggregate_key(key: str) -> bool:
# NOTE: If we get a key that starts with `:`, like `:class`, we do not split it.
# This syntax is used by Vue and AlpineJS.
return ":" in key and not key.startswith(":")
# A string that must start and end with quotes, and somewhere inside includes
# at least one tag. Tag may be variable (`{{ }}`), block (`{% %}`), or comment (`{# #}`).
DYNAMIC_EXPR_RE = re.compile(
r"^{start_quote}.*?(?:{var_tag}|{block_tag}|{comment_tag}).*?{end_quote}$".format(
var_tag=r"(?:\{\{.*?\}\})",
block_tag=r"(?:\{%.*?%\})",
comment_tag=r"(?:\{#.*?#\})",
start_quote=r"(?P<quote>['\"])", # NOTE: Capture group so we check for the same quote at the end
end_quote=r"(?P=quote)",
)
)
def is_dynamic_expression(value: Any) -> bool:
# NOTE: Currently dynamic expression need at least 6 characters
# for the opening and closing tags, and quotes, e.g. `"`, `{%`, `%}` in `" some text {% ... %}"`
MIN_EXPR_LEN = 6
if not isinstance(value, str) or not value or len(value) < MIN_EXPR_LEN:
return False
# Is not wrapped in quotes, or does not contain any tags
if not DYNAMIC_EXPR_RE.match(value):
return False
return True
# TODO - Move this out into a plugin?
def process_aggregate_kwargs(params: List["TagParam"]) -> List["TagParam"]:
"""
This function aggregates "prefixed" kwargs into dicts. "Prefixed" kwargs
start with some prefix delimited with `:` (e.g. `attrs:`).
Example:
```py
process_component_kwargs({"abc:one": 1, "abc:two": 2, "def:three": 3, "four": 4})
# {"abc": {"one": 1, "two": 2}, "def": {"three": 3}, "four": 4}
```
---
We want to support a use case similar to Vue's fallthrough attributes.
In other words, where a component author can designate a prop (input)
which is a dict and which will be rendered as HTML attributes.
This is useful for allowing component users to tweak styling or add
event handling to the underlying HTML. E.g.:
`class="pa-4 d-flex text-black"` or `@click.stop="alert('clicked!')"`
So if the prop is `attrs`, and the component is called like so:
```django
{% component "my_comp" attrs=attrs %}
```
then, if `attrs` is:
```py
{"class": "text-red pa-4", "@click": "dispatch('my_event', 123)"}
```
and the component template is:
```django
<div {% html_attrs attrs add:class="extra-class" %}></div>
```
Then this renders:
```html
<div class="text-red pa-4 extra-class" @click="dispatch('my_event', 123)" ></div>
```
However, this way it is difficult for the component user to define the `attrs`
variable, especially if they want to combine static and dynamic values. Because
they will need to pre-process the `attrs` dict.
So, instead, we allow to "aggregate" props into a dict. So all props that start
with `attrs:`, like `attrs:class="text-red"`, will be collected into a dict
at key `attrs`.
This provides sufficient flexiblity to make it easy for component users to provide
"fallthrough attributes", and sufficiently easy for component authors to process
that input while still being able to provide their own keys.
"""
from django_components.util.template_tag import TagParam
_check_kwargs_for_agg_conflict(params)
processed_params = []
seen_keys = set()
nested_kwargs: Dict[str, Dict[str, Any]] = {}
for param in params:
# Positional args
if param.key is None:
processed_params.append(param)
continue
# Regular kwargs without `:` prefix
if not is_aggregate_key(param.key):
outer_key = param.key
inner_key = None
seen_keys.add(outer_key)
processed_params.append(param)
continue
# NOTE: Trim off the outer_key from keys
outer_key, inner_key = param.key.split(":", 1)
if outer_key not in nested_kwargs:
nested_kwargs[outer_key] = {}
nested_kwargs[outer_key][inner_key] = param.value
# Assign aggregated values into normal input
for key, val in nested_kwargs.items():
if key in seen_keys:
raise TemplateSyntaxError(
f"Received argument '{key}' both as a regular input ({key}=...)"
f" and as an aggregate dict ('{key}:key=...'). Must be only one of the two"
)
processed_params.append(TagParam(key=key, value=val))
return processed_params
def _check_kwargs_for_agg_conflict(params: List["TagParam"]) -> None:
seen_regular_kwargs = set()
seen_agg_kwargs = set()
for param in params:
# Ignore positional args
if param.key is None:
continue
is_agg_kwarg = is_aggregate_key(param.key)
if (
(is_agg_kwarg and (param.key in seen_regular_kwargs))
or (not is_agg_kwarg and (param.key in seen_agg_kwargs))
): # fmt: skip
raise TemplateSyntaxError(
f"Received argument '{param.key}' both as a regular input ({param.key}=...)"
f" and as an aggregate dict ('{param.key}:key=...'). Must be only one of the two"
)
if is_agg_kwarg:
seen_agg_kwargs.add(param.key)
else:
seen_regular_kwargs.add(param.key)