mirror of
https://github.com/django-components/django-components.git
synced 2025-08-17 12:40:15 +00:00
feat: Literal dicts and lists part 2 (#902)
This commit is contained in:
parent
d3c5c535e0
commit
8cd4b03286
19 changed files with 1329 additions and 979 deletions
|
@ -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} %}}"
|
||||
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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({})
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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}'")
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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': '() => {}', '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': '() => {}', '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
|
@ -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)
|
|
@ -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"])
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue