feat: Literal dicts and lists part 2 (#902)

This commit is contained in:
Juro Oravec 2025-01-14 09:01:47 +01:00 committed by GitHub
parent d3c5c535e0
commit 8cd4b03286
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 1329 additions and 979 deletions

View file

@ -593,30 +593,19 @@ def _format_tag_signature(tag_spec: TagSpec) -> str:
{% endcomponent %} {% endcomponent %}
``` ```
""" """
params: List[str] = [tag_spec.tag] # The signature returns a string like:
# `(arg: Any, **kwargs: Any) -> None`
if tag_spec.positional_only_args: params_str = str(tag_spec.signature)
params.extend([*tag_spec.positional_only_args, "/"]) # Remove the return type annotation, the `-> None` part
params_str = params_str.rsplit("->", 1)[0]
optional_kwargs = set(tag_spec.optional_kwargs or []) # Remove brackets around the params, to end up only with `arg: Any, **kwargs: Any`
params_str = params_str.strip()[1:-1]
params.extend([f"{name}=None" if name in optional_kwargs else name for name in tag_spec.pos_or_keyword_args or []])
if tag_spec.positional_args_allow_extra:
params.append("[arg, ...]")
if tag_spec.keywordonly_args is True:
params.append("**kwargs")
elif tag_spec.keywordonly_args:
params.extend(
[f"{name}=None" if name in optional_kwargs else name for name in (tag_spec.keywordonly_args or [])]
)
if tag_spec.flags: if tag_spec.flags:
params.extend([f"[{name}]" for name in tag_spec.flags]) params_str += " " + " ".join([f"[{name}]" for name in tag_spec.flags])
# Create the function signature # Create the function signature
full_tag = f"{{% {' '.join(params)} %}}" full_tag = "{% " + tag_spec.tag + " " + params_str + " %}"
if tag_spec.end_tag: if tag_spec.end_tag:
full_tag += f"\n{{% {tag_spec.end_tag} %}}" full_tag += f"\n{{% {tag_spec.end_tag} %}}"

View file

@ -8,8 +8,8 @@ from django.template import Context
from django.utils.html import conditional_escape, format_html from django.utils.html import conditional_escape, format_html
from django.utils.safestring import SafeString, mark_safe from django.utils.safestring import SafeString, mark_safe
from django_components.expression import RuntimeKwargPairs, RuntimeKwargs
from django_components.node import BaseNode from django_components.node import BaseNode
from django_components.util.template_tag import TagParams
HTML_ATTRS_DEFAULTS_KEY = "defaults" HTML_ATTRS_DEFAULTS_KEY = "defaults"
HTML_ATTRS_ATTRS_KEY = "attrs" HTML_ATTRS_ATTRS_KEY = "attrs"
@ -18,32 +18,19 @@ HTML_ATTRS_ATTRS_KEY = "attrs"
class HtmlAttrsNode(BaseNode): class HtmlAttrsNode(BaseNode):
def __init__( def __init__(
self, self,
kwargs: RuntimeKwargs, params: TagParams,
kwarg_pairs: RuntimeKwargPairs,
node_id: Optional[str] = None, node_id: Optional[str] = None,
): ):
super().__init__(nodelist=None, args=None, kwargs=kwargs, node_id=node_id) super().__init__(nodelist=None, params=params, node_id=node_id)
self.kwarg_pairs = kwarg_pairs
def render(self, context: Context) -> str: def render(self, context: Context) -> str:
append_attrs: List[Tuple[str, Any]] = [] append_attrs: List[Tuple[str, Any]] = []
# Resolve all data # Resolve all data
kwargs = self.kwargs.resolve(context) args, kwargs = self.params.resolve(context)
attrs = kwargs.pop(HTML_ATTRS_ATTRS_KEY, None) or {} attrs = kwargs.pop(HTML_ATTRS_ATTRS_KEY, None) or {}
defaults = kwargs.pop(HTML_ATTRS_DEFAULTS_KEY, None) or {} defaults = kwargs.pop(HTML_ATTRS_DEFAULTS_KEY, None) or {}
append_attrs = list(kwargs.items())
kwarg_pairs = self.kwarg_pairs.resolve(context)
for key, value in kwarg_pairs:
if (
key in [HTML_ATTRS_ATTRS_KEY, HTML_ATTRS_DEFAULTS_KEY]
or key.startswith(f"{HTML_ATTRS_ATTRS_KEY}:")
or key.startswith(f"{HTML_ATTRS_DEFAULTS_KEY}:")
):
continue
append_attrs.append((key, value))
# Merge it # Merge it
final_attrs = {**defaults, **attrs} final_attrs = {**defaults, **attrs}

View file

@ -11,7 +11,6 @@ from typing import (
Dict, Dict,
Generator, Generator,
Generic, Generic,
List,
Literal, Literal,
Mapping, Mapping,
NamedTuple, NamedTuple,
@ -54,7 +53,6 @@ from django_components.dependencies import (
cache_component_js_vars, cache_component_js_vars,
postprocess_component_html, postprocess_component_html,
) )
from django_components.expression import Expression, RuntimeKwargs, safe_resolve_list
from django_components.node import BaseNode from django_components.node import BaseNode
from django_components.slots import ( from django_components.slots import (
ComponentSlotContext, ComponentSlotContext,
@ -72,6 +70,7 @@ from django_components.slots import (
from django_components.template import cached_template from django_components.template import cached_template
from django_components.util.logger import trace_msg from django_components.util.logger import trace_msg
from django_components.util.misc import gen_id from django_components.util.misc import gen_id
from django_components.util.template_tag import TagParams
from django_components.util.validation import validate_typed_dict, validate_typed_tuple from django_components.util.validation import validate_typed_dict, validate_typed_tuple
# TODO_REMOVE_IN_V1 - Users should use top-level import instead # TODO_REMOVE_IN_V1 - Users should use top-level import instead
@ -1214,14 +1213,13 @@ class ComponentNode(BaseNode):
def __init__( def __init__(
self, self,
name: str, name: str,
args: List[Expression],
kwargs: RuntimeKwargs,
registry: ComponentRegistry, # noqa F811 registry: ComponentRegistry, # noqa F811
nodelist: NodeList,
params: TagParams,
isolated_context: bool = False, isolated_context: bool = False,
nodelist: Optional[NodeList] = None,
node_id: Optional[str] = None, node_id: Optional[str] = None,
) -> None: ) -> None:
super().__init__(nodelist=nodelist or NodeList(), args=args, kwargs=kwargs, node_id=node_id) super().__init__(nodelist=nodelist or NodeList(), params=params, node_id=node_id)
self.name = name self.name = name
self.isolated_context = isolated_context self.isolated_context = isolated_context
@ -1246,8 +1244,7 @@ class ComponentNode(BaseNode):
# Resolve FilterExpressions and Variables that were passed as args to the # Resolve FilterExpressions and Variables that were passed as args to the
# component, then call component's context method # component, then call component's context method
# to get values to insert into the context # to get values to insert into the context
args = safe_resolve_list(context, self.args) args, kwargs = self.params.resolve(context)
kwargs = self.kwargs.resolve(context)
slot_fills = resolve_fills(context, self.nodelist, self.name) slot_fills = resolve_fills(context, self.nodelist, self.name)

View file

@ -1,42 +1,43 @@
import re import re
from abc import ABC, abstractmethod from typing import TYPE_CHECKING, Any, Dict, List
from typing import Any, Dict, List, Mapping, Optional, Tuple, Union
from django.template import Context, Node, NodeList, TemplateSyntaxError from django.template import Context, Node, NodeList, TemplateSyntaxError
from django.template.base import FilterExpression, Lexer, Parser, VariableNode from django.template.base import Lexer, Parser, VariableNode
Expression = Union[FilterExpression, "DynamicFilterExpression"] if TYPE_CHECKING:
RuntimeKwargsInput = Dict[str, Union[Expression, "Operator"]] from django_components.util.template_tag import TagParam
RuntimeKwargPairsInput = List[Tuple[str, Union[Expression, "Operator"]]]
class Operator(ABC):
"""
Operator describes something that somehow changes the inputs
to template tags (the `{% %}`).
For example, a SpreadOperator inserts one or more kwargs at the
specified location.
"""
@abstractmethod
def resolve(self, context: Context) -> Any: ... # noqa E704
class SpreadOperator(Operator):
"""Operator that inserts one or more kwargs at the specified location."""
def __init__(self, expr: Expression) -> None:
self.expr = expr
def resolve(self, context: Context) -> Dict[str, Any]:
data = self.expr.resolve(context)
if not isinstance(data, dict):
raise RuntimeError(f"Spread operator expression must resolve to a Dict, got {data}")
return data
class DynamicFilterExpression: 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: def __init__(self, parser: Parser, expr_str: str) -> None:
if not is_dynamic_expression(expr_str): if not is_dynamic_expression(expr_str):
raise TemplateSyntaxError(f"Not a valid dynamic expression: '{expr_str}'") raise TemplateSyntaxError(f"Not a valid dynamic expression: '{expr_str}'")
@ -103,72 +104,6 @@ class StringifiedNode(Node):
return str(result) return str(result)
class RuntimeKwargs:
def __init__(self, kwargs: RuntimeKwargsInput) -> None:
self.kwargs = kwargs
def resolve(self, context: Context) -> Dict[str, Any]:
resolved_kwargs = safe_resolve_dict(context, self.kwargs)
return process_aggregate_kwargs(resolved_kwargs)
class RuntimeKwargPairs:
def __init__(self, kwarg_pairs: RuntimeKwargPairsInput) -> None:
self.kwarg_pairs = kwarg_pairs
def resolve(self, context: Context) -> List[Tuple[str, Any]]:
resolved_kwarg_pairs: List[Tuple[str, Any]] = []
for key, kwarg in self.kwarg_pairs:
if isinstance(kwarg, SpreadOperator):
spread_kwargs = kwarg.resolve(context)
for spread_key, spread_value in spread_kwargs.items():
resolved_kwarg_pairs.append((spread_key, spread_value))
else:
resolved_kwarg_pairs.append((key, kwarg.resolve(context)))
return resolved_kwarg_pairs
def is_identifier(value: Any) -> bool:
if not isinstance(value, str):
return False
if not value.isidentifier():
return False
return True
def safe_resolve_list(context: Context, args: List[Expression]) -> List:
return [arg.resolve(context) for arg in args]
def safe_resolve_dict(
context: Context,
kwargs: Dict[str, Union[Expression, "Operator"]],
) -> Dict[str, Any]:
result = {}
for key, kwarg in kwargs.items():
# If we've come across a Spread Operator (...), we insert the kwargs from it here
if isinstance(kwarg, SpreadOperator):
spread_dict = kwarg.resolve(context)
if spread_dict is not None:
for spreadkey, spreadkwarg in spread_dict.items():
result[spreadkey] = spreadkwarg
else:
result[key] = kwarg.resolve(context)
return result
def resolve_string(
s: str,
parser: Optional[Parser] = None,
context: Optional[Mapping[str, Any]] = None,
) -> str:
parser = parser or Parser([])
context = Context(context or {})
return parser.compile_filter(s).resolve(context)
def is_aggregate_key(key: str) -> bool: def is_aggregate_key(key: str) -> bool:
# NOTE: If we get a key that starts with `:`, like `:class`, we do not split it. # NOTE: If we get a key that starts with `:`, like `:class`, we do not split it.
# This syntax is used by Vue and AlpineJS. # This syntax is used by Vue and AlpineJS.
@ -203,14 +138,8 @@ def is_dynamic_expression(value: Any) -> bool:
return True return True
def is_spread_operator(value: Any) -> bool: # TODO - Move this out into a plugin?
if not isinstance(value, str) or not value: def process_aggregate_kwargs(params: List["TagParam"]) -> List["TagParam"]:
return False
return value.startswith("...")
def process_aggregate_kwargs(kwargs: Mapping[str, Any]) -> Dict[str, Any]:
""" """
This function aggregates "prefixed" kwargs into dicts. "Prefixed" kwargs This function aggregates "prefixed" kwargs into dicts. "Prefixed" kwargs
start with some prefix delimited with `:` (e.g. `attrs:`). start with some prefix delimited with `:` (e.g. `attrs:`).
@ -264,26 +193,65 @@ def process_aggregate_kwargs(kwargs: Mapping[str, Any]) -> Dict[str, Any]:
"fallthrough attributes", and sufficiently easy for component authors to process "fallthrough attributes", and sufficiently easy for component authors to process
that input while still being able to provide their own keys. that input while still being able to provide their own keys.
""" """
processed_kwargs = {} 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]] = {} nested_kwargs: Dict[str, Dict[str, Any]] = {}
for key, val in kwargs.items(): for param in params:
if not is_aggregate_key(key): # Positional args
processed_kwargs[key] = val if param.key is None:
processed_params.append(param)
continue continue
# NOTE: Trim off the prefix from keys # Regular kwargs without `:` prefix
prefix, sub_key = key.split(":", 1) if not is_aggregate_key(param.key):
if prefix not in nested_kwargs: outer_key = param.key
nested_kwargs[prefix] = {} inner_key = None
nested_kwargs[prefix][sub_key] = val 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 # Assign aggregated values into normal input
for key, val in nested_kwargs.items(): for key, val in nested_kwargs.items():
if key in processed_kwargs: if key in seen_keys:
raise TemplateSyntaxError( raise TemplateSyntaxError(
f"Received argument '{key}' both as a regular input ({key}=...)" 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" f" and as an aggregate dict ('{key}:key=...'). Must be only one of the two"
) )
processed_kwargs[key] = val processed_params.append(TagParam(key=key, value=val))
return processed_kwargs 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)

View file

@ -1,9 +1,9 @@
from typing import List, Optional from typing import Optional
from django.template.base import Node, NodeList from django.template.base import Node, NodeList
from django_components.expression import Expression, RuntimeKwargs
from django_components.util.misc import gen_id from django_components.util.misc import gen_id
from django_components.util.template_tag import TagParams
class BaseNode(Node): class BaseNode(Node):
@ -11,12 +11,10 @@ class BaseNode(Node):
def __init__( def __init__(
self, self,
params: TagParams,
nodelist: Optional[NodeList] = None, nodelist: Optional[NodeList] = None,
node_id: Optional[str] = None, node_id: Optional[str] = None,
args: Optional[List[Expression]] = None,
kwargs: Optional[RuntimeKwargs] = None,
): ):
self.params = params
self.nodelist = nodelist or NodeList() self.nodelist = nodelist or NodeList()
self.node_id = node_id or gen_id() self.node_id = node_id or gen_id()
self.args = args or []
self.kwargs = kwargs or RuntimeKwargs({})

View file

@ -5,9 +5,9 @@ from django.template.base import NodeList
from django.utils.safestring import SafeString from django.utils.safestring import SafeString
from django_components.context import set_provided_context_var from django_components.context import set_provided_context_var
from django_components.expression import RuntimeKwargs
from django_components.node import BaseNode from django_components.node import BaseNode
from django_components.util.logger import trace_msg from django_components.util.logger import trace_msg
from django_components.util.template_tag import TagParams
PROVIDE_NAME_KWARG = "name" PROVIDE_NAME_KWARG = "name"
@ -21,11 +21,11 @@ class ProvideNode(BaseNode):
def __init__( def __init__(
self, self,
nodelist: NodeList, nodelist: NodeList,
params: TagParams,
trace_id: str, trace_id: str,
node_id: Optional[str] = None, node_id: Optional[str] = None,
kwargs: Optional[RuntimeKwargs] = None,
): ):
super().__init__(nodelist=nodelist, args=None, kwargs=kwargs, node_id=node_id) super().__init__(nodelist=nodelist, params=params, node_id=node_id)
self.trace_id = trace_id self.trace_id = trace_id
@ -50,7 +50,7 @@ class ProvideNode(BaseNode):
return output return output
def resolve_kwargs(self, context: Context) -> Tuple[str, Dict[str, Optional[str]]]: def resolve_kwargs(self, context: Context) -> Tuple[str, Dict[str, Optional[str]]]:
kwargs = self.kwargs.resolve(context) args, kwargs = self.params.resolve(context)
name = kwargs.pop(PROVIDE_NAME_KWARG, None) name = kwargs.pop(PROVIDE_NAME_KWARG, None)
if not name: if not name:

View file

@ -32,10 +32,10 @@ from django_components.context import (
_REGISTRY_CONTEXT_KEY, _REGISTRY_CONTEXT_KEY,
_ROOT_CTX_CONTEXT_KEY, _ROOT_CTX_CONTEXT_KEY,
) )
from django_components.expression import RuntimeKwargs, is_identifier
from django_components.node import BaseNode from django_components.node import BaseNode
from django_components.util.logger import trace_msg from django_components.util.logger import trace_msg
from django_components.util.misc import get_last_index from django_components.util.misc import get_last_index, is_identifier
from django_components.util.template_tag import TagParams
if TYPE_CHECKING: if TYPE_CHECKING:
from django_components.component_registry import ComponentRegistry from django_components.component_registry import ComponentRegistry
@ -155,13 +155,13 @@ class SlotNode(BaseNode):
def __init__( def __init__(
self, self,
nodelist: NodeList, nodelist: NodeList,
params: TagParams,
trace_id: str, trace_id: str,
node_id: Optional[str] = None, node_id: Optional[str] = None,
kwargs: Optional[RuntimeKwargs] = None,
is_required: bool = False, is_required: bool = False,
is_default: bool = False, is_default: bool = False,
): ):
super().__init__(nodelist=nodelist, args=None, kwargs=kwargs, node_id=node_id) super().__init__(nodelist=nodelist, params=params, node_id=node_id)
self.is_required = is_required self.is_required = is_required
self.is_default = is_default self.is_default = is_default
@ -373,7 +373,7 @@ class SlotNode(BaseNode):
context: Context, context: Context,
component_name: Optional[str] = None, component_name: Optional[str] = None,
) -> Tuple[str, Dict[str, Optional[str]]]: ) -> Tuple[str, Dict[str, Optional[str]]]:
kwargs = self.kwargs.resolve(context) _, kwargs = self.params.resolve(context)
name = kwargs.pop(SLOT_NAME_KWARG, None) name = kwargs.pop(SLOT_NAME_KWARG, None)
if not name: if not name:
@ -388,11 +388,11 @@ class FillNode(BaseNode):
def __init__( def __init__(
self, self,
nodelist: NodeList, nodelist: NodeList,
kwargs: RuntimeKwargs, params: TagParams,
trace_id: str, trace_id: str,
node_id: Optional[str] = None, node_id: Optional[str] = None,
): ):
super().__init__(nodelist=nodelist, args=None, kwargs=kwargs, node_id=node_id) super().__init__(nodelist=nodelist, params=params, node_id=node_id)
self.trace_id = trace_id self.trace_id = trace_id
@ -410,7 +410,7 @@ class FillNode(BaseNode):
return f"<{self.__class__.__name__} ID: {self.node_id}. Contents: {repr(self.nodelist)}.>" return f"<{self.__class__.__name__} ID: {self.node_id}. Contents: {repr(self.nodelist)}.>"
def resolve_kwargs(self, context: Context) -> "FillWithData": def resolve_kwargs(self, context: Context) -> "FillWithData":
kwargs = self.kwargs.resolve(context) _, kwargs = self.params.resolve(context)
name = self._process_kwarg(kwargs, SLOT_NAME_KWARG, identifier=False) name = self._process_kwarg(kwargs, SLOT_NAME_KWARG, identifier=False)
default_var = self._process_kwarg(kwargs, SLOT_DEFAULT_KWARG) default_var = self._process_kwarg(kwargs, SLOT_DEFAULT_KWARG)
@ -452,6 +452,9 @@ class FillNode(BaseNode):
return None return None
value = kwargs[key] value = kwargs[key]
if value is None:
return None
if identifier and not is_identifier(value): if identifier and not is_identifier(value):
raise RuntimeError(f"Fill tag kwarg '{key}' does not resolve to a valid Python identifier, got '{value}'") raise RuntimeError(f"Fill tag kwarg '{key}' does not resolve to a valid Python identifier, got '{value}'")

View file

@ -5,7 +5,6 @@ from typing import TYPE_CHECKING, List, NamedTuple
from django.template import TemplateSyntaxError from django.template import TemplateSyntaxError
from django.utils.module_loading import import_string from django.utils.module_loading import import_string
from django_components.expression import resolve_string
from django_components.util.misc import is_str_wrapped_in_quotes from django_components.util.misc import is_str_wrapped_in_quotes
if TYPE_CHECKING: if TYPE_CHECKING:
@ -267,7 +266,7 @@ class ComponentFormatter(TagFormatterABC):
raise TemplateSyntaxError(f"Component name must be a string 'literal', got: {comp_name}") raise TemplateSyntaxError(f"Component name must be a string 'literal', got: {comp_name}")
# Remove the quotes # Remove the quotes
comp_name = resolve_string(comp_name) comp_name = comp_name[1:-1]
return TagResult(comp_name, final_args) return TagResult(comp_name, final_args)

View file

@ -11,27 +11,20 @@
# as the last argument, and will also set the `TagSpec` instance to `fn._tag_spec`. # as the last argument, and will also set the `TagSpec` instance to `fn._tag_spec`.
# During documentation generation, we access the `fn._tag_spec`. # During documentation generation, we access the `fn._tag_spec`.
from typing import Literal import inspect
from typing import Any, Dict, Literal, Optional
import django.template import django.template
from django.template.base import Parser, TextNode, Token from django.template.base import Parser, TextNode, Token
from django.template.exceptions import TemplateSyntaxError from django.template.exceptions import TemplateSyntaxError
from django.utils.safestring import SafeString, mark_safe from django.utils.safestring import SafeString, mark_safe
from django_components.attributes import HTML_ATTRS_ATTRS_KEY, HTML_ATTRS_DEFAULTS_KEY, HtmlAttrsNode from django_components.attributes import HtmlAttrsNode
from django_components.component import COMP_ONLY_FLAG, ComponentNode from django_components.component import COMP_ONLY_FLAG, ComponentNode
from django_components.component_registry import ComponentRegistry from django_components.component_registry import ComponentRegistry
from django_components.dependencies import CSS_DEPENDENCY_PLACEHOLDER, JS_DEPENDENCY_PLACEHOLDER from django_components.dependencies import CSS_DEPENDENCY_PLACEHOLDER, JS_DEPENDENCY_PLACEHOLDER
from django_components.provide import PROVIDE_NAME_KWARG, ProvideNode from django_components.provide import ProvideNode
from django_components.slots import ( from django_components.slots import SLOT_DEFAULT_KEYWORD, SLOT_REQUIRED_KEYWORD, FillNode, SlotNode
SLOT_DATA_KWARG,
SLOT_DEFAULT_KEYWORD,
SLOT_DEFAULT_KWARG,
SLOT_NAME_KWARG,
SLOT_REQUIRED_KEYWORD,
FillNode,
SlotNode,
)
from django_components.tag_formatter import get_tag_formatter from django_components.tag_formatter import get_tag_formatter
from django_components.util.logger import trace_msg from django_components.util.logger import trace_msg
from django_components.util.misc import gen_id from django_components.util.misc import gen_id
@ -56,11 +49,15 @@ def _component_dependencies(type: Literal["js", "css"]) -> SafeString:
return TextNode(mark_safe(placeholder)) return TextNode(mark_safe(placeholder))
def component_dependencies_signature() -> None: ... # noqa: E704
@register.tag("component_css_dependencies") @register.tag("component_css_dependencies")
@with_tag_spec( @with_tag_spec(
TagSpec( TagSpec(
tag="component_css_dependencies", tag="component_css_dependencies",
end_tag=None, # inline-only end_tag=None, # inline-only
signature=inspect.Signature.from_callable(component_dependencies_signature),
) )
) )
def component_css_dependencies(parser: Parser, token: Token, tag_spec: TagSpec) -> TextNode: def component_css_dependencies(parser: Parser, token: Token, tag_spec: TagSpec) -> TextNode:
@ -77,8 +74,7 @@ def component_css_dependencies(parser: Parser, token: Token, tag_spec: TagSpec)
If you insert this tag multiple times, ALL CSS links will be duplicately inserted into ALL these places. If you insert this tag multiple times, ALL CSS links will be duplicately inserted into ALL these places.
""" """
# Parse to check that the syntax is valid # Parse to check that the syntax is valid
tag_id = gen_id() parse_template_tag(parser, token, tag_spec)
parse_template_tag(parser, token, tag_spec, tag_id)
return _component_dependencies("css") return _component_dependencies("css")
@ -87,6 +83,7 @@ def component_css_dependencies(parser: Parser, token: Token, tag_spec: TagSpec)
TagSpec( TagSpec(
tag="component_js_dependencies", tag="component_js_dependencies",
end_tag=None, # inline-only end_tag=None, # inline-only
signature=inspect.Signature.from_callable(component_dependencies_signature),
) )
) )
def component_js_dependencies(parser: Parser, token: Token, tag_spec: TagSpec) -> TextNode: def component_js_dependencies(parser: Parser, token: Token, tag_spec: TagSpec) -> TextNode:
@ -103,20 +100,19 @@ def component_js_dependencies(parser: Parser, token: Token, tag_spec: TagSpec) -
If you insert this tag multiple times, ALL JS scripts will be duplicately inserted into ALL these places. If you insert this tag multiple times, ALL JS scripts will be duplicately inserted into ALL these places.
""" """
# Parse to check that the syntax is valid # Parse to check that the syntax is valid
tag_id = gen_id() parse_template_tag(parser, token, tag_spec)
parse_template_tag(parser, token, tag_spec, tag_id)
return _component_dependencies("js") return _component_dependencies("js")
def slot_signature(name: str, **kwargs: Any) -> None: ... # noqa: E704
@register.tag("slot") @register.tag("slot")
@with_tag_spec( @with_tag_spec(
TagSpec( TagSpec(
tag="slot", tag="slot",
end_tag="endslot", end_tag="endslot",
positional_only_args=[], signature=inspect.Signature.from_callable(slot_signature),
pos_or_keyword_args=[SLOT_NAME_KWARG],
keywordonly_args=True,
repeatable_kwargs=False,
flags=[SLOT_DEFAULT_KEYWORD, SLOT_REQUIRED_KEYWORD], flags=[SLOT_DEFAULT_KEYWORD, SLOT_REQUIRED_KEYWORD],
) )
) )
@ -244,37 +240,34 @@ def slot(parser: Parser, token: Token, tag_spec: TagSpec) -> SlotNode:
``` ```
""" """
tag_id = gen_id() tag_id = gen_id()
tag = parse_template_tag(parser, token, tag_spec, tag_id=tag_id) tag = parse_template_tag(parser, token, tag_spec)
slot_name_kwarg = tag.kwargs.kwargs.get(SLOT_NAME_KWARG, None) trace_id = f"slot-id-{tag_id}"
trace_id = f"slot-id-{tag.id} ({slot_name_kwarg})" if slot_name_kwarg else f"slot-id-{tag.id}" trace_msg("PARSE", "SLOT", trace_id, tag_id)
trace_msg("PARSE", "SLOT", trace_id, tag.id)
body = tag.parse_body() body = tag.parse_body()
slot_node = SlotNode( slot_node = SlotNode(
nodelist=body, nodelist=body,
node_id=tag.id, node_id=tag_id,
kwargs=tag.kwargs, params=tag.params,
is_required=tag.flags[SLOT_REQUIRED_KEYWORD], is_required=tag.flags[SLOT_REQUIRED_KEYWORD],
is_default=tag.flags[SLOT_DEFAULT_KEYWORD], is_default=tag.flags[SLOT_DEFAULT_KEYWORD],
trace_id=trace_id, trace_id=trace_id,
) )
trace_msg("PARSE", "SLOT", trace_id, tag.id, "...Done!") trace_msg("PARSE", "SLOT", trace_id, tag_id, "...Done!")
return slot_node return slot_node
def fill_signature(name: str, *, data: Optional[str] = None, default: Optional[str] = None) -> None: ... # noqa: E704
@register.tag("fill") @register.tag("fill")
@with_tag_spec( @with_tag_spec(
TagSpec( TagSpec(
tag="fill", tag="fill",
end_tag="endfill", end_tag="endfill",
positional_only_args=[], signature=inspect.Signature.from_callable(fill_signature),
pos_or_keyword_args=[SLOT_NAME_KWARG],
keywordonly_args=[SLOT_DATA_KWARG, SLOT_DEFAULT_KWARG],
optional_kwargs=[SLOT_DATA_KWARG, SLOT_DEFAULT_KWARG],
repeatable_kwargs=False,
) )
) )
def fill(parser: Parser, token: Token, tag_spec: TagSpec) -> FillNode: def fill(parser: Parser, token: Token, tag_spec: TagSpec) -> FillNode:
@ -366,33 +359,31 @@ def fill(parser: Parser, token: Token, tag_spec: TagSpec) -> FillNode:
``` ```
""" """
tag_id = gen_id() tag_id = gen_id()
tag = parse_template_tag(parser, token, tag_spec, tag_id=tag_id) tag = parse_template_tag(parser, token, tag_spec)
fill_name_kwarg = tag.kwargs.kwargs.get(SLOT_NAME_KWARG, None) trace_id = f"fill-id-{tag_id}"
trace_id = f"fill-id-{tag.id} ({fill_name_kwarg})" if fill_name_kwarg else f"fill-id-{tag.id}" trace_msg("PARSE", "FILL", trace_id, tag_id)
trace_msg("PARSE", "FILL", trace_id, tag.id)
body = tag.parse_body() body = tag.parse_body()
fill_node = FillNode( fill_node = FillNode(
nodelist=body, nodelist=body,
node_id=tag.id, node_id=tag_id,
kwargs=tag.kwargs, params=tag.params,
trace_id=trace_id, trace_id=trace_id,
) )
trace_msg("PARSE", "FILL", trace_id, tag.id, "...Done!") trace_msg("PARSE", "FILL", trace_id, tag_id, "...Done!")
return fill_node return fill_node
def component_signature(*args: Any, **kwargs: Any) -> None: ... # noqa: E704
@with_tag_spec( @with_tag_spec(
TagSpec( TagSpec(
tag="component", tag="component",
end_tag="endcomponent", end_tag="endcomponent",
positional_only_args=[], signature=inspect.Signature.from_callable(component_signature),
positional_args_allow_extra=True, # Allow many args
keywordonly_args=True,
repeatable_kwargs=False,
flags=[COMP_ONLY_FLAG], flags=[COMP_ONLY_FLAG],
) )
) )
@ -509,53 +500,44 @@ def component(
result = formatter.parse([*bits]) result = formatter.parse([*bits])
end_tag = formatter.end_tag(result.component_name) end_tag = formatter.end_tag(result.component_name)
# NOTE: The tokens returned from TagFormatter.parse do NOT include the tag itself # NOTE: The tokens returned from TagFormatter.parse do NOT include the tag itself,
# so we add it back in.
bits = [bits[0], *result.tokens] bits = [bits[0], *result.tokens]
token.contents = " ".join(bits) token.contents = " ".join(bits)
tag = parse_template_tag( # Set the component-specific start and end tags
parser, component_tag_spec = tag_spec.copy()
token, component_tag_spec.tag = tag_name
TagSpec( component_tag_spec.end_tag = end_tag
**{
**tag_spec._asdict(),
"tag": tag_name,
"end_tag": end_tag,
}
),
tag_id=tag_id,
)
# Check for isolated context keyword tag = parse_template_tag(parser, token, component_tag_spec)
isolated_context = tag.flags[COMP_ONLY_FLAG]
trace_msg("PARSE", "COMP", result.component_name, tag.id) trace_msg("PARSE", "COMP", result.component_name, tag_id)
body = tag.parse_body() body = tag.parse_body()
component_node = ComponentNode( component_node = ComponentNode(
name=result.component_name, name=result.component_name,
args=tag.args, params=tag.params,
kwargs=tag.kwargs, isolated_context=tag.flags[COMP_ONLY_FLAG],
isolated_context=isolated_context,
nodelist=body, nodelist=body,
node_id=tag.id, node_id=tag_id,
registry=registry, registry=registry,
) )
trace_msg("PARSE", "COMP", result.component_name, tag.id, "...Done!") trace_msg("PARSE", "COMP", result.component_name, tag_id, "...Done!")
return component_node return component_node
def provide_signature(name: str, **kwargs: Any) -> None: ... # noqa: E704
@register.tag("provide") @register.tag("provide")
@with_tag_spec( @with_tag_spec(
TagSpec( TagSpec(
tag="provide", tag="provide",
end_tag="endprovide", end_tag="endprovide",
positional_only_args=[], signature=inspect.Signature.from_callable(provide_signature),
pos_or_keyword_args=[PROVIDE_NAME_KWARG],
keywordonly_args=True,
repeatable_kwargs=False,
flags=[], flags=[],
) )
) )
@ -631,35 +613,34 @@ def provide(parser: Parser, token: Token, tag_spec: TagSpec) -> ProvideNode:
tag_id = gen_id() tag_id = gen_id()
# e.g. {% provide <name> key=val key2=val2 %} # e.g. {% provide <name> key=val key2=val2 %}
tag = parse_template_tag(parser, token, tag_spec, tag_id) tag = parse_template_tag(parser, token, tag_spec)
name_kwarg = tag.kwargs.kwargs.get(PROVIDE_NAME_KWARG, None) trace_id = f"fill-id-{tag_id}"
trace_id = f"provide-id-{tag.id} ({name_kwarg})" if name_kwarg else f"fill-id-{tag.id}" trace_msg("PARSE", "PROVIDE", trace_id, tag_id)
trace_msg("PARSE", "PROVIDE", trace_id, tag.id)
body = tag.parse_body() body = tag.parse_body()
provide_node = ProvideNode( provide_node = ProvideNode(
nodelist=body, nodelist=body,
node_id=tag.id, node_id=tag_id,
kwargs=tag.kwargs, params=tag.params,
trace_id=trace_id, trace_id=trace_id,
) )
trace_msg("PARSE", "PROVIDE", trace_id, tag.id, "...Done!") trace_msg("PARSE", "PROVIDE", trace_id, tag_id, "...Done!")
return provide_node return provide_node
def html_attrs_signature( # noqa: E704
attrs: Optional[Dict] = None, defaults: Optional[Dict] = None, **kwargs: Any
) -> None: ...
@register.tag("html_attrs") @register.tag("html_attrs")
@with_tag_spec( @with_tag_spec(
TagSpec( TagSpec(
tag="html_attrs", tag="html_attrs",
end_tag=None, # inline-only end_tag=None, # inline-only
positional_only_args=[], signature=inspect.Signature.from_callable(html_attrs_signature),
pos_or_keyword_args=[HTML_ATTRS_ATTRS_KEY, HTML_ATTRS_DEFAULTS_KEY],
optional_kwargs=[HTML_ATTRS_ATTRS_KEY, HTML_ATTRS_DEFAULTS_KEY],
keywordonly_args=True,
repeatable_kwargs=True,
flags=[], flags=[],
) )
) )
@ -716,11 +697,11 @@ def html_attrs(parser: Parser, token: Token, tag_spec: TagSpec) -> HtmlAttrsNode
[HTML attributes](../../concepts/fundamentals/html_attributes#examples-for-html_attrs).** [HTML attributes](../../concepts/fundamentals/html_attributes#examples-for-html_attrs).**
""" """
tag_id = gen_id() tag_id = gen_id()
tag = parse_template_tag(parser, token, tag_spec, tag_id) tag = parse_template_tag(parser, token, tag_spec)
return HtmlAttrsNode( return HtmlAttrsNode(
kwargs=tag.kwargs, node_id=tag_id,
kwarg_pairs=tag.kwarg_pairs, params=tag.params,
) )

View file

@ -31,6 +31,14 @@ def is_str_wrapped_in_quotes(s: str) -> bool:
return s.startswith(('"', "'")) and s[0] == s[-1] and len(s) >= 2 return s.startswith(('"', "'")) and s[0] == s[-1] and len(s) >= 2
def is_identifier(value: Any) -> bool:
if not isinstance(value, str):
return False
if not value.isidentifier():
return False
return True
def any_regex_match(string: str, patterns: List[re.Pattern]) -> bool: def any_regex_match(string: str, patterns: List[re.Pattern]) -> bool:
return any(p.search(string) is not None for p in patterns) return any(p.search(string) is not None for p in patterns)

View file

@ -37,10 +37,14 @@ See `parse_tag()` for details.
""" """
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Dict, List, Literal, NamedTuple, Optional, Sequence, Tuple, Union, cast from typing import Any, Dict, List, Literal, Optional, Sequence, Tuple, Union, cast
from django.template.base import FilterExpression, Parser
from django.template.context import Context
from django.template.exceptions import TemplateSyntaxError from django.template.exceptions import TemplateSyntaxError
from django_components.expression import DynamicFilterExpression, is_dynamic_expression
TAG_WHITESPACE = (" ", "\t", "\n", "\r", "\f") TAG_WHITESPACE = (" ", "\t", "\n", "\r", "\f")
TAG_FILTER = ("|", ":") TAG_FILTER = ("|", ":")
TAG_SPREAD = ("*", "**", "...") TAG_SPREAD = ("*", "**", "...")
@ -71,7 +75,8 @@ class TagAttr:
return s return s
class TagValue(NamedTuple): @dataclass
class TagValue:
""" """
A tag value represents the text to the right of the `=` in a tag attribute. A tag value represents the text to the right of the `=` in a tag attribute.
@ -84,6 +89,7 @@ class TagValue(NamedTuple):
""" """
parts: List["TagValuePart"] parts: List["TagValuePart"]
compiled: Optional[FilterExpression] = None
@property @property
def is_spread(self) -> bool: def is_spread(self) -> bool:
@ -94,6 +100,29 @@ class TagValue(NamedTuple):
def serialize(self) -> str: def serialize(self) -> str:
return "".join(part.serialize() for part in self.parts) return "".join(part.serialize() for part in self.parts)
def compile(self, parser: Optional[Parser]) -> None:
if self.compiled is not None:
return
serialized = self.serialize()
# Remove the spread token from the start of the serialized value
# E.g. `*val|filter:arg` -> `val|filter:arg`
if self.is_spread:
spread_token = self.parts[0].spread
spread_token_offset = len(spread_token) if spread_token else 0
serialized = serialized[spread_token_offset:]
# Allow to use dynamic expressions as args, e.g. `"{{ }}"` inside of strings
if is_dynamic_expression(serialized):
self.compiled = DynamicFilterExpression(parser, serialized)
else:
self.compiled = FilterExpression(serialized, parser)
def resolve(self, context: Context) -> Any:
if self.compiled is None:
raise TemplateSyntaxError("Malformed tag: TagValue.resolve() called before compile()")
return self.compiled.resolve(context)
@dataclass @dataclass
class TagValuePart: class TagValuePart:
@ -170,9 +199,12 @@ class TagValueStruct:
Types: Types:
- `root`: Plain tag value - `simple`: Plain tag value
- `list`: A list of tag values - `list`: A list of tag values
- `dict`: A dictionary of tag values - `dict`: A dictionary of tag values
TagValueStruct may be arbitrarily nested, creating JSON-like structures
that contains lists, dicts, and simple values.
""" """
type: Literal["list", "dict", "simple"] type: Literal["list", "dict", "simple"]
@ -182,11 +214,20 @@ class TagValueStruct:
The prefix used by a spread syntax, e.g. `...`, `*`, or `**`. If present, it means The prefix used by a spread syntax, e.g. `...`, `*`, or `**`. If present, it means
this values should be spread into the parent tag / list / dict. this values should be spread into the parent tag / list / dict.
""" """
# Container for parser-specific metadata
meta: Dict[str, Any] meta: Dict[str, Any]
# Parser is passed through so we can resolve variables with filters
parser: Optional[Parser]
compiled: bool = False
# Recursively walks down the value of TagAttr and serializes it to a string.
# This is effectively the inverse of `parse_tag()`.
def serialize(self) -> str: def serialize(self) -> str:
"""
Recursively walks down the value of potentially nested lists and dicts,
and serializes them all to a string.
This is effectively the inverse of `parse_tag()`.
"""
def render_value(value: Union[TagValue, TagValueStruct]) -> str: def render_value(value: Union[TagValue, TagValueStruct]) -> str:
if isinstance(value, TagValue): if isinstance(value, TagValue):
return value.serialize() return value.serialize()
@ -226,8 +267,99 @@ class TagValueStruct:
dict_pair = [] dict_pair = []
return prefix + "{" + ", ".join(dict_pairs) + "}" return prefix + "{" + ", ".join(dict_pairs) + "}"
# When we want to render the TagValueStruct, which may contain nested lists and dicts,
# we need to find all leaf nodes (the "simple" types) and compile them to FilterExpression.
#
# To make sure that the compilation needs to be done only once, the result
# each TagValueStruct contains a `compiled` flag to signal to its parent.
def compile(self) -> None:
if self.compiled:
return
def parse_tag(text: str) -> Tuple[str, List[TagAttr]]: def compile_value(value: Union[TagValue, TagValueStruct]) -> None:
if isinstance(value, TagValue):
value.compile(self.parser)
else:
value.compile()
if self.type == "simple":
value = self.entries[0]
compile_value(value)
elif self.type == "list":
for entry in self.entries:
compile_value(entry)
elif self.type == "dict":
# NOTE: Here we assume that the dict pairs have been validated by the parser and
# that the pairs line up.
for entry in self.entries:
compile_value(entry)
self.compiled = True
# Walk down the TagValueStructs and resolve the expressions.
#
# NOTE: This is where the TagValueStructs are converted to lists and dicts.
def resolve(self, context: Context) -> Any:
self.compile()
if self.type == "simple":
value = self.entries[0]
if not isinstance(value, TagValue):
raise TemplateSyntaxError("Malformed tag: simple value is not a TagValue")
return value.resolve(context)
elif self.type == "list":
resolved_list: List[Any] = []
for entry in self.entries:
resolved = entry.resolve(context)
# Case: Spreading a literal list: [ *[1, 2, 3] ]
if isinstance(entry, TagValueStruct) and entry.spread:
if not entry.type == "list":
raise TemplateSyntaxError("Malformed tag: cannot spread non-list value into a list")
resolved_list.extend(resolved)
# Case: Spreading a variable: [ *val ]
elif isinstance(entry, TagValue) and entry.is_spread:
resolved_list.extend(resolved)
# Case: Plain value: [ val ]
else:
resolved_list.append(resolved)
return resolved_list
elif self.type == "dict":
resolved_dict: Dict = {}
dict_pair: List = []
# NOTE: Here we assume that the dict pairs have been validated by the parser and
# that the pairs line up.
for entry in self.entries:
resolved = entry.resolve(context)
if isinstance(entry, TagValueStruct) and entry.spread:
if dict_pair:
raise TemplateSyntaxError(
"Malformed dict: spread operator cannot be used on the position of a dict value"
)
# Case: Spreading a literal dict: { **{"key": val2} }
resolved_dict.update(resolved)
elif isinstance(entry, TagValue) and entry.is_spread:
if dict_pair:
raise TemplateSyntaxError(
"Malformed dict: spread operator cannot be used on the position of a dict value"
)
# Case: Spreading a variable: { **val }
resolved_dict.update(resolved)
else:
# Case: Plain value: { key: val }
dict_pair.append(resolved)
if len(dict_pair) == 2:
dict_key = dict_pair[0]
dict_value = dict_pair[1]
resolved_dict[dict_key] = dict_value
dict_pair = []
return resolved_dict
def parse_tag(text: str, parser: Optional[Parser]) -> Tuple[str, List[TagAttr]]:
""" """
Parse the content of a Django template tag like this: Parse the content of a Django template tag like this:
@ -450,7 +582,7 @@ def parse_tag(text: str) -> Tuple[str, List[TagAttr]]:
# NOTE: We put a fake root item, so we can modify the list in place. # NOTE: We put a fake root item, so we can modify the list in place.
# At the end, we'll unwrap the list to get the actual value. # At the end, we'll unwrap the list to get the actual value.
total_value = TagValueStruct(type="simple", entries=[], spread=None, meta={}) total_value = TagValueStruct(type="simple", entries=[], spread=None, meta={}, parser=parser)
stack = [total_value] stack = [total_value]
while len(stack) > 0: while len(stack) > 0:
@ -466,7 +598,7 @@ def parse_tag(text: str) -> Tuple[str, List[TagAttr]]:
raise TemplateSyntaxError("Spread syntax '...' cannot follow a key ('key=...attrs')") raise TemplateSyntaxError("Spread syntax '...' cannot follow a key ('key=...attrs')")
# NOTE: The `...`, `**`, `*` are "taken" in `extract_spread_token()` # NOTE: The `...`, `**`, `*` are "taken" in `extract_spread_token()`
taken_n(1) # [ taken_n(1) # [
struct = TagValueStruct(type="list", entries=[], spread=spread_token, meta={}) struct = TagValueStruct(type="list", entries=[], spread=spread_token, meta={}, parser=parser)
curr_value.entries.append(struct) curr_value.entries.append(struct)
stack.append(struct) stack.append(struct)
continue continue
@ -499,7 +631,7 @@ def parse_tag(text: str) -> Tuple[str, List[TagAttr]]:
else: else:
raise TemplateSyntaxError("Dictionary cannot be used as a dictionary key") raise TemplateSyntaxError("Dictionary cannot be used as a dictionary key")
struct = TagValueStruct(type="dict", entries=[], spread=spread_token, meta={}) struct = TagValueStruct(type="dict", entries=[], spread=spread_token, meta={}, parser=parser)
curr_value.entries.append(struct) curr_value.entries.append(struct)
struct.meta["expects_key"] = True struct.meta["expects_key"] = True
stack.append(struct) stack.append(struct)
@ -625,10 +757,15 @@ def parse_tag(text: str) -> Tuple[str, List[TagAttr]]:
# Get past the filter tokens like `|` or `:`, until the next value part. # Get past the filter tokens like `|` or `:`, until the next value part.
# E.g. imagine: `height="20" | yesno : "1,2,3" | lower` # E.g. imagine: `height="20" | yesno : "1,2,3" | lower`
# and we're here: ^ # and we're here: ^
# (or here) ^
# (or here) ^
# and we want to parse `yesno` next # and we want to parse `yesno` next
if not is_first_part: if not is_first_part:
filter_token = taken_n(1) # | or : filter_token = taken_n(1) # | or :
take_while(TAG_WHITESPACE) # Allow whitespace after filter take_while(TAG_WHITESPACE) # Allow whitespace after filter
if filter_token == ":" and values_parts[-1].filter != "|":
raise TemplateSyntaxError("Filter argument (':arg') must follow a filter ('|filter')")
else: else:
filter_token = None filter_token = None
is_first_part = False is_first_part = False

View file

@ -1,46 +1,22 @@
import functools import functools
from typing import Any, Callable, Dict, List, Literal, NamedTuple, Optional, Set, Tuple, Union, cast import inspect
from dataclasses import dataclass
from typing import Any, Callable, Dict, Iterable, List, Mapping, NamedTuple, Optional, Set, Tuple, cast
from django.template import NodeList from django.template import Context, NodeList
from django.template.base import Parser, Token, TokenType from django.template.base import Parser, Token, TokenType
from django.template.exceptions import TemplateSyntaxError from django.template.exceptions import TemplateSyntaxError
from django_components.expression import ( from django_components.expression import process_aggregate_kwargs
DynamicFilterExpression,
Expression,
FilterExpression,
Operator,
RuntimeKwargPairs,
RuntimeKwargPairsInput,
RuntimeKwargs,
RuntimeKwargsInput,
SpreadOperator,
is_aggregate_key,
is_dynamic_expression,
)
from django_components.util.tag_parser import TagAttr, TagValue, parse_tag from django_components.util.tag_parser import TagAttr, TagValue, parse_tag
class ParsedTag(NamedTuple): @dataclass
id: str class TagSpec:
name: str
flags: Dict[str, bool]
args: List[Expression]
named_args: Dict[str, Expression]
kwargs: RuntimeKwargs
kwarg_pairs: RuntimeKwargPairs
is_inline: bool
parse_body: Callable[[], NodeList]
class TagArg(NamedTuple):
name: str
positional_only: bool
class TagSpec(NamedTuple):
"""Definition of args, kwargs, flags, etc, for a template tag.""" """Definition of args, kwargs, flags, etc, for a template tag."""
signature: inspect.Signature
"""Input to the tag as a Python function signature."""
tag: str tag: str
"""Tag name. E.g. `"slot"` means the tag is written like so `{% slot ... %}`""" """Tag name. E.g. `"slot"` means the tag is written like so `{% slot ... %}`"""
end_tag: Optional[str] = None end_tag: Optional[str] = None
@ -50,34 +26,6 @@ class TagSpec(NamedTuple):
E.g. `"endslot"` means anything between the start tag and `{% endslot %}` E.g. `"endslot"` means anything between the start tag and `{% endslot %}`
is considered the slot's body. is considered the slot's body.
""" """
positional_only_args: Optional[List[str]] = None
"""Arguments that MUST be given as positional args."""
positional_args_allow_extra: bool = False
"""
If `True`, allows variable number of positional args, e.g. `{% mytag val1 1234 val2 890 ... %}`
"""
pos_or_keyword_args: Optional[List[str]] = None
"""Like regular Python kwargs, these can be given EITHER as positional OR as keyword arguments."""
keywordonly_args: Optional[Union[bool, List[str]]] = False
"""
Parameters that MUST be given only as kwargs (not accounting for `pos_or_keyword_args`).
- If `False`, NO extra kwargs allowed.
- If `True`, ANY number of extra kwargs allowed.
- If a list of strings, e.g. `["class", "style"]`, then only those kwargs are allowed.
"""
optional_kwargs: Optional[List[str]] = None
"""Specify which kwargs can be optional."""
repeatable_kwargs: Optional[Union[bool, List[str]]] = False
"""
Whether this tag allows all or certain kwargs to be repeated.
- If `False`, NO kwargs can repeat.
- If `True`, ALL kwargs can repeat.
- If a list of strings, e.g. `["class", "style"]`, then only those kwargs can repeat.
E.g. `["class"]` means one can write `{% mytag class="one" class="two" %}`
"""
flags: Optional[List[str]] = None flags: Optional[List[str]] = None
""" """
List of allowed flags. List of allowed flags.
@ -87,9 +35,107 @@ class TagSpec(NamedTuple):
- and treated as `only=False` and `required=False` if omitted - and treated as `only=False` and `required=False` if omitted
""" """
def copy(self) -> "TagSpec":
sig_parameters_copy = [param.replace() for param in self.signature.parameters.values()]
signature = inspect.Signature(sig_parameters_copy)
flags = self.flags.copy() if self.flags else None
return self.__class__(
signature=signature,
tag=self.tag,
end_tag=self.end_tag,
flags=flags,
)
# For details see https://github.com/EmilStenstrom/django-components/pull/902
def validate_params(self, params: List["TagParam"]) -> Tuple[List[Any], Dict[str, Any]]:
"""
Validates a list of TagParam objects against this tag spec's function signature.
The validation preserves the order of parameters as they appeared in the template.
Args:
params: List of TagParam objects representing the parameters as they appeared
in the template tag.
Returns:
A tuple of (args, kwargs) containing the validated parameters.
Raises:
TypeError: If the parameters don't match the tag spec's rules.
"""
# Create a function with this signature that captures the input and sorts
# it into args and kwargs
def validator(*args: Any, **kwargs: Any) -> Tuple[List[Any], Dict[str, Any]]:
# Let Python do the signature validation
bound = self.signature.bind(*args, **kwargs)
bound.apply_defaults()
# Extract positional args
pos_args: List[Any] = []
for name, param in self.signature.parameters.items():
# Case: `name` (positional)
if param.kind == inspect.Parameter.POSITIONAL_ONLY:
pos_args.append(bound.arguments[name])
# Case: `*args`
elif param.kind == inspect.Parameter.VAR_POSITIONAL:
pos_args.extend(bound.arguments[name])
# Extract kwargs
kw_args: Dict[str, Any] = {}
for name, param in self.signature.parameters.items():
# Case: `name=...`
if param.kind in (inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY):
if name in bound.arguments:
kw_args[name] = bound.arguments[name]
# Case: `**kwargs`
elif param.kind == inspect.Parameter.VAR_KEYWORD:
kw_args.update(bound.arguments[name])
return pos_args, kw_args
# Set the signature on the function
validator.__signature__ = self.signature # type: ignore[attr-defined]
call_args = []
call_kwargs = []
for param in params:
if param.key is None:
call_args.append(param.value)
else:
call_kwargs.append({param.key: param.value})
# Call the validator with our args and kwargs, in such a way to
# let the Python interpreter validate on repeated kwargs.
#
# E.g. `args, kwargs = validator(*call_args, **call_kwargs[0], **call_kwargs[1])`
#
# NOTE: Although we use `exec()` here, it's safe, because we control the input -
# we pass in only the list index.
validator_call_script = "args, kwargs = validator(*call_args, "
for kw_index, _ in enumerate(call_kwargs):
validator_call_script += f"**call_kwargs[{kw_index}], "
validator_call_script += ")"
try:
# Create function namespace
namespace: Dict[str, Any] = {"validator": validator, "call_args": call_args, "call_kwargs": call_kwargs}
exec(validator_call_script, namespace)
new_args, new_kwargs = namespace["args"], namespace["kwargs"]
return new_args, new_kwargs
except TypeError as e:
# Enhance the error message
raise TypeError(f"Invalid parameters for tag '{self.tag}': {str(e)}") from None
def with_tag_spec(tag_spec: TagSpec) -> Callable: def with_tag_spec(tag_spec: TagSpec) -> Callable:
"""""" """
Decorator that binds a `tag_spec` to a template tag function,
there's a single source of truth for the tag spec, while also:
1. Making the tag spec available inside the tag function as `tag_spec`.
2. Making the tag spec accessible from outside as `_tag_spec` for documentation generation.
"""
def decorator(fn: Callable) -> Any: def decorator(fn: Callable) -> Any:
fn._tag_spec = tag_spec # type: ignore[attr-defined] fn._tag_spec = tag_spec # type: ignore[attr-defined]
@ -103,43 +149,89 @@ def with_tag_spec(tag_spec: TagSpec) -> Callable:
return decorator return decorator
@dataclass
class TagParam:
"""
TagParam is practically what `TagAttr` gets resolved to.
While TagAttr represents an item within template tag call, e.g.:
{% component key=value ... %}
TagParam represents an arg or kwarg that was resolved from TagAttr, and will
be passed to the tag function. E.g.:
component(key="value", ...)
"""
# E.g. `attrs:class` in `attrs:class="my-class"`
key: Optional[str]
# E.g. `"my-class"` in `attrs:class="my-class"`
value: Any
class TagParams(NamedTuple):
"""
TagParams holds the parsed tag attributes and the tag spec, so that, at render time,
when we are able to resolve the tag inputs with the given Context, we are also able to validate
the inputs against the tag spec.
This is done so that the tag's public API (as defined in the tag spec) can be defined
next to the tag implementation. Otherwise the input validation would have to be defined by
the internal `Node` classes.
"""
params: List[TagAttr]
tag_spec: TagSpec
def resolve(self, context: Context) -> Tuple[List[Any], Dict[str, Any]]:
# First, resolve any spread operators. Spreads can introduce both positional
# args (e.g. `*args`) and kwargs (e.g. `**kwargs`).
resolved_params: List[TagParam] = []
for param in self.params:
resolved = param.value.resolve(context)
if param.value.spread:
if param.key:
raise ValueError(f"Cannot spread a value onto a key: {param.key}")
if isinstance(resolved, Mapping):
for key, value in resolved.items():
resolved_params.append(TagParam(key=key, value=value))
elif isinstance(resolved, Iterable):
for value in resolved:
resolved_params.append(TagParam(key=None, value=value))
else:
raise ValueError(
f"Cannot spread non-iterable value: '{param.value.serialize()}' resolved to {resolved}"
)
else:
resolved_params.append(TagParam(key=param.key, value=resolved))
if self.tag_spec.tag == "html_attrs":
resolved_params = merge_repeated_kwargs(resolved_params)
resolved_params = process_aggregate_kwargs(resolved_params)
args, kwargs = self.tag_spec.validate_params(resolved_params)
return args, kwargs
# Data obj to give meaning to the parsed tag fields
class ParsedTag(NamedTuple):
tag_name: str
flags: Dict[str, bool]
params: TagParams
parse_body: Callable[[], NodeList]
def parse_template_tag( def parse_template_tag(
parser: Parser, parser: Parser,
token: Token, token: Token,
tag_spec: TagSpec, tag_spec: TagSpec,
tag_id: str,
) -> ParsedTag: ) -> ParsedTag:
tag_name, raw_args, raw_kwargs, raw_flags, is_inline = _parse_tag_preprocess(parser, token, tag_spec)
parsed_tag = _parse_tag_process(
parser=parser,
tag_id=tag_id,
tag_name=tag_name,
tag_spec=tag_spec,
raw_args=raw_args,
raw_kwargs=raw_kwargs,
raw_flags=raw_flags,
is_inline=is_inline,
)
return parsed_tag
class TagKwarg(NamedTuple):
type: Literal["kwarg", "spread"]
key: str
# E.g. `class` in `attrs:class="my-class"`
inner_key: Optional[str]
value: str
def _parse_tag_preprocess(
parser: Parser,
token: Token,
tag_spec: TagSpec,
) -> Tuple[str, List[str], List[TagKwarg], Set[str], bool]:
fix_nested_tags(parser, token) fix_nested_tags(parser, token)
_, attrs = parse_tag(token.contents) _, attrs = parse_tag(token.contents, parser)
# First token is tag name, e.g. `slot` in `{% slot <name> ... %}` # First token is tag name, e.g. `slot` in `{% slot <name> ... %}`
tag_name_attr = attrs.pop(0) tag_name_attr = attrs.pop(0)
@ -155,281 +247,16 @@ def _parse_tag_preprocess(
# Otherwise, depending on the tag spec, the tag may be: # Otherwise, depending on the tag spec, the tag may be:
# 2. Block tag - With corresponding end tag, e.g. `{% endslot %}` # 2. Block tag - With corresponding end tag, e.g. `{% endslot %}`
# 3. Inlined tag - Without the end tag. # 3. Inlined tag - Without the end tag.
last_token = attrs[-1].serialize(omit_key=True) if len(attrs) else None last_token = attrs[-1].value if len(attrs) else None
if last_token and last_token.serialize() == "/":
if last_token == "/":
attrs.pop() attrs.pop()
is_inline = True is_inline = True
else: else:
is_inline = not tag_spec.end_tag is_inline = not tag_spec.end_tag
raw_args, raw_kwargs, raw_flags = _parse_tag_input(tag_name, attrs) raw_params, flags = _extract_flags(tag_name, attrs, tag_spec.flags or [])
return tag_name, raw_args, raw_kwargs, raw_flags, is_inline def _parse_tag_body(parser: Parser, end_tag: str, inline: bool) -> NodeList:
def _parse_tag_input(tag_name: str, attrs: List[TagAttr]) -> Tuple[List[str], List[TagKwarg], Set[str]]:
# Given a list of attributes passed to a tag, categorise them into args, kwargs, and flags.
# The result of this will be passed to plugins to allow them to modify the tag inputs.
# And only once we get back the modified inputs, we will parse the data into
# internal structures like `DynamicFilterExpression`, or `SpreadOperator`.
#
# NOTES:
# - When args end, kwargs start. Positional args cannot follow kwargs
# - There can be multiple kwargs with same keys
# - Flags can be anywhere
# - Each flag can be present only once
is_args = True
args_or_flags: List[str] = []
kwarg_pairs: List[TagKwarg] = []
flags = set()
seen_spreads = 0
for attr in attrs:
value = attr.serialize(omit_key=True)
# Spread
if attr.value.spread:
if value == "...":
raise TemplateSyntaxError("Syntax operator is missing a value")
kwarg = TagKwarg(type="spread", key=f"...{seen_spreads}", inner_key=None, value=value[3:])
kwarg_pairs.append(kwarg)
is_args = False
seen_spreads += 1
continue
# Positional or flag
elif is_args and not attr.key:
args_or_flags.append(value)
continue
# Keyword
elif attr.key:
if is_aggregate_key(attr.key):
key, inner_key = attr.key.split(":", 1)
else:
key, inner_key = attr.key, None
kwarg = TagKwarg(type="kwarg", key=key, inner_key=inner_key, value=value)
kwarg_pairs.append(kwarg)
is_args = False
continue
# Either flag or a misplaced positional arg
elif not is_args and not attr.key:
# NOTE: By definition, dynamic expressions CANNOT be identifiers, because
# they contain quotes. So we can catch those early.
if not value.isidentifier():
raise TemplateSyntaxError(
f"'{tag_name}' received positional argument '{value}' after keyword argument(s)"
)
# Otherwise, we assume that the token is a flag. It is up to the tag logic
# to decide whether this is a recognized flag or a misplaced positional arg.
if value in flags:
raise TemplateSyntaxError(f"'{tag_name}' received flag '{value}' multiple times")
flags.add(value)
continue
return args_or_flags, kwarg_pairs, flags
def _parse_tag_process(
parser: Parser,
tag_id: str,
tag_name: str,
tag_spec: TagSpec,
raw_args: List[str],
raw_kwargs: List[TagKwarg],
raw_flags: Set[str],
is_inline: bool,
) -> ParsedTag:
seen_kwargs = set([kwarg.key for kwarg in raw_kwargs if kwarg.key and kwarg.type == "kwarg"])
seen_regular_kwargs = set()
seen_agg_kwargs = set()
def check_kwarg_for_agg_conflict(kwarg: TagKwarg) -> None:
# Skip spread operators
if kwarg.type == "spread":
return
is_agg_kwarg = kwarg.inner_key
if (
(is_agg_kwarg and (kwarg.key in seen_regular_kwargs))
or (not is_agg_kwarg and (kwarg.key in seen_agg_kwargs))
): # fmt: skip
raise TemplateSyntaxError(
f"Received argument '{kwarg.key}' both as a regular input ({kwarg.key}=...)"
f" and as an aggregate dict ('{kwarg.key}:key=...'). Must be only one of the two"
)
if is_agg_kwarg:
seen_agg_kwargs.add(kwarg.key)
else:
seen_regular_kwargs.add(kwarg.key)
for raw_kwarg in raw_kwargs:
check_kwarg_for_agg_conflict(raw_kwarg)
# Params that may be passed as positional args
pos_params: List[TagArg] = [
*[TagArg(name=name, positional_only=True) for name in (tag_spec.positional_only_args or [])],
*[TagArg(name=name, positional_only=False) for name in (tag_spec.pos_or_keyword_args or [])],
]
args: List[Expression] = []
# For convenience, allow to access named args by their name instead of index
named_args: Dict[str, Expression] = {}
kwarg_pairs: RuntimeKwargPairsInput = []
flags = set()
# When we come across a flag within positional args, we need to remember
# the offset, so we can correctly assign the args to the correct params
flag_offset = 0
for index, arg_input in enumerate(raw_args):
# Flags may be anywhere, so we need to check if the arg is a flag
if tag_spec.flags and arg_input in tag_spec.flags:
if arg_input in raw_flags or arg_input in flags:
raise TemplateSyntaxError(f"'{tag_name}' received flag '{arg_input}' multiple times")
flags.add(arg_input)
flag_offset += 1
continue
# Allow to use dynamic expressions as args, e.g. `"{{ }}"`
if is_dynamic_expression(arg_input):
arg = DynamicFilterExpression(parser, arg_input)
else:
arg = FilterExpression(arg_input, parser)
if (index - flag_offset) >= len(pos_params):
if tag_spec.positional_args_allow_extra:
args.append(arg)
continue
else:
# Allow only as many positional args as given
raise TemplateSyntaxError(
f"Tag '{tag_name}' received too many positional arguments: {raw_args[index:]}"
)
param = pos_params[index - flag_offset]
if param.positional_only:
args.append(arg)
named_args[param.name] = arg
else:
kwarg = TagKwarg(type="kwarg", key=param.name, inner_key=None, value=arg_input)
check_kwarg_for_agg_conflict(kwarg)
if param.name in seen_kwargs:
raise TemplateSyntaxError(
f"'{tag_name}' received argument '{param.name}' both as positional and keyword argument"
)
kwarg_pairs.append((param.name, arg))
if len(raw_args) - flag_offset < len(tag_spec.positional_only_args or []):
raise TemplateSyntaxError(
f"Tag '{tag_name}' received too few positional arguments. "
f"Expected {len(tag_spec.positional_only_args or [])}, got {len(raw_args) - flag_offset}"
)
for kwarg_input in raw_kwargs:
# Allow to use dynamic expressions with spread operator, e.g.
# `..."{{ }}"` or as kwargs values `key="{{ }}"`
if is_dynamic_expression(kwarg_input.value):
expr: Union[Expression, Operator] = DynamicFilterExpression(parser, kwarg_input.value)
else:
expr = FilterExpression(kwarg_input.value, parser)
if kwarg_input.type == "spread":
expr = SpreadOperator(expr)
if kwarg_input.inner_key:
full_key = f"{kwarg_input.key}:{kwarg_input.inner_key}"
else:
full_key = kwarg_input.key
kwarg_pairs.append((full_key, expr))
# Flags
flags_dict: Dict[str, bool] = {
# Base state, as defined in the tag spec
**{flag: False for flag in (tag_spec.flags or [])},
# Flags found among positional args
**{flag: True for flag in flags},
}
# Flags found among kwargs
for flag in raw_flags:
if flag in flags:
raise TemplateSyntaxError(f"'{tag_name}' received flag '{flag}' multiple times")
if flag not in (tag_spec.flags or []):
raise TemplateSyntaxError(f"'{tag_name}' received unknown flag '{flag}'")
flags.add(flag)
flags_dict[flag] = True
# Validate that there are no name conflicts between kwargs and flags
if flags.intersection(seen_kwargs):
raise TemplateSyntaxError(
f"'{tag_name}' received flags that conflict with keyword arguments: {flags.intersection(seen_kwargs)}"
)
# Validate kwargs
kwargs: RuntimeKwargsInput = {}
extra_keywords: Set[str] = set()
for key, val in kwarg_pairs:
# Operators are resolved at render-time, so skip them
if isinstance(val, Operator):
kwargs[key] = val
continue
# Check if key allowed
if not tag_spec.keywordonly_args:
is_key_allowed = False
else:
is_key_allowed = (
tag_spec.keywordonly_args == True or key in tag_spec.keywordonly_args # noqa: E712
) or bool(tag_spec.pos_or_keyword_args and key in tag_spec.pos_or_keyword_args)
if not is_key_allowed:
is_optional = key in tag_spec.optional_kwargs if tag_spec.optional_kwargs else False
if not is_optional:
extra_keywords.add(key)
# Check for repeated keys
if key in kwargs:
if not tag_spec.repeatable_kwargs:
is_key_repeatable = False
else:
is_key_repeatable = (
tag_spec.repeatable_kwargs == True or key in tag_spec.repeatable_kwargs # noqa: E712
)
if not is_key_repeatable:
# The keyword argument has already been supplied once
raise TemplateSyntaxError(f"'{tag_name}' received multiple values for keyword argument '{key}'")
# All ok
kwargs[key] = val
if len(extra_keywords):
extra_keys = ", ".join(extra_keywords)
raise TemplateSyntaxError(f"'{tag_name}' received unexpected kwargs: {extra_keys}")
return ParsedTag(
id=tag_id,
name=tag_name,
flags=flags_dict,
args=args,
named_args=named_args,
kwargs=RuntimeKwargs(kwargs),
kwarg_pairs=RuntimeKwargPairs(kwarg_pairs),
# NOTE: We defer parsing of the body, so we have the chance to call the tracing
# loggers before the parsing. This is because, if the body contains any other
# tags, it will trigger their tag handlers. So the code called AFTER
# `parse_body()` is already after all the nested tags were processed.
parse_body=lambda: _parse_tag_body(parser, tag_spec.end_tag, is_inline) if tag_spec.end_tag else NodeList(),
is_inline=is_inline,
)
def _parse_tag_body(parser: Parser, end_tag: str, inline: bool) -> NodeList:
if inline: if inline:
body = NodeList() body = NodeList()
else: else:
@ -437,6 +264,83 @@ def _parse_tag_body(parser: Parser, end_tag: str, inline: bool) -> NodeList:
parser.delete_first_token() parser.delete_first_token()
return body return body
return ParsedTag(
tag_name=tag_name,
params=TagParams(params=raw_params, tag_spec=tag_spec),
flags=flags,
# NOTE: We defer parsing of the body, so we have the chance to call the tracing
# loggers before the parsing. This is because, if the body contains any other
# tags, it will trigger their tag handlers. So the code called AFTER
# `parse_body()` is already after all the nested tags were processed.
parse_body=lambda: _parse_tag_body(parser, tag_spec.end_tag, is_inline) if tag_spec.end_tag else NodeList(),
)
def _extract_flags(
tag_name: str, attrs: List[TagAttr], allowed_flags: List[str]
) -> Tuple[List[TagAttr], Dict[str, bool]]:
found_flags = set()
remaining_attrs = []
for attr in attrs:
value = attr.serialize(omit_key=True)
if value not in allowed_flags:
remaining_attrs.append(attr)
continue
if attr.value.spread:
raise TemplateSyntaxError(f"'{tag_name}' - keyword '{value}' is a reserved flag, and cannot be spread")
if value in found_flags:
raise TemplateSyntaxError(f"'{tag_name}' received flag '{value}' multiple times")
found_flags.add(value)
flags_dict: Dict[str, bool] = {
# Base state, as defined in the tag spec
**{flag: False for flag in (allowed_flags or [])},
# Flags found on the template tag
**{flag: True for flag in found_flags},
}
return remaining_attrs, flags_dict
# TODO_REMOVE_IN_V1 - Disallow specifying the same key multiple times once in v1.
def merge_repeated_kwargs(params: List[TagParam]) -> List[TagParam]:
resolved_params: List[TagParam] = []
params_by_key: Dict[str, TagParam] = {}
param_indices_by_key: Dict[str, int] = {}
replaced_param_indices: Set[int] = set()
for index, param in enumerate(params):
if param.key is None:
resolved_params.append(param)
continue
# Case: First time we see a kwarg
if param.key not in params_by_key:
params_by_key[param.key] = param
param_indices_by_key[param.key] = index
resolved_params.append(param)
# Case: A kwarg is repeated - we merge the values into a single string, with a space in between.
else:
# We want to avoid mutating the items of the original list in place.
# So when it actually comes to merging the values, we create a new TagParam onto
# which we can merge values of all the repeated params. Thus, we keep track of this
# with `replaced_param_indices`.
if index not in replaced_param_indices:
orig_param = params_by_key[param.key]
orig_param_index = param_indices_by_key[param.key]
param_copy = TagParam(key=orig_param.key, value=str(orig_param.value))
resolved_params[orig_param_index] = param_copy
params_by_key[param.key] = param_copy
replaced_param_indices.add(orig_param_index)
params_by_key[param.key].value += " " + str(param.value)
return resolved_params
def fix_nested_tags(parser: Parser, block_token: Token) -> None: def fix_nested_tags(parser: Parser, block_token: Token) -> None:
# Since the nested tags MUST be wrapped in quotes, e.g. # Since the nested tags MUST be wrapped in quotes, e.g.
@ -445,7 +349,7 @@ def fix_nested_tags(parser: Parser, block_token: Token) -> None:
# #
# We can parse the tag's tokens so we can find the last one, and so we consider # We can parse the tag's tokens so we can find the last one, and so we consider
# the unclosed `{%` only for the last bit. # the unclosed `{%` only for the last bit.
_, attrs = parse_tag(block_token.contents) _, attrs = parse_tag(block_token.contents, parser)
# If there are no attributes, then there are no nested tags # If there are no attributes, then there are no nested tags
if not attrs: if not attrs:
@ -496,7 +400,12 @@ def fix_nested_tags(parser: Parser, block_token: Token) -> None:
# There is 3 double quotes, but if the contents get split at the first `%}` # There is 3 double quotes, but if the contents get split at the first `%}`
# then there will be a single unclosed double quote in the last bit. # then there will be a single unclosed double quote in the last bit.
has_unclosed_quote = not last_token.quoted and last_token.value and last_token.value[0] in ('"', "'") first_char_index = len(last_token.spread or "")
has_unclosed_quote = (
not last_token.quoted
and last_token.value
and last_token.value[first_char_index] in ('"', "'")
) # fmt: skip
needs_fixing = has_unclosed_tag and has_unclosed_quote needs_fixing = has_unclosed_tag and has_unclosed_quote

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(
TemplateSyntaxError, "'html_attrs' received too many positional arguments: ['class']" TypeError, "Invalid parameters for tag 'html_attrs': too many positional arguments"
): ):
template.render(Context({"class_var": "padding-top-8"})) template.render(Context({"class_var": "padding-top-8"}))
@ -247,6 +247,12 @@ class HtmlAttrsTests(BaseTestCase):
) )
self.assertNotIn("override-me", rendered) self.assertNotIn("override-me", rendered)
# Note: Because there's both `attrs:class` and `defaults:class`, the `attrs`,
# it's as if the template tag call was (ignoring the `class` and `data-id` attrs):
#
# `{% html_attrs attrs={"class": ...} defaults={"class": ...} attrs %}>content</div>`
#
# Which raises, because `attrs` is passed both as positional and as keyword argument.
def test_tag_raises_on_aggregate_and_positional_args_for_attrs(self): def test_tag_raises_on_aggregate_and_positional_args_for_attrs(self):
@register("test") @register("test")
class AttrsComponent(Component): class AttrsComponent(Component):
@ -262,7 +268,9 @@ class HtmlAttrsTests(BaseTestCase):
template = Template(self.template_str) template = Template(self.template_str)
with self.assertRaisesMessage(TemplateSyntaxError, "Received argument 'attrs' both as a regular input"): with self.assertRaisesMessage(
TypeError, "Invalid parameters for tag 'html_attrs': multiple values for argument 'attrs'"
):
template.render(Context({"class_var": "padding-top-8"})) template.render(Context({"class_var": "padding-top-8"}))
def test_tag_raises_on_aggregate_and_positional_args_for_defaults(self): def test_tag_raises_on_aggregate_and_positional_args_for_defaults(self):
@ -270,7 +278,14 @@ class HtmlAttrsTests(BaseTestCase):
class AttrsComponent(Component): class AttrsComponent(Component):
template: types.django_html = """ template: types.django_html = """
{% load component_tags %} {% load component_tags %}
<div {% html_attrs defaults=defaults attrs:class="from_agg_key" defaults:class="override-me" class="added_class" class="another-class" data-id=123 %}> <div {% html_attrs
defaults=defaults
attrs:class="from_agg_key"
defaults:class="override-me"
class="added_class"
class="another-class"
data-id=123
%}>
content content
</div> </div>
""" # noqa: E501 """ # noqa: E501

View file

@ -108,7 +108,7 @@ class MainMediaTest(BaseTestCase):
self.assertInHTML( self.assertInHTML(
""" """
<form data-djc-id-a1bc41 method="post"> <form data-djc-id-a1bc3f method="post">
<input name="variable" type="text" value="test"/> <input name="variable" type="text" value="test"/>
<input type="submit"/> <input type="submit"/>
</form> </form>
@ -184,7 +184,7 @@ class MainMediaTest(BaseTestCase):
rendered = render_dependencies(rendered_raw) rendered = render_dependencies(rendered_raw)
self.assertIn( self.assertIn(
"Variable: <strong data-djc-id-a1bc41>test</strong>", "Variable: <strong data-djc-id-a1bc3f>test</strong>",
rendered, rendered,
) )
self.assertInHTML( self.assertInHTML(
@ -915,7 +915,7 @@ class MediaRelativePathTests(BaseTestCase):
self.assertInHTML( self.assertInHTML(
""" """
<form data-djc-id-a1bc41 method="post"> <form data-djc-id-a1bc3f method="post">
<input type="text" name="variable" value="test"> <input type="text" name="variable" value="test">
<input type="submit"> <input type="submit">
</form> </form>

View file

@ -265,7 +265,7 @@ class RenderDependenciesTests(BaseTestCase):
self.assertInHTML( self.assertInHTML(
""" """
<body> <body>
Variable: <strong data-djc-id-a1bc41>foo</strong> Variable: <strong data-djc-id-a1bc3f>foo</strong>
<style>.xyz { color: red; }</style> <style>.xyz { color: red; }</style>
<link href="style.css" media="all" rel="stylesheet"> <link href="style.css" media="all" rel="stylesheet">
@ -510,7 +510,7 @@ class MiddlewareTests(BaseTestCase):
assert_dependencies(rendered1) assert_dependencies(rendered1)
self.assertEqual( self.assertEqual(
rendered1.count("Variable: <strong data-djc-id-a1bc41 data-djc-id-a1bc42>value</strong>"), rendered1.count("Variable: <strong data-djc-id-a1bc3f data-djc-id-a1bc40>value</strong>"),
1, 1,
) )
@ -520,7 +520,7 @@ class MiddlewareTests(BaseTestCase):
) )
assert_dependencies(rendered2) assert_dependencies(rendered2)
self.assertEqual( self.assertEqual(
rendered2.count("Variable: <strong data-djc-id-a1bc43 data-djc-id-a1bc44>value</strong>"), rendered2.count("Variable: <strong data-djc-id-a1bc41 data-djc-id-a1bc42>value</strong>"),
1, 1,
) )
@ -531,6 +531,6 @@ class MiddlewareTests(BaseTestCase):
assert_dependencies(rendered3) assert_dependencies(rendered3)
self.assertEqual( self.assertEqual(
rendered3.count("Variable: <strong data-djc-id-a1bc45 data-djc-id-a1bc46>value</strong>"), rendered3.count("Variable: <strong data-djc-id-a1bc43 data-djc-id-a1bc44>value</strong>"),
1, 1,
) )

View file

@ -6,7 +6,7 @@ from django.template import Context, Template, TemplateSyntaxError
from django.template.base import FilterExpression, Node, Parser, Token from django.template.base import FilterExpression, Node, Parser, Token
from django_components import Component, register, registry, types from django_components import Component, register, registry, types
from django_components.expression import DynamicFilterExpression, safe_resolve_dict, safe_resolve_list from django_components.expression import DynamicFilterExpression, is_aggregate_key
from .django_test_setup import setup_test_config from .django_test_setup import setup_test_config
from .testutils import BaseTestCase, parametrize_context_behavior from .testutils import BaseTestCase, parametrize_context_behavior
@ -46,38 +46,6 @@ def make_context(d: Dict):
####################### #######################
class ResolveTests(BaseTestCase):
def test_safe_resolve(self):
expr = default_parser.compile_filter("var_abc")
ctx = make_context({"var_abc": 123})
self.assertEqual(
expr.resolve(ctx),
123,
)
ctx2 = make_context({"var_xyz": 123})
self.assertEqual(expr.resolve(ctx2), "")
def test_safe_resolve_list(self):
exprs = [default_parser.compile_filter(f"var_{char}") for char in "abc"]
ctx = make_context({"var_a": 123, "var_b": [{}, {}]})
self.assertEqual(
safe_resolve_list(ctx, exprs),
[123, [{}, {}], ""],
)
def test_safe_resolve_dict(self):
exprs = {char: default_parser.compile_filter(f"var_{char}") for char in "abc"}
ctx = make_context({"var_a": 123, "var_b": [{}, {}]})
self.assertEqual(
safe_resolve_dict(ctx, exprs),
{"a": 123, "b": [{}, {}], "c": ""},
)
# NOTE: Django calls the `{{ }}` syntax "variables" and `{% %}` "blocks" # NOTE: Django calls the `{{ }}` syntax "variables" and `{% %}` "blocks"
class DynamicExprTests(BaseTestCase): class DynamicExprTests(BaseTestCase):
def test_variable_resolve_dynamic_expr(self): def test_variable_resolve_dynamic_expr(self):
@ -729,7 +697,7 @@ class SpreadOperatorTests(BaseTestCase):
) )
@parametrize_context_behavior(["django", "isolated"]) @parametrize_context_behavior(["django", "isolated"])
def test_later_spreads_overwrite_earlier(self): def test_later_spreads_do_not_overwrite_earlier(self):
@register("test") @register("test")
class SimpleComponent(Component): class SimpleComponent(Component):
def get_context_data( def get_context_data(
@ -748,7 +716,20 @@ class SpreadOperatorTests(BaseTestCase):
<div>{{ x }}</div> <div>{{ x }}</div>
""" """
template_str: types.django_html = ( context = Context(
{
"my_dict": {
"attrs:@click": "() => {}",
"attrs:style": "height: 20px",
"items": [1, 2, 3],
},
"list": [{"a": 1, "x": "OVERWRITTEN_X"}, {"a": 2}, {"a": 3}],
}
)
# Mergingg like this will raise TypeError, because it's like
# a function receiving multiple kwargs with the same name.
template_str1: types.django_html = (
""" """
{% load component_tags %} {% load component_tags %}
{% component 'test' {% component 'test'
@ -762,52 +743,44 @@ class SpreadOperatorTests(BaseTestCase):
) )
) )
template = Template(template_str) template1 = Template(template_str1)
rendered = template.render(
Context(
{
"my_dict": {
"attrs:@click": "() => {}",
"attrs:style": "height: 20px",
"items": [1, 2, 3],
},
"list": [{"a": 1, "x": "OVERWRITTEN_X"}, {"a": 2}, {"a": 3}],
}
),
)
self.assertHTMLEqual( with self.assertRaisesMessage(
rendered, TypeError,
""" "got multiple values for keyword argument 'x'",
<div data-djc-id-a1bc3f>{'@click': '() =&gt; {}', 'style': 'OVERWRITTEN'}</div> ):
<div data-djc-id-a1bc3f>[1, 2, 3]</div> template1.render(context)
<div data-djc-id-a1bc3f>1</div>
<div data-djc-id-a1bc3f>OVERWRITTEN_X</div>
""",
)
@parametrize_context_behavior(["django", "isolated"]) # But, similarly to python, we can merge multiple **kwargs by instead
def test_raises_if_positional_arg_after_spread(self): # merging them into a single dict, and spreading that.
@register("test") template_str2: types.django_html = (
class SimpleComponent(Component):
pass
template_str: types.django_html = (
""" """
{% load component_tags %} {% load component_tags %}
{% component 'test' {% component 'test'
...my_dict ...{
var_a **my_dict,
..."{{ list|first }}" "x": 123,
x=123 **"{{ list|first }}",
}
attrs:style="OVERWRITTEN"
/ %} / %}
""".replace( """.replace(
"\n", " " "\n", " "
) )
) )
with self.assertRaisesMessage(TemplateSyntaxError, "'component' received unknown flag 'var_a'"): template2 = Template(template_str2)
Template(template_str) rendered2 = template2.render(context)
self.assertHTMLEqual(
rendered2,
"""
<div data-djc-id-a1bc40>{'@click': '() =&gt; {}', 'style': 'OVERWRITTEN'}</div>
<div data-djc-id-a1bc40>[1, 2, 3]</div>
<div data-djc-id-a1bc40>1</div>
<div data-djc-id-a1bc40>OVERWRITTEN_X</div>
""",
)
@parametrize_context_behavior(["django", "isolated"]) @parametrize_context_behavior(["django", "isolated"])
def test_raises_on_missing_value(self): def test_raises_on_missing_value(self):
@ -830,6 +803,49 @@ class SpreadOperatorTests(BaseTestCase):
with self.assertRaisesMessage(TemplateSyntaxError, "Spread syntax '...' is missing a value"): with self.assertRaisesMessage(TemplateSyntaxError, "Spread syntax '...' is missing a value"):
Template(template_str) Template(template_str)
@parametrize_context_behavior(["django", "isolated"])
def test_spread_list_and_iterables(self):
captured = None
@register("test")
class SimpleComponent(Component):
template = ""
def get_context_data(self, *args, **kwargs):
nonlocal captured
captured = args, kwargs
return {}
template_str: types.django_html = (
"""
{% load component_tags %}
{% component 'test'
...var_a
...var_b
/ %}
""".replace(
"\n", " "
)
)
template = Template(template_str)
context = Context(
{
"var_a": "abc",
"var_b": [1, 2, 3],
}
)
template.render(context)
self.assertEqual(
captured,
(
("a", "b", "c", 1, 2, 3),
{},
),
)
@parametrize_context_behavior(["django", "isolated"]) @parametrize_context_behavior(["django", "isolated"])
def test_raises_on_non_dict(self): def test_raises_on_non_dict(self):
@register("test") @register("test")
@ -840,7 +856,6 @@ class SpreadOperatorTests(BaseTestCase):
""" """
{% load component_tags %} {% load component_tags %}
{% component 'test' {% component 'test'
var_a
...var_b ...var_b
/ %} / %}
""".replace( """.replace(
@ -851,25 +866,63 @@ class SpreadOperatorTests(BaseTestCase):
template = Template(template_str) template = Template(template_str)
# List # List
with self.assertRaisesMessage( with self.assertRaisesMessage(ValueError, "Cannot spread non-iterable value: '...var_b' resolved to 123"):
RuntimeError, "Spread operator expression must resolve to a Dict, got [1, 2, 3]" template.render(Context({"var_b": 123}))
):
template.render(
Context( class AggregateKwargsTest(BaseTestCase):
def test_aggregate_kwargs(self):
captured = None
@register("test")
class Test(Component):
template = ""
def get_context_data(self, *args, **kwargs):
nonlocal captured
captured = args, kwargs
return {}
template_str: types.django_html = """
{% load component_tags %}
{% component 'test'
attrs:@click.stop="dispatch('click_event')"
attrs:x-data="{hello: 'world'}"
attrs:class=class_var
attrs::placeholder="No text"
my_dict:one=2
three=four
/ %}
"""
template = Template(template_str)
template.render(Context({"class_var": "padding-top-8", "four": 4}))
self.assertEqual(
captured,
(
(),
{ {
"var_a": "abc", "attrs": {
"var_b": [1, 2, 3], "@click.stop": "dispatch('click_event')",
} "x-data": "{hello: 'world'}",
) "class": "padding-top-8",
":placeholder": "No text",
},
"my_dict": {"one": 2},
"three": 4,
},
),
) )
# String def is_aggregate_key(self):
with self.assertRaisesMessage(RuntimeError, "Spread operator expression must resolve to a Dict, got def"): self.assertEqual(is_aggregate_key(""), False)
template.render( self.assertEqual(is_aggregate_key(" "), False)
Context( self.assertEqual(is_aggregate_key(" : "), False)
{ self.assertEqual(is_aggregate_key("attrs"), False)
"var_a": "abc", self.assertEqual(is_aggregate_key(":attrs"), False)
"var_b": "def", self.assertEqual(is_aggregate_key(" :attrs "), False)
} self.assertEqual(is_aggregate_key("attrs:"), False)
) self.assertEqual(is_aggregate_key(":attrs:"), False)
) self.assertEqual(is_aggregate_key("at:trs"), True)
self.assertEqual(is_aggregate_key(":at:trs"), False)

File diff suppressed because it is too large Load diff

View file

@ -1,137 +0,0 @@
from django.template import Context, Template
from django.template.base import Lexer, Parser
from django_components import Component, registry, types
from django_components.expression import (
is_aggregate_key,
process_aggregate_kwargs,
safe_resolve_dict,
safe_resolve_list,
)
from django_components.util.template_tag import TagSpec, parse_template_tag
from .django_test_setup import setup_test_config
from .testutils import BaseTestCase, parametrize_context_behavior
setup_test_config({"autodiscover": False})
class ParserTest(BaseTestCase):
def test_parses_args_kwargs(self):
template_str = "{% component 42 myvar key='val' key2=val2 %}"
tokens = Lexer(template_str).tokenize()
parser = Parser(tokens=tokens)
spec = TagSpec(
tag="component",
pos_or_keyword_args=["num", "var"],
keywordonly_args=True,
)
tag = parse_template_tag(parser, parser.tokens[0], tag_spec=spec, tag_id="my-id")
ctx = {"myvar": {"a": "b"}, "val2": 1}
args = safe_resolve_list(ctx, tag.args)
named_args = safe_resolve_dict(ctx, tag.named_args)
kwargs = tag.kwargs.resolve(ctx)
self.assertListEqual(args, [])
self.assertDictEqual(named_args, {})
self.assertDictEqual(kwargs, {"num": 42, "var": {"a": "b"}, "key": "val", "key2": 1})
def test_parses_special_kwargs(self):
template_str = "{% component date=date @lol=2 na-me=bzz @event:na-me.mod=bzz #my-id=True %}"
tokens = Lexer(template_str).tokenize()
parser = Parser(tokens=tokens)
spec = TagSpec(tag="component", keywordonly_args=True)
tag = parse_template_tag(parser, parser.tokens[0], tag_spec=spec, tag_id="my-id")
ctx = Context({"date": 2024, "bzz": "fzz"})
args = safe_resolve_list(ctx, tag.args)
kwargs = tag.kwargs.resolve(ctx)
self.assertListEqual(args, [])
self.assertDictEqual(
kwargs,
{
"@event": {"na-me.mod": "fzz"},
"@lol": 2,
"date": 2024,
"na-me": "fzz",
"#my-id": True,
},
)
class ParserComponentTest(BaseTestCase):
class SimpleComponent(Component):
template: types.django_html = """
{{ date }}
{{ id }}
{{ on_click }}
"""
def get_context_data(self, **kwargs):
return {
"date": kwargs["my-date"],
"id": kwargs["#some_id"],
"on_click": kwargs["@click.native"],
}
@parametrize_context_behavior(["django", "isolated"])
def test_special_chars_accessible_via_kwargs(self):
registry.register("test", self.SimpleComponent)
template_str: types.django_html = """
{% load component_tags %}
{% component "test" my-date="2015-06-19" @click.native=do_something #some_id=True %}
{% endcomponent %}
"""
template = Template(template_str)
rendered = template.render(Context({"do_something": "abc"}))
self.assertHTMLEqual(
rendered,
"""
2015-06-19
True
abc
""",
)
class AggregateKwargsTest(BaseTestCase):
def test_aggregate_kwargs(self):
processed = process_aggregate_kwargs(
{
"attrs:@click.stop": "dispatch('click_event')",
"attrs:x-data": "{hello: 'world'}",
"attrs:class": "class_var",
"my_dict:one": 2,
"three": "four",
":placeholder": "No text",
}
)
self.assertDictEqual(
processed,
{
"attrs": {
"@click.stop": "dispatch('click_event')",
"x-data": "{hello: 'world'}",
"class": "class_var",
},
"my_dict": {"one": 2},
"three": "four",
":placeholder": "No text",
},
)
def is_aggregate_key(self):
self.assertEqual(is_aggregate_key(""), False)
self.assertEqual(is_aggregate_key(" "), False)
self.assertEqual(is_aggregate_key(" : "), False)
self.assertEqual(is_aggregate_key("attrs"), False)
self.assertEqual(is_aggregate_key(":attrs"), False)
self.assertEqual(is_aggregate_key(" :attrs "), False)
self.assertEqual(is_aggregate_key("attrs:"), False)
self.assertEqual(is_aggregate_key(":attrs:"), False)
self.assertEqual(is_aggregate_key("at:trs"), True)
self.assertEqual(is_aggregate_key(":at:trs"), False)

View file

@ -354,7 +354,10 @@ class ProvideTemplateTagTest(BaseTestCase):
{% component "injectee" %} {% component "injectee" %}
{% endcomponent %} {% endcomponent %}
""" """
with self.assertRaisesMessage(RuntimeError, "Provide tag kwarg 'name' is missing"): with self.assertRaisesMessage(
TypeError,
"Invalid parameters for tag 'provide': missing a required argument: 'name'",
):
Template(template_str).render(Context({})) Template(template_str).render(Context({}))
@parametrize_context_behavior(["django", "isolated"]) @parametrize_context_behavior(["django", "isolated"])