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 %}
```
"""
params: List[str] = [tag_spec.tag]
if tag_spec.positional_only_args:
params.extend([*tag_spec.positional_only_args, "/"])
optional_kwargs = set(tag_spec.optional_kwargs or [])
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 [])]
)
# The signature returns a string like:
# `(arg: Any, **kwargs: Any) -> None`
params_str = str(tag_spec.signature)
# Remove the return type annotation, the `-> None` part
params_str = params_str.rsplit("->", 1)[0]
# Remove brackets around the params, to end up only with `arg: Any, **kwargs: Any`
params_str = params_str.strip()[1:-1]
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
full_tag = f"{{% {' '.join(params)} %}}"
full_tag = "{% " + tag_spec.tag + " " + params_str + " %}"
if 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.safestring import SafeString, mark_safe
from django_components.expression import RuntimeKwargPairs, RuntimeKwargs
from django_components.node import BaseNode
from django_components.util.template_tag import TagParams
HTML_ATTRS_DEFAULTS_KEY = "defaults"
HTML_ATTRS_ATTRS_KEY = "attrs"
@ -18,32 +18,19 @@ HTML_ATTRS_ATTRS_KEY = "attrs"
class HtmlAttrsNode(BaseNode):
def __init__(
self,
kwargs: RuntimeKwargs,
kwarg_pairs: RuntimeKwargPairs,
params: TagParams,
node_id: Optional[str] = None,
):
super().__init__(nodelist=None, args=None, kwargs=kwargs, node_id=node_id)
self.kwarg_pairs = kwarg_pairs
super().__init__(nodelist=None, params=params, node_id=node_id)
def render(self, context: Context) -> str:
append_attrs: List[Tuple[str, Any]] = []
# Resolve all data
kwargs = self.kwargs.resolve(context)
args, kwargs = self.params.resolve(context)
attrs = kwargs.pop(HTML_ATTRS_ATTRS_KEY, None) or {}
defaults = kwargs.pop(HTML_ATTRS_DEFAULTS_KEY, None) or {}
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))
append_attrs = list(kwargs.items())
# Merge it
final_attrs = {**defaults, **attrs}

View file

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

View file

@ -1,42 +1,43 @@
import re
from abc import ABC, abstractmethod
from typing import Any, Dict, List, Mapping, Optional, Tuple, Union
from typing import TYPE_CHECKING, Any, Dict, List
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"]
RuntimeKwargsInput = Dict[str, Union[Expression, "Operator"]]
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
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}'")
@ -103,72 +104,6 @@ class StringifiedNode(Node):
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:
# NOTE: If we get a key that starts with `:`, like `:class`, we do not split it.
# This syntax is used by Vue and AlpineJS.
@ -203,14 +138,8 @@ def is_dynamic_expression(value: Any) -> bool:
return True
def is_spread_operator(value: Any) -> bool:
if not isinstance(value, str) or not value:
return False
return value.startswith("...")
def process_aggregate_kwargs(kwargs: Mapping[str, Any]) -> Dict[str, Any]:
# 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:`).
@ -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
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]] = {}
for key, val in kwargs.items():
if not is_aggregate_key(key):
processed_kwargs[key] = val
for param in params:
# Positional args
if param.key is None:
processed_params.append(param)
continue
# NOTE: Trim off the prefix from keys
prefix, sub_key = key.split(":", 1)
if prefix not in nested_kwargs:
nested_kwargs[prefix] = {}
nested_kwargs[prefix][sub_key] = val
# 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 processed_kwargs:
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_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_components.expression import Expression, RuntimeKwargs
from django_components.util.misc import gen_id
from django_components.util.template_tag import TagParams
class BaseNode(Node):
@ -11,12 +11,10 @@ class BaseNode(Node):
def __init__(
self,
params: TagParams,
nodelist: Optional[NodeList] = None,
node_id: Optional[str] = None,
args: Optional[List[Expression]] = None,
kwargs: Optional[RuntimeKwargs] = None,
):
self.params = params
self.nodelist = nodelist or NodeList()
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_components.context import set_provided_context_var
from django_components.expression import RuntimeKwargs
from django_components.node import BaseNode
from django_components.util.logger import trace_msg
from django_components.util.template_tag import TagParams
PROVIDE_NAME_KWARG = "name"
@ -21,11 +21,11 @@ class ProvideNode(BaseNode):
def __init__(
self,
nodelist: NodeList,
params: TagParams,
trace_id: str,
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
@ -50,7 +50,7 @@ class ProvideNode(BaseNode):
return output
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)
if not name:

View file

@ -32,10 +32,10 @@ from django_components.context import (
_REGISTRY_CONTEXT_KEY,
_ROOT_CTX_CONTEXT_KEY,
)
from django_components.expression import RuntimeKwargs, is_identifier
from django_components.node import BaseNode
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:
from django_components.component_registry import ComponentRegistry
@ -155,13 +155,13 @@ class SlotNode(BaseNode):
def __init__(
self,
nodelist: NodeList,
params: TagParams,
trace_id: str,
node_id: Optional[str] = None,
kwargs: Optional[RuntimeKwargs] = None,
is_required: 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_default = is_default
@ -373,7 +373,7 @@ class SlotNode(BaseNode):
context: Context,
component_name: Optional[str] = None,
) -> Tuple[str, Dict[str, Optional[str]]]:
kwargs = self.kwargs.resolve(context)
_, kwargs = self.params.resolve(context)
name = kwargs.pop(SLOT_NAME_KWARG, None)
if not name:
@ -388,11 +388,11 @@ class FillNode(BaseNode):
def __init__(
self,
nodelist: NodeList,
kwargs: RuntimeKwargs,
params: TagParams,
trace_id: str,
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
@ -410,7 +410,7 @@ class FillNode(BaseNode):
return f"<{self.__class__.__name__} ID: {self.node_id}. Contents: {repr(self.nodelist)}.>"
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)
default_var = self._process_kwarg(kwargs, SLOT_DEFAULT_KWARG)
@ -452,6 +452,9 @@ class FillNode(BaseNode):
return None
value = kwargs[key]
if value is None:
return None
if identifier and not is_identifier(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.utils.module_loading import import_string
from django_components.expression import resolve_string
from django_components.util.misc import is_str_wrapped_in_quotes
if TYPE_CHECKING:
@ -267,7 +266,7 @@ class ComponentFormatter(TagFormatterABC):
raise TemplateSyntaxError(f"Component name must be a string 'literal', got: {comp_name}")
# Remove the quotes
comp_name = resolve_string(comp_name)
comp_name = comp_name[1:-1]
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`.
# 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
from django.template.base import Parser, TextNode, Token
from django.template.exceptions import TemplateSyntaxError
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_registry import ComponentRegistry
from django_components.dependencies import CSS_DEPENDENCY_PLACEHOLDER, JS_DEPENDENCY_PLACEHOLDER
from django_components.provide import PROVIDE_NAME_KWARG, ProvideNode
from django_components.slots import (
SLOT_DATA_KWARG,
SLOT_DEFAULT_KEYWORD,
SLOT_DEFAULT_KWARG,
SLOT_NAME_KWARG,
SLOT_REQUIRED_KEYWORD,
FillNode,
SlotNode,
)
from django_components.provide import ProvideNode
from django_components.slots import SLOT_DEFAULT_KEYWORD, SLOT_REQUIRED_KEYWORD, FillNode, SlotNode
from django_components.tag_formatter import get_tag_formatter
from django_components.util.logger import trace_msg
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))
def component_dependencies_signature() -> None: ... # noqa: E704
@register.tag("component_css_dependencies")
@with_tag_spec(
TagSpec(
tag="component_css_dependencies",
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:
@ -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.
"""
# Parse to check that the syntax is valid
tag_id = gen_id()
parse_template_tag(parser, token, tag_spec, tag_id)
parse_template_tag(parser, token, tag_spec)
return _component_dependencies("css")
@ -87,6 +83,7 @@ def component_css_dependencies(parser: Parser, token: Token, tag_spec: TagSpec)
TagSpec(
tag="component_js_dependencies",
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:
@ -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.
"""
# Parse to check that the syntax is valid
tag_id = gen_id()
parse_template_tag(parser, token, tag_spec, tag_id)
parse_template_tag(parser, token, tag_spec)
return _component_dependencies("js")
def slot_signature(name: str, **kwargs: Any) -> None: ... # noqa: E704
@register.tag("slot")
@with_tag_spec(
TagSpec(
tag="slot",
end_tag="endslot",
positional_only_args=[],
pos_or_keyword_args=[SLOT_NAME_KWARG],
keywordonly_args=True,
repeatable_kwargs=False,
signature=inspect.Signature.from_callable(slot_signature),
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 = 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} ({slot_name_kwarg})" if slot_name_kwarg else f"slot-id-{tag.id}"
trace_msg("PARSE", "SLOT", trace_id, tag.id)
trace_id = f"slot-id-{tag_id}"
trace_msg("PARSE", "SLOT", trace_id, tag_id)
body = tag.parse_body()
slot_node = SlotNode(
nodelist=body,
node_id=tag.id,
kwargs=tag.kwargs,
node_id=tag_id,
params=tag.params,
is_required=tag.flags[SLOT_REQUIRED_KEYWORD],
is_default=tag.flags[SLOT_DEFAULT_KEYWORD],
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
def fill_signature(name: str, *, data: Optional[str] = None, default: Optional[str] = None) -> None: ... # noqa: E704
@register.tag("fill")
@with_tag_spec(
TagSpec(
tag="fill",
end_tag="endfill",
positional_only_args=[],
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,
signature=inspect.Signature.from_callable(fill_signature),
)
)
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 = 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} ({fill_name_kwarg})" if fill_name_kwarg else f"fill-id-{tag.id}"
trace_msg("PARSE", "FILL", trace_id, tag.id)
trace_id = f"fill-id-{tag_id}"
trace_msg("PARSE", "FILL", trace_id, tag_id)
body = tag.parse_body()
fill_node = FillNode(
nodelist=body,
node_id=tag.id,
kwargs=tag.kwargs,
node_id=tag_id,
params=tag.params,
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
def component_signature(*args: Any, **kwargs: Any) -> None: ... # noqa: E704
@with_tag_spec(
TagSpec(
tag="component",
end_tag="endcomponent",
positional_only_args=[],
positional_args_allow_extra=True, # Allow many args
keywordonly_args=True,
repeatable_kwargs=False,
signature=inspect.Signature.from_callable(component_signature),
flags=[COMP_ONLY_FLAG],
)
)
@ -509,53 +500,44 @@ def component(
result = formatter.parse([*bits])
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]
token.contents = " ".join(bits)
tag = parse_template_tag(
parser,
token,
TagSpec(
**{
**tag_spec._asdict(),
"tag": tag_name,
"end_tag": end_tag,
}
),
tag_id=tag_id,
)
# Set the component-specific start and end tags
component_tag_spec = tag_spec.copy()
component_tag_spec.tag = tag_name
component_tag_spec.end_tag = end_tag
# Check for isolated context keyword
isolated_context = tag.flags[COMP_ONLY_FLAG]
tag = parse_template_tag(parser, token, component_tag_spec)
trace_msg("PARSE", "COMP", result.component_name, tag.id)
trace_msg("PARSE", "COMP", result.component_name, tag_id)
body = tag.parse_body()
component_node = ComponentNode(
name=result.component_name,
args=tag.args,
kwargs=tag.kwargs,
isolated_context=isolated_context,
params=tag.params,
isolated_context=tag.flags[COMP_ONLY_FLAG],
nodelist=body,
node_id=tag.id,
node_id=tag_id,
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
def provide_signature(name: str, **kwargs: Any) -> None: ... # noqa: E704
@register.tag("provide")
@with_tag_spec(
TagSpec(
tag="provide",
end_tag="endprovide",
positional_only_args=[],
pos_or_keyword_args=[PROVIDE_NAME_KWARG],
keywordonly_args=True,
repeatable_kwargs=False,
signature=inspect.Signature.from_callable(provide_signature),
flags=[],
)
)
@ -631,35 +613,34 @@ def provide(parser: Parser, token: Token, tag_spec: TagSpec) -> ProvideNode:
tag_id = gen_id()
# 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"provide-id-{tag.id} ({name_kwarg})" if name_kwarg else f"fill-id-{tag.id}"
trace_msg("PARSE", "PROVIDE", trace_id, tag.id)
trace_id = f"fill-id-{tag_id}"
trace_msg("PARSE", "PROVIDE", trace_id, tag_id)
body = tag.parse_body()
provide_node = ProvideNode(
nodelist=body,
node_id=tag.id,
kwargs=tag.kwargs,
node_id=tag_id,
params=tag.params,
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
def html_attrs_signature( # noqa: E704
attrs: Optional[Dict] = None, defaults: Optional[Dict] = None, **kwargs: Any
) -> None: ...
@register.tag("html_attrs")
@with_tag_spec(
TagSpec(
tag="html_attrs",
end_tag=None, # inline-only
positional_only_args=[],
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,
signature=inspect.Signature.from_callable(html_attrs_signature),
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).**
"""
tag_id = gen_id()
tag = parse_template_tag(parser, token, tag_spec, tag_id)
tag = parse_template_tag(parser, token, tag_spec)
return HtmlAttrsNode(
kwargs=tag.kwargs,
kwarg_pairs=tag.kwarg_pairs,
node_id=tag_id,
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
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:
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 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_components.expression import DynamicFilterExpression, is_dynamic_expression
TAG_WHITESPACE = (" ", "\t", "\n", "\r", "\f")
TAG_FILTER = ("|", ":")
TAG_SPREAD = ("*", "**", "...")
@ -71,7 +75,8 @@ class TagAttr:
return s
class TagValue(NamedTuple):
@dataclass
class TagValue:
"""
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"]
compiled: Optional[FilterExpression] = None
@property
def is_spread(self) -> bool:
@ -94,6 +100,29 @@ class TagValue(NamedTuple):
def serialize(self) -> str:
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
class TagValuePart:
@ -170,9 +199,12 @@ class TagValueStruct:
Types:
- `root`: Plain tag value
- `simple`: Plain tag value
- `list`: A list 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"]
@ -182,11 +214,20 @@ class TagValueStruct:
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.
"""
# Container for parser-specific metadata
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:
"""
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:
if isinstance(value, TagValue):
return value.serialize()
@ -226,8 +267,99 @@ class TagValueStruct:
dict_pair = []
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:
@ -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.
# 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]
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')")
# NOTE: The `...`, `**`, `*` are "taken" in `extract_spread_token()`
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)
stack.append(struct)
continue
@ -499,7 +631,7 @@ def parse_tag(text: str) -> Tuple[str, List[TagAttr]]:
else:
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)
struct.meta["expects_key"] = True
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.
# E.g. imagine: `height="20" | yesno : "1,2,3" | lower`
# and we're here: ^
# (or here) ^
# (or here) ^
# and we want to parse `yesno` next
if not is_first_part:
filter_token = taken_n(1) # | or :
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:
filter_token = None
is_first_part = False

View file

@ -1,46 +1,22 @@
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.exceptions import TemplateSyntaxError
from django_components.expression import (
DynamicFilterExpression,
Expression,
FilterExpression,
Operator,
RuntimeKwargPairs,
RuntimeKwargPairsInput,
RuntimeKwargs,
RuntimeKwargsInput,
SpreadOperator,
is_aggregate_key,
is_dynamic_expression,
)
from django_components.expression import process_aggregate_kwargs
from django_components.util.tag_parser import TagAttr, TagValue, parse_tag
class ParsedTag(NamedTuple):
id: str
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):
@dataclass
class TagSpec:
"""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 name. E.g. `"slot"` means the tag is written like so `{% slot ... %}`"""
end_tag: Optional[str] = None
@ -50,34 +26,6 @@ class TagSpec(NamedTuple):
E.g. `"endslot"` means anything between the start tag and `{% endslot %}`
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
"""
List of allowed flags.
@ -87,9 +35,107 @@ class TagSpec(NamedTuple):
- 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:
""""""
"""
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:
fn._tag_spec = tag_spec # type: ignore[attr-defined]
@ -103,43 +149,89 @@ def with_tag_spec(tag_spec: TagSpec) -> Callable:
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(
parser: Parser,
token: Token,
tag_spec: TagSpec,
tag_id: str,
) -> 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)
_, attrs = parse_tag(token.contents)
_, attrs = parse_tag(token.contents, parser)
# First token is tag name, e.g. `slot` in `{% slot <name> ... %}`
tag_name_attr = attrs.pop(0)
@ -155,287 +247,99 @@ def _parse_tag_preprocess(
# Otherwise, depending on the tag spec, the tag may be:
# 2. Block tag - With corresponding end tag, e.g. `{% endslot %}`
# 3. Inlined tag - Without the end tag.
last_token = attrs[-1].serialize(omit_key=True) if len(attrs) else None
if last_token == "/":
last_token = attrs[-1].value if len(attrs) else None
if last_token and last_token.serialize() == "/":
attrs.pop()
is_inline = True
else:
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_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)
def _parse_tag_body(parser: Parser, end_tag: str, inline: bool) -> NodeList:
if inline:
body = NodeList()
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}")
body = parser.parse(parse_until=[end_tag])
parser.delete_first_token()
return body
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),
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(),
is_inline=is_inline,
)
def _parse_tag_body(parser: Parser, end_tag: str, inline: bool) -> NodeList:
if inline:
body = NodeList()
else:
body = parser.parse(parse_until=[end_tag])
parser.delete_first_token()
return body
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:
@ -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
# 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 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 `%}`
# 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

View file

@ -128,7 +128,7 @@ class HtmlAttrsTests(BaseTestCase):
template = Template(self.template_str)
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"}))
@ -247,6 +247,12 @@ class HtmlAttrsTests(BaseTestCase):
)
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):
@register("test")
class AttrsComponent(Component):
@ -262,7 +268,9 @@ class HtmlAttrsTests(BaseTestCase):
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"}))
def test_tag_raises_on_aggregate_and_positional_args_for_defaults(self):
@ -270,7 +278,14 @@ class HtmlAttrsTests(BaseTestCase):
class AttrsComponent(Component):
template: types.django_html = """
{% 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
</div>
""" # noqa: E501

View file

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

View file

@ -265,7 +265,7 @@ class RenderDependenciesTests(BaseTestCase):
self.assertInHTML(
"""
<body>
Variable: <strong data-djc-id-a1bc41>foo</strong>
Variable: <strong data-djc-id-a1bc3f>foo</strong>
<style>.xyz { color: red; }</style>
<link href="style.css" media="all" rel="stylesheet">
@ -510,7 +510,7 @@ class MiddlewareTests(BaseTestCase):
assert_dependencies(rendered1)
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,
)
@ -520,7 +520,7 @@ class MiddlewareTests(BaseTestCase):
)
assert_dependencies(rendered2)
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,
)
@ -531,6 +531,6 @@ class MiddlewareTests(BaseTestCase):
assert_dependencies(rendered3)
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,
)

View file

@ -6,7 +6,7 @@ from django.template import Context, Template, TemplateSyntaxError
from django.template.base import FilterExpression, Node, Parser, Token
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 .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"
class DynamicExprTests(BaseTestCase):
def test_variable_resolve_dynamic_expr(self):
@ -729,7 +697,7 @@ class SpreadOperatorTests(BaseTestCase):
)
@parametrize_context_behavior(["django", "isolated"])
def test_later_spreads_overwrite_earlier(self):
def test_later_spreads_do_not_overwrite_earlier(self):
@register("test")
class SimpleComponent(Component):
def get_context_data(
@ -748,7 +716,20 @@ class SpreadOperatorTests(BaseTestCase):
<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 %}
{% component 'test'
@ -762,52 +743,44 @@ class SpreadOperatorTests(BaseTestCase):
)
)
template = Template(template_str)
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}],
}
),
)
template1 = Template(template_str1)
self.assertHTMLEqual(
rendered,
"""
<div data-djc-id-a1bc3f>{'@click': '() =&gt; {}', 'style': 'OVERWRITTEN'}</div>
<div data-djc-id-a1bc3f>[1, 2, 3]</div>
<div data-djc-id-a1bc3f>1</div>
<div data-djc-id-a1bc3f>OVERWRITTEN_X</div>
""",
)
with self.assertRaisesMessage(
TypeError,
"got multiple values for keyword argument 'x'",
):
template1.render(context)
@parametrize_context_behavior(["django", "isolated"])
def test_raises_if_positional_arg_after_spread(self):
@register("test")
class SimpleComponent(Component):
pass
template_str: types.django_html = (
# But, similarly to python, we can merge multiple **kwargs by instead
# merging them into a single dict, and spreading that.
template_str2: types.django_html = (
"""
{% load component_tags %}
{% component 'test'
...my_dict
var_a
..."{{ list|first }}"
x=123
...{
**my_dict,
"x": 123,
**"{{ list|first }}",
}
attrs:style="OVERWRITTEN"
/ %}
""".replace(
"\n", " "
)
)
with self.assertRaisesMessage(TemplateSyntaxError, "'component' received unknown flag 'var_a'"):
Template(template_str)
template2 = Template(template_str2)
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"])
def test_raises_on_missing_value(self):
@ -830,6 +803,49 @@ class SpreadOperatorTests(BaseTestCase):
with self.assertRaisesMessage(TemplateSyntaxError, "Spread syntax '...' is missing a value"):
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"])
def test_raises_on_non_dict(self):
@register("test")
@ -840,7 +856,6 @@ class SpreadOperatorTests(BaseTestCase):
"""
{% load component_tags %}
{% component 'test'
var_a
...var_b
/ %}
""".replace(
@ -851,25 +866,63 @@ class SpreadOperatorTests(BaseTestCase):
template = Template(template_str)
# List
with self.assertRaisesMessage(
RuntimeError, "Spread operator expression must resolve to a Dict, got [1, 2, 3]"
):
template.render(
Context(
{
"var_a": "abc",
"var_b": [1, 2, 3],
}
)
)
with self.assertRaisesMessage(ValueError, "Cannot spread non-iterable value: '...var_b' resolved to 123"):
template.render(Context({"var_b": 123}))
# String
with self.assertRaisesMessage(RuntimeError, "Spread operator expression must resolve to a Dict, got def"):
template.render(
Context(
{
"var_a": "abc",
"var_b": "def",
}
)
)
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,
(
(),
{
"attrs": {
"@click.stop": "dispatch('click_event')",
"x-data": "{hello: 'world'}",
"class": "padding-top-8",
":placeholder": "No text",
},
"my_dict": {"one": 2},
"three": 4,
},
),
)
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)

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" %}
{% 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({}))
@parametrize_context_behavior(["django", "isolated"])