mirror of
https://github.com/django-components/django-components.git
synced 2025-08-18 13:10:13 +00:00
refactor: move the utils from component_tags to util.template_tag (#899)
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
parent
a79b24b692
commit
ba0f9e65e9
4 changed files with 584 additions and 576 deletions
|
@ -49,8 +49,8 @@ from django.urls import URLPattern, URLResolver
|
||||||
|
|
||||||
from django_components import ComponentVars, TagFormatterABC
|
from django_components import ComponentVars, TagFormatterABC
|
||||||
from django_components.component import Component
|
from django_components.component import Component
|
||||||
from django_components.templatetags.component_tags import TagSpec
|
|
||||||
from django_components.util.misc import get_import_path
|
from django_components.util.misc import get_import_path
|
||||||
|
from django_components.util.template_tag import TagSpec
|
||||||
|
|
||||||
# NOTE: This file is an entrypoint for the `gen-files` plugin in `mkdocs.yml`.
|
# NOTE: This file is an entrypoint for the `gen-files` plugin in `mkdocs.yml`.
|
||||||
# However, `gen-files` plugin runs this file as a script, NOT as a module.
|
# However, `gen-files` plugin runs this file as a script, NOT as a module.
|
||||||
|
|
|
@ -4,18 +4,17 @@
|
||||||
# `from django_components.templatetags.component_tags import slot`
|
# `from django_components.templatetags.component_tags import slot`
|
||||||
#
|
#
|
||||||
# - All tags are defined using `@register.tag`. Do NOT use `@register.simple_tag`.
|
# - All tags are defined using `@register.tag`. Do NOT use `@register.simple_tag`.
|
||||||
# The reason for this is so that we use `TagSpec` and `_parse_tag`. When generating
|
# The reason for this is so that we use `TagSpec` and `parse_template_tag`. When generating
|
||||||
# documentation, we extract the `TagSpecs` to be able to describe each tag's function signature.
|
# documentation, we extract the `TagSpecs` to be able to describe each tag's function signature.
|
||||||
#
|
#
|
||||||
# - Use `with_tag_spec` for defining `TagSpecs`. This will make it available to the function
|
# - Use `with_tag_spec` for defining `TagSpecs`. This will make it available to the function
|
||||||
# as the last argument, and will also set the `TagSpec` instance to `fn._tag_spec`.
|
# as the last argument, and will also set the `TagSpec` instance to `fn._tag_spec`.
|
||||||
# During documentation generation, we access the `fn._tag_spec`.
|
# During documentation generation, we access the `fn._tag_spec`.
|
||||||
|
|
||||||
import functools
|
from typing import Literal
|
||||||
from typing import Any, Callable, Dict, List, Literal, NamedTuple, Optional, Set, Tuple, Union, cast
|
|
||||||
|
|
||||||
import django.template
|
import django.template
|
||||||
from django.template.base import FilterExpression, NodeList, Parser, TextNode, Token, TokenType
|
from django.template.base import Parser, TextNode, Token
|
||||||
from django.template.exceptions import TemplateSyntaxError
|
from django.template.exceptions import TemplateSyntaxError
|
||||||
from django.utils.safestring import SafeString, mark_safe
|
from django.utils.safestring import SafeString, mark_safe
|
||||||
|
|
||||||
|
@ -23,18 +22,6 @@ from django_components.attributes import HTML_ATTRS_ATTRS_KEY, HTML_ATTRS_DEFAUL
|
||||||
from django_components.component import COMP_ONLY_FLAG, ComponentNode
|
from django_components.component import COMP_ONLY_FLAG, ComponentNode
|
||||||
from django_components.component_registry import ComponentRegistry
|
from django_components.component_registry import ComponentRegistry
|
||||||
from django_components.dependencies import CSS_DEPENDENCY_PLACEHOLDER, JS_DEPENDENCY_PLACEHOLDER
|
from django_components.dependencies import CSS_DEPENDENCY_PLACEHOLDER, JS_DEPENDENCY_PLACEHOLDER
|
||||||
from django_components.expression import (
|
|
||||||
DynamicFilterExpression,
|
|
||||||
Expression,
|
|
||||||
Operator,
|
|
||||||
RuntimeKwargPairs,
|
|
||||||
RuntimeKwargPairsInput,
|
|
||||||
RuntimeKwargs,
|
|
||||||
RuntimeKwargsInput,
|
|
||||||
SpreadOperator,
|
|
||||||
is_aggregate_key,
|
|
||||||
is_dynamic_expression,
|
|
||||||
)
|
|
||||||
from django_components.provide import PROVIDE_NAME_KWARG, ProvideNode
|
from django_components.provide import PROVIDE_NAME_KWARG, ProvideNode
|
||||||
from django_components.slots import (
|
from django_components.slots import (
|
||||||
SLOT_DATA_KWARG,
|
SLOT_DATA_KWARG,
|
||||||
|
@ -48,78 +35,13 @@ from django_components.slots import (
|
||||||
from django_components.tag_formatter import get_tag_formatter
|
from django_components.tag_formatter import get_tag_formatter
|
||||||
from django_components.util.logger import trace_msg
|
from django_components.util.logger import trace_msg
|
||||||
from django_components.util.misc import gen_id
|
from django_components.util.misc import gen_id
|
||||||
from django_components.util.tag_parser import TagAttr, TagValue, parse_tag
|
from django_components.util.template_tag import TagSpec, fix_nested_tags, parse_template_tag, with_tag_spec
|
||||||
|
|
||||||
# NOTE: Variable name `register` is required by Django to recognize this as a template tag library
|
# NOTE: Variable name `register` is required by Django to recognize this as a template tag library
|
||||||
# See https://docs.djangoproject.com/en/dev/howto/custom-template-tags
|
# See https://docs.djangoproject.com/en/dev/howto/custom-template-tags
|
||||||
register = django.template.Library()
|
register = django.template.Library()
|
||||||
|
|
||||||
|
|
||||||
class TagSpec(NamedTuple):
|
|
||||||
"""Definition of args, kwargs, flags, etc, for a template tag."""
|
|
||||||
|
|
||||||
tag: str
|
|
||||||
"""Tag name. E.g. `"slot"` means the tag is written like so `{% slot ... %}`"""
|
|
||||||
end_tag: Optional[str] = None
|
|
||||||
"""
|
|
||||||
End tag.
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
Flags are like kwargs, but without the value part. E.g. in `{% mytag only required %}`:
|
|
||||||
- `only` and `required` are treated as `only=True` and `required=True` if present
|
|
||||||
- and treated as `only=False` and `required=False` if omitted
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def with_tag_spec(tag_spec: TagSpec) -> Callable:
|
|
||||||
""""""
|
|
||||||
|
|
||||||
def decorator(fn: Callable) -> Any:
|
|
||||||
fn._tag_spec = tag_spec # type: ignore[attr-defined]
|
|
||||||
|
|
||||||
@functools.wraps(fn)
|
|
||||||
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
||||||
return fn(*args, **kwargs, tag_spec=tag_spec)
|
|
||||||
|
|
||||||
return wrapper
|
|
||||||
|
|
||||||
return decorator
|
|
||||||
|
|
||||||
|
|
||||||
def _component_dependencies(type: Literal["js", "css"]) -> SafeString:
|
def _component_dependencies(type: Literal["js", "css"]) -> SafeString:
|
||||||
"""Marks location where CSS link and JS script tags should be rendered."""
|
"""Marks location where CSS link and JS script tags should be rendered."""
|
||||||
if type == "css":
|
if type == "css":
|
||||||
|
@ -156,7 +78,7 @@ def component_css_dependencies(parser: Parser, token: Token, tag_spec: TagSpec)
|
||||||
"""
|
"""
|
||||||
# Parse to check that the syntax is valid
|
# Parse to check that the syntax is valid
|
||||||
tag_id = gen_id()
|
tag_id = gen_id()
|
||||||
_parse_tag(parser, token, tag_spec, tag_id)
|
parse_template_tag(parser, token, tag_spec, tag_id)
|
||||||
return _component_dependencies("css")
|
return _component_dependencies("css")
|
||||||
|
|
||||||
|
|
||||||
|
@ -182,7 +104,7 @@ def component_js_dependencies(parser: Parser, token: Token, tag_spec: TagSpec) -
|
||||||
"""
|
"""
|
||||||
# Parse to check that the syntax is valid
|
# Parse to check that the syntax is valid
|
||||||
tag_id = gen_id()
|
tag_id = gen_id()
|
||||||
_parse_tag(parser, token, tag_spec, tag_id)
|
parse_template_tag(parser, token, tag_spec, tag_id)
|
||||||
return _component_dependencies("js")
|
return _component_dependencies("js")
|
||||||
|
|
||||||
|
|
||||||
|
@ -322,7 +244,7 @@ def slot(parser: Parser, token: Token, tag_spec: TagSpec) -> SlotNode:
|
||||||
```
|
```
|
||||||
"""
|
"""
|
||||||
tag_id = gen_id()
|
tag_id = gen_id()
|
||||||
tag = _parse_tag(parser, token, tag_spec, tag_id=tag_id)
|
tag = parse_template_tag(parser, token, tag_spec, tag_id=tag_id)
|
||||||
|
|
||||||
slot_name_kwarg = tag.kwargs.kwargs.get(SLOT_NAME_KWARG, None)
|
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_id = f"slot-id-{tag.id} ({slot_name_kwarg})" if slot_name_kwarg else f"slot-id-{tag.id}"
|
||||||
|
@ -444,7 +366,7 @@ def fill(parser: Parser, token: Token, tag_spec: TagSpec) -> FillNode:
|
||||||
```
|
```
|
||||||
"""
|
"""
|
||||||
tag_id = gen_id()
|
tag_id = gen_id()
|
||||||
tag = _parse_tag(parser, token, tag_spec, tag_id=tag_id)
|
tag = parse_template_tag(parser, token, tag_spec, tag_id=tag_id)
|
||||||
|
|
||||||
fill_name_kwarg = tag.kwargs.kwargs.get(SLOT_NAME_KWARG, None)
|
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_id = f"fill-id-{tag.id} ({fill_name_kwarg})" if fill_name_kwarg else f"fill-id-{tag.id}"
|
||||||
|
@ -579,7 +501,7 @@ def component(
|
||||||
"""
|
"""
|
||||||
tag_id = gen_id()
|
tag_id = gen_id()
|
||||||
|
|
||||||
_fix_nested_tags(parser, token)
|
fix_nested_tags(parser, token)
|
||||||
bits = token.split_contents()
|
bits = token.split_contents()
|
||||||
|
|
||||||
# Let the TagFormatter pre-process the tokens
|
# Let the TagFormatter pre-process the tokens
|
||||||
|
@ -591,7 +513,7 @@ def component(
|
||||||
bits = [bits[0], *result.tokens]
|
bits = [bits[0], *result.tokens]
|
||||||
token.contents = " ".join(bits)
|
token.contents = " ".join(bits)
|
||||||
|
|
||||||
tag = _parse_tag(
|
tag = parse_template_tag(
|
||||||
parser,
|
parser,
|
||||||
token,
|
token,
|
||||||
TagSpec(
|
TagSpec(
|
||||||
|
@ -709,7 +631,7 @@ def provide(parser: Parser, token: Token, tag_spec: TagSpec) -> ProvideNode:
|
||||||
tag_id = gen_id()
|
tag_id = gen_id()
|
||||||
|
|
||||||
# e.g. {% provide <name> key=val key2=val2 %}
|
# e.g. {% provide <name> key=val key2=val2 %}
|
||||||
tag = _parse_tag(parser, token, tag_spec, tag_id)
|
tag = parse_template_tag(parser, token, tag_spec, tag_id)
|
||||||
|
|
||||||
name_kwarg = tag.kwargs.kwargs.get(PROVIDE_NAME_KWARG, None)
|
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_id = f"provide-id-{tag.id} ({name_kwarg})" if name_kwarg else f"fill-id-{tag.id}"
|
||||||
|
@ -794,7 +716,7 @@ def html_attrs(parser: Parser, token: Token, tag_spec: TagSpec) -> HtmlAttrsNode
|
||||||
[HTML attributes](../../concepts/fundamentals/html_attributes#examples-for-html_attrs).**
|
[HTML attributes](../../concepts/fundamentals/html_attributes#examples-for-html_attrs).**
|
||||||
"""
|
"""
|
||||||
tag_id = gen_id()
|
tag_id = gen_id()
|
||||||
tag = _parse_tag(parser, token, tag_spec, tag_id)
|
tag = parse_template_tag(parser, token, tag_spec, tag_id)
|
||||||
|
|
||||||
return HtmlAttrsNode(
|
return HtmlAttrsNode(
|
||||||
kwargs=tag.kwargs,
|
kwargs=tag.kwargs,
|
||||||
|
@ -802,488 +724,6 @@ def html_attrs(parser: Parser, token: Token, tag_spec: TagSpec) -> HtmlAttrsNode
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_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)
|
|
||||||
|
|
||||||
# First token is tag name, e.g. `slot` in `{% slot <name> ... %}`
|
|
||||||
tag_name_attr = attrs.pop(0)
|
|
||||||
tag_name = tag_name_attr.serialize(omit_key=True)
|
|
||||||
|
|
||||||
# Sanity check
|
|
||||||
if tag_name != tag_spec.tag:
|
|
||||||
raise TemplateSyntaxError(f"Start tag parser received tag '{tag_name}', expected '{tag_spec.tag}'")
|
|
||||||
|
|
||||||
# There's 3 ways how we tell when a tag ends:
|
|
||||||
# 1. If the tag contains `/` at the end, it's a self-closing tag (like `<div />`),
|
|
||||||
# and it doesn't have an end tag. In this case we strip the trailing slash.
|
|
||||||
# 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 == "/":
|
|
||||||
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)
|
|
||||||
|
|
||||||
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)
|
|
||||||
else:
|
|
||||||
seen_regular_kwargs.add(kwarg.key)
|
|
||||||
|
|
||||||
for raw_kwarg in raw_kwargs:
|
|
||||||
check_kwarg_for_agg_conflict(raw_kwarg)
|
|
||||||
|
|
||||||
# Params that may be passed as positional args
|
|
||||||
pos_params: List[TagArg] = [
|
|
||||||
*[TagArg(name=name, positional_only=True) for name in (tag_spec.positional_only_args or [])],
|
|
||||||
*[TagArg(name=name, positional_only=False) for name in (tag_spec.pos_or_keyword_args or [])],
|
|
||||||
]
|
|
||||||
|
|
||||||
args: List[Expression] = []
|
|
||||||
# For convenience, allow to access named args by their name instead of index
|
|
||||||
named_args: Dict[str, Expression] = {}
|
|
||||||
kwarg_pairs: RuntimeKwargPairsInput = []
|
|
||||||
flags = set()
|
|
||||||
# When we come across a flag within positional args, we need to remember
|
|
||||||
# the offset, so we can correctly assign the args to the correct params
|
|
||||||
flag_offset = 0
|
|
||||||
|
|
||||||
for index, arg_input in enumerate(raw_args):
|
|
||||||
# Flags may be anywhere, so we need to check if the arg is a flag
|
|
||||||
if tag_spec.flags and arg_input in tag_spec.flags:
|
|
||||||
if arg_input in raw_flags or arg_input in flags:
|
|
||||||
raise TemplateSyntaxError(f"'{tag_name}' received flag '{arg_input}' multiple times")
|
|
||||||
flags.add(arg_input)
|
|
||||||
flag_offset += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Allow to use dynamic expressions as args, e.g. `"{{ }}"`
|
|
||||||
if is_dynamic_expression(arg_input):
|
|
||||||
arg = DynamicFilterExpression(parser, arg_input)
|
|
||||||
else:
|
|
||||||
arg = FilterExpression(arg_input, parser)
|
|
||||||
|
|
||||||
if (index - flag_offset) >= len(pos_params):
|
|
||||||
if tag_spec.positional_args_allow_extra:
|
|
||||||
args.append(arg)
|
|
||||||
continue
|
|
||||||
else:
|
|
||||||
# Allow only as many positional args as given
|
|
||||||
raise TemplateSyntaxError(
|
|
||||||
f"Tag '{tag_name}' received too many positional arguments: {raw_args[index:]}"
|
|
||||||
)
|
|
||||||
|
|
||||||
param = pos_params[index - flag_offset]
|
|
||||||
if param.positional_only:
|
|
||||||
args.append(arg)
|
|
||||||
named_args[param.name] = arg
|
|
||||||
else:
|
|
||||||
kwarg = TagKwarg(type="kwarg", key=param.name, inner_key=None, value=arg_input)
|
|
||||||
check_kwarg_for_agg_conflict(kwarg)
|
|
||||||
if param.name in seen_kwargs:
|
|
||||||
raise TemplateSyntaxError(
|
|
||||||
f"'{tag_name}' received argument '{param.name}' both as positional and keyword argument"
|
|
||||||
)
|
|
||||||
|
|
||||||
kwarg_pairs.append((param.name, arg))
|
|
||||||
|
|
||||||
if len(raw_args) - flag_offset < len(tag_spec.positional_only_args or []):
|
|
||||||
raise TemplateSyntaxError(
|
|
||||||
f"Tag '{tag_name}' received too few positional arguments. "
|
|
||||||
f"Expected {len(tag_spec.positional_only_args or [])}, got {len(raw_args) - flag_offset}"
|
|
||||||
)
|
|
||||||
|
|
||||||
for kwarg_input in raw_kwargs:
|
|
||||||
# Allow to use dynamic expressions with spread operator, e.g.
|
|
||||||
# `..."{{ }}"` or as kwargs values `key="{{ }}"`
|
|
||||||
if is_dynamic_expression(kwarg_input.value):
|
|
||||||
expr: Union[Expression, Operator] = DynamicFilterExpression(parser, kwarg_input.value)
|
|
||||||
else:
|
|
||||||
expr = FilterExpression(kwarg_input.value, parser)
|
|
||||||
|
|
||||||
if kwarg_input.type == "spread":
|
|
||||||
expr = SpreadOperator(expr)
|
|
||||||
|
|
||||||
if kwarg_input.inner_key:
|
|
||||||
full_key = f"{kwarg_input.key}:{kwarg_input.inner_key}"
|
|
||||||
else:
|
|
||||||
full_key = kwarg_input.key
|
|
||||||
|
|
||||||
kwarg_pairs.append((full_key, expr))
|
|
||||||
|
|
||||||
# Flags
|
|
||||||
flags_dict: Dict[str, bool] = {
|
|
||||||
# Base state, as defined in the tag spec
|
|
||||||
**{flag: False for flag in (tag_spec.flags or [])},
|
|
||||||
# Flags found among positional args
|
|
||||||
**{flag: True for flag in flags},
|
|
||||||
}
|
|
||||||
|
|
||||||
# Flags found among kwargs
|
|
||||||
for flag in raw_flags:
|
|
||||||
if flag in flags:
|
|
||||||
raise TemplateSyntaxError(f"'{tag_name}' received flag '{flag}' multiple times")
|
|
||||||
if flag not in (tag_spec.flags or []):
|
|
||||||
raise TemplateSyntaxError(f"'{tag_name}' received unknown flag '{flag}'")
|
|
||||||
flags.add(flag)
|
|
||||||
flags_dict[flag] = True
|
|
||||||
|
|
||||||
# Validate that there are no name conflicts between kwargs and flags
|
|
||||||
if flags.intersection(seen_kwargs):
|
|
||||||
raise TemplateSyntaxError(
|
|
||||||
f"'{tag_name}' received flags that conflict with keyword arguments: {flags.intersection(seen_kwargs)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Validate kwargs
|
|
||||||
kwargs: RuntimeKwargsInput = {}
|
|
||||||
extra_keywords: Set[str] = set()
|
|
||||||
for key, val in kwarg_pairs:
|
|
||||||
# Operators are resolved at render-time, so skip them
|
|
||||||
if isinstance(val, Operator):
|
|
||||||
kwargs[key] = val
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Check if key allowed
|
|
||||||
if not tag_spec.keywordonly_args:
|
|
||||||
is_key_allowed = False
|
|
||||||
else:
|
|
||||||
is_key_allowed = (
|
|
||||||
tag_spec.keywordonly_args == True or key in tag_spec.keywordonly_args # noqa: E712
|
|
||||||
) or bool(tag_spec.pos_or_keyword_args and key in tag_spec.pos_or_keyword_args)
|
|
||||||
if not is_key_allowed:
|
|
||||||
is_optional = key in tag_spec.optional_kwargs if tag_spec.optional_kwargs else False
|
|
||||||
if not is_optional:
|
|
||||||
extra_keywords.add(key)
|
|
||||||
|
|
||||||
# Check for repeated keys
|
|
||||||
if key in kwargs:
|
|
||||||
if not tag_spec.repeatable_kwargs:
|
|
||||||
is_key_repeatable = False
|
|
||||||
else:
|
|
||||||
is_key_repeatable = (
|
|
||||||
tag_spec.repeatable_kwargs == True or key in tag_spec.repeatable_kwargs # noqa: E712
|
|
||||||
)
|
|
||||||
if not is_key_repeatable:
|
|
||||||
# The keyword argument has already been supplied once
|
|
||||||
raise TemplateSyntaxError(f"'{tag_name}' received multiple values for keyword argument '{key}'")
|
|
||||||
# All ok
|
|
||||||
kwargs[key] = val
|
|
||||||
|
|
||||||
if len(extra_keywords):
|
|
||||||
extra_keys = ", ".join(extra_keywords)
|
|
||||||
raise TemplateSyntaxError(f"'{tag_name}' received unexpected kwargs: {extra_keys}")
|
|
||||||
|
|
||||||
return ParsedTag(
|
|
||||||
id=tag_id,
|
|
||||||
name=tag_name,
|
|
||||||
flags=flags_dict,
|
|
||||||
args=args,
|
|
||||||
named_args=named_args,
|
|
||||||
kwargs=RuntimeKwargs(kwargs),
|
|
||||||
kwarg_pairs=RuntimeKwargPairs(kwarg_pairs),
|
|
||||||
# NOTE: We defer parsing of the body, so we have the chance to call the tracing
|
|
||||||
# loggers before the parsing. This is because, if the body contains any other
|
|
||||||
# tags, it will trigger their tag handlers. So the code called AFTER
|
|
||||||
# `parse_body()` is already after all the nested tags were processed.
|
|
||||||
parse_body=lambda: _parse_tag_body(parser, tag_spec.end_tag, is_inline) if tag_spec.end_tag else NodeList(),
|
|
||||||
is_inline=is_inline,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_tag_body(parser: Parser, end_tag: str, inline: bool) -> NodeList:
|
|
||||||
if inline:
|
|
||||||
body = NodeList()
|
|
||||||
else:
|
|
||||||
body = parser.parse(parse_until=[end_tag])
|
|
||||||
parser.delete_first_token()
|
|
||||||
return body
|
|
||||||
|
|
||||||
|
|
||||||
def _fix_nested_tags(parser: Parser, block_token: Token) -> None:
|
|
||||||
# Since the nested tags MUST be wrapped in quotes, e.g.
|
|
||||||
# `{% component 'test' "{% lorem var_a w %}" %}`
|
|
||||||
# `{% component 'test' key="{% lorem var_a w %}" %}`
|
|
||||||
#
|
|
||||||
# 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)
|
|
||||||
|
|
||||||
# If there are no attributes, then there are no nested tags
|
|
||||||
if not attrs:
|
|
||||||
return
|
|
||||||
|
|
||||||
last_attr = attrs[-1]
|
|
||||||
|
|
||||||
# TODO: Currently, using a nested template inside a list or dict
|
|
||||||
# e.g. `{% component ... key=["{% nested %}"] %}` is NOT supported.
|
|
||||||
# Hence why we leave if value is not "simple" (which means the value is list or dict).
|
|
||||||
if last_attr.value.type != "simple":
|
|
||||||
return
|
|
||||||
|
|
||||||
last_attr_value = cast(TagValue, last_attr.value.entries[0])
|
|
||||||
last_token = last_attr_value.parts[-1]
|
|
||||||
|
|
||||||
# User probably forgot to wrap the nested tag in quotes, or this is the end of the input.
|
|
||||||
# `{% component ... key={% nested %} %}`
|
|
||||||
# `{% component ... key= %}`
|
|
||||||
if not last_token.value:
|
|
||||||
return
|
|
||||||
|
|
||||||
# When our template tag contains a nested tag, e.g.:
|
|
||||||
# `{% component 'test' "{% lorem var_a w %}" %}`
|
|
||||||
#
|
|
||||||
# Django parses this into:
|
|
||||||
# `TokenType.BLOCK: 'component 'test' "{% lorem var_a w'`
|
|
||||||
#
|
|
||||||
# Above you can see that the token ends at the end of the NESTED tag,
|
|
||||||
# and includes `{%`. So that's what we use to identify if we need to fix
|
|
||||||
# nested tags or not.
|
|
||||||
has_unclosed_tag = (
|
|
||||||
(last_token.value.count("{%") > last_token.value.count("%}"))
|
|
||||||
# Moreover we need to also check for unclosed quotes for this edge case:
|
|
||||||
# `{% component 'test' "{%}" %}`
|
|
||||||
#
|
|
||||||
# Which Django parses this into:
|
|
||||||
# `TokenType.BLOCK: 'component 'test' "{'`
|
|
||||||
#
|
|
||||||
# Here we cannot see any unclosed tags, but there is an unclosed double quote at the end.
|
|
||||||
#
|
|
||||||
# But we cannot naively search the full contents for unclosed quotes, but
|
|
||||||
# only within the last 'bit'. Consider this:
|
|
||||||
# `{% component 'test' '"' "{%}" %}`
|
|
||||||
#
|
|
||||||
or (last_token.value in ("'{", '"{'))
|
|
||||||
)
|
|
||||||
|
|
||||||
# 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 ('"', "'")
|
|
||||||
|
|
||||||
needs_fixing = has_unclosed_tag and has_unclosed_quote
|
|
||||||
|
|
||||||
if not needs_fixing:
|
|
||||||
return
|
|
||||||
|
|
||||||
block_token.contents += "%}" if has_unclosed_quote else " %}"
|
|
||||||
expects_text = True
|
|
||||||
while True:
|
|
||||||
# This is where we need to take parsing in our own hands, because Django parser parsed
|
|
||||||
# only up to the first closing tag `%}`, but that closing tag corresponds to a nested tag,
|
|
||||||
# and not to the end of the outer template tag.
|
|
||||||
#
|
|
||||||
# NOTE: If we run out of tokens, this will raise, and break out of the loop
|
|
||||||
token = parser.next_token()
|
|
||||||
|
|
||||||
# If there is a nested BLOCK `{% %}`, VAR `{{ }}`, or COMMENT `{# #}` tag inside the template tag,
|
|
||||||
# then the way Django parses it results in alternating Tokens of TEXT and non-TEXT types.
|
|
||||||
#
|
|
||||||
# We use `expects_text` to know which type to handle.
|
|
||||||
if expects_text:
|
|
||||||
if token.token_type != TokenType.TEXT:
|
|
||||||
raise TemplateSyntaxError(f"Template parser received TokenType '{token.token_type}' instead of 'TEXT'")
|
|
||||||
|
|
||||||
expects_text = False
|
|
||||||
|
|
||||||
# Once we come across a closing tag in the text, we know that's our original
|
|
||||||
# end tag. Until then, append all the text to the block token and continue
|
|
||||||
if "%}" not in token.contents:
|
|
||||||
block_token.contents += token.contents
|
|
||||||
continue
|
|
||||||
|
|
||||||
# This is the ACTUAL end of the block template tag
|
|
||||||
remaining_block_content, text_content = token.contents.split("%}", 1)
|
|
||||||
block_token.contents += remaining_block_content
|
|
||||||
|
|
||||||
# We put back into the Parser the remaining bit of the text.
|
|
||||||
# NOTE: Looking at the implementation, `parser.prepend_token()` is the opposite
|
|
||||||
# of `parser.next_token()`.
|
|
||||||
parser.prepend_token(Token(TokenType.TEXT, contents=text_content))
|
|
||||||
break
|
|
||||||
|
|
||||||
# In this case we've come across a next block tag `{% %}` inside the template tag
|
|
||||||
# This isn't the first occurence, where the `{%` was ignored. And so, the content
|
|
||||||
# between the `{% %}` is correctly captured, e.g.
|
|
||||||
#
|
|
||||||
# `{% firstof False 0 is_active %}`
|
|
||||||
# gives
|
|
||||||
# `TokenType.BLOCK: 'firstof False 0 is_active'`
|
|
||||||
#
|
|
||||||
# But we don't want to evaluate this as a standalone BLOCK tag, and instead append
|
|
||||||
# it to the block tag that this nested block is part of
|
|
||||||
else:
|
|
||||||
if token.token_type == TokenType.TEXT:
|
|
||||||
raise TemplateSyntaxError(
|
|
||||||
f"Template parser received TokenType '{token.token_type}' instead of 'BLOCK', 'VAR', 'COMMENT'"
|
|
||||||
)
|
|
||||||
|
|
||||||
if token.token_type == TokenType.BLOCK:
|
|
||||||
block_token.contents += "{% " + token.contents + " %}"
|
|
||||||
elif token.token_type == TokenType.VAR:
|
|
||||||
block_token.contents += "{{ " + token.contents + " }}"
|
|
||||||
elif token.token_type == TokenType.COMMENT:
|
|
||||||
pass # Comments are ignored
|
|
||||||
else:
|
|
||||||
raise TemplateSyntaxError(f"Unknown token type '{token.token_type}'")
|
|
||||||
|
|
||||||
expects_text = True
|
|
||||||
continue
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"component",
|
"component",
|
||||||
"component_css_dependencies",
|
"component_css_dependencies",
|
||||||
|
|
568
src/django_components/util/template_tag.py
Normal file
568
src/django_components/util/template_tag.py
Normal file
|
@ -0,0 +1,568 @@
|
||||||
|
import functools
|
||||||
|
from typing import Any, Callable, Dict, List, Literal, NamedTuple, Optional, Set, Tuple, Union, cast
|
||||||
|
|
||||||
|
from django.template import 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.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):
|
||||||
|
"""Definition of args, kwargs, flags, etc, for a template tag."""
|
||||||
|
|
||||||
|
tag: str
|
||||||
|
"""Tag name. E.g. `"slot"` means the tag is written like so `{% slot ... %}`"""
|
||||||
|
end_tag: Optional[str] = None
|
||||||
|
"""
|
||||||
|
End tag.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Flags are like kwargs, but without the value part. E.g. in `{% mytag only required %}`:
|
||||||
|
- `only` and `required` are treated as `only=True` and `required=True` if present
|
||||||
|
- and treated as `only=False` and `required=False` if omitted
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def with_tag_spec(tag_spec: TagSpec) -> Callable:
|
||||||
|
""""""
|
||||||
|
|
||||||
|
def decorator(fn: Callable) -> Any:
|
||||||
|
fn._tag_spec = tag_spec # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
@functools.wraps(fn)
|
||||||
|
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||||
|
return fn(*args, **kwargs, tag_spec=tag_spec)
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
# First token is tag name, e.g. `slot` in `{% slot <name> ... %}`
|
||||||
|
tag_name_attr = attrs.pop(0)
|
||||||
|
tag_name = tag_name_attr.serialize(omit_key=True)
|
||||||
|
|
||||||
|
# Sanity check
|
||||||
|
if tag_name != tag_spec.tag:
|
||||||
|
raise TemplateSyntaxError(f"Start tag parser received tag '{tag_name}', expected '{tag_spec.tag}'")
|
||||||
|
|
||||||
|
# There's 3 ways how we tell when a tag ends:
|
||||||
|
# 1. If the tag contains `/` at the end, it's a self-closing tag (like `<div />`),
|
||||||
|
# and it doesn't have an end tag. In this case we strip the trailing slash.
|
||||||
|
# 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 == "/":
|
||||||
|
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)
|
||||||
|
|
||||||
|
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)
|
||||||
|
else:
|
||||||
|
seen_regular_kwargs.add(kwarg.key)
|
||||||
|
|
||||||
|
for raw_kwarg in raw_kwargs:
|
||||||
|
check_kwarg_for_agg_conflict(raw_kwarg)
|
||||||
|
|
||||||
|
# Params that may be passed as positional args
|
||||||
|
pos_params: List[TagArg] = [
|
||||||
|
*[TagArg(name=name, positional_only=True) for name in (tag_spec.positional_only_args or [])],
|
||||||
|
*[TagArg(name=name, positional_only=False) for name in (tag_spec.pos_or_keyword_args or [])],
|
||||||
|
]
|
||||||
|
|
||||||
|
args: List[Expression] = []
|
||||||
|
# For convenience, allow to access named args by their name instead of index
|
||||||
|
named_args: Dict[str, Expression] = {}
|
||||||
|
kwarg_pairs: RuntimeKwargPairsInput = []
|
||||||
|
flags = set()
|
||||||
|
# When we come across a flag within positional args, we need to remember
|
||||||
|
# the offset, so we can correctly assign the args to the correct params
|
||||||
|
flag_offset = 0
|
||||||
|
|
||||||
|
for index, arg_input in enumerate(raw_args):
|
||||||
|
# Flags may be anywhere, so we need to check if the arg is a flag
|
||||||
|
if tag_spec.flags and arg_input in tag_spec.flags:
|
||||||
|
if arg_input in raw_flags or arg_input in flags:
|
||||||
|
raise TemplateSyntaxError(f"'{tag_name}' received flag '{arg_input}' multiple times")
|
||||||
|
flags.add(arg_input)
|
||||||
|
flag_offset += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Allow to use dynamic expressions as args, e.g. `"{{ }}"`
|
||||||
|
if is_dynamic_expression(arg_input):
|
||||||
|
arg = DynamicFilterExpression(parser, arg_input)
|
||||||
|
else:
|
||||||
|
arg = FilterExpression(arg_input, parser)
|
||||||
|
|
||||||
|
if (index - flag_offset) >= len(pos_params):
|
||||||
|
if tag_spec.positional_args_allow_extra:
|
||||||
|
args.append(arg)
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
# Allow only as many positional args as given
|
||||||
|
raise TemplateSyntaxError(
|
||||||
|
f"Tag '{tag_name}' received too many positional arguments: {raw_args[index:]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
param = pos_params[index - flag_offset]
|
||||||
|
if param.positional_only:
|
||||||
|
args.append(arg)
|
||||||
|
named_args[param.name] = arg
|
||||||
|
else:
|
||||||
|
kwarg = TagKwarg(type="kwarg", key=param.name, inner_key=None, value=arg_input)
|
||||||
|
check_kwarg_for_agg_conflict(kwarg)
|
||||||
|
if param.name in seen_kwargs:
|
||||||
|
raise TemplateSyntaxError(
|
||||||
|
f"'{tag_name}' received argument '{param.name}' both as positional and keyword argument"
|
||||||
|
)
|
||||||
|
|
||||||
|
kwarg_pairs.append((param.name, arg))
|
||||||
|
|
||||||
|
if len(raw_args) - flag_offset < len(tag_spec.positional_only_args or []):
|
||||||
|
raise TemplateSyntaxError(
|
||||||
|
f"Tag '{tag_name}' received too few positional arguments. "
|
||||||
|
f"Expected {len(tag_spec.positional_only_args or [])}, got {len(raw_args) - flag_offset}"
|
||||||
|
)
|
||||||
|
|
||||||
|
for kwarg_input in raw_kwargs:
|
||||||
|
# Allow to use dynamic expressions with spread operator, e.g.
|
||||||
|
# `..."{{ }}"` or as kwargs values `key="{{ }}"`
|
||||||
|
if is_dynamic_expression(kwarg_input.value):
|
||||||
|
expr: Union[Expression, Operator] = DynamicFilterExpression(parser, kwarg_input.value)
|
||||||
|
else:
|
||||||
|
expr = FilterExpression(kwarg_input.value, parser)
|
||||||
|
|
||||||
|
if kwarg_input.type == "spread":
|
||||||
|
expr = SpreadOperator(expr)
|
||||||
|
|
||||||
|
if kwarg_input.inner_key:
|
||||||
|
full_key = f"{kwarg_input.key}:{kwarg_input.inner_key}"
|
||||||
|
else:
|
||||||
|
full_key = kwarg_input.key
|
||||||
|
|
||||||
|
kwarg_pairs.append((full_key, expr))
|
||||||
|
|
||||||
|
# Flags
|
||||||
|
flags_dict: Dict[str, bool] = {
|
||||||
|
# Base state, as defined in the tag spec
|
||||||
|
**{flag: False for flag in (tag_spec.flags or [])},
|
||||||
|
# Flags found among positional args
|
||||||
|
**{flag: True for flag in flags},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Flags found among kwargs
|
||||||
|
for flag in raw_flags:
|
||||||
|
if flag in flags:
|
||||||
|
raise TemplateSyntaxError(f"'{tag_name}' received flag '{flag}' multiple times")
|
||||||
|
if flag not in (tag_spec.flags or []):
|
||||||
|
raise TemplateSyntaxError(f"'{tag_name}' received unknown flag '{flag}'")
|
||||||
|
flags.add(flag)
|
||||||
|
flags_dict[flag] = True
|
||||||
|
|
||||||
|
# Validate that there are no name conflicts between kwargs and flags
|
||||||
|
if flags.intersection(seen_kwargs):
|
||||||
|
raise TemplateSyntaxError(
|
||||||
|
f"'{tag_name}' received flags that conflict with keyword arguments: {flags.intersection(seen_kwargs)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate kwargs
|
||||||
|
kwargs: RuntimeKwargsInput = {}
|
||||||
|
extra_keywords: Set[str] = set()
|
||||||
|
for key, val in kwarg_pairs:
|
||||||
|
# Operators are resolved at render-time, so skip them
|
||||||
|
if isinstance(val, Operator):
|
||||||
|
kwargs[key] = val
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if key allowed
|
||||||
|
if not tag_spec.keywordonly_args:
|
||||||
|
is_key_allowed = False
|
||||||
|
else:
|
||||||
|
is_key_allowed = (
|
||||||
|
tag_spec.keywordonly_args == True or key in tag_spec.keywordonly_args # noqa: E712
|
||||||
|
) or bool(tag_spec.pos_or_keyword_args and key in tag_spec.pos_or_keyword_args)
|
||||||
|
if not is_key_allowed:
|
||||||
|
is_optional = key in tag_spec.optional_kwargs if tag_spec.optional_kwargs else False
|
||||||
|
if not is_optional:
|
||||||
|
extra_keywords.add(key)
|
||||||
|
|
||||||
|
# Check for repeated keys
|
||||||
|
if key in kwargs:
|
||||||
|
if not tag_spec.repeatable_kwargs:
|
||||||
|
is_key_repeatable = False
|
||||||
|
else:
|
||||||
|
is_key_repeatable = (
|
||||||
|
tag_spec.repeatable_kwargs == True or key in tag_spec.repeatable_kwargs # noqa: E712
|
||||||
|
)
|
||||||
|
if not is_key_repeatable:
|
||||||
|
# The keyword argument has already been supplied once
|
||||||
|
raise TemplateSyntaxError(f"'{tag_name}' received multiple values for keyword argument '{key}'")
|
||||||
|
# All ok
|
||||||
|
kwargs[key] = val
|
||||||
|
|
||||||
|
if len(extra_keywords):
|
||||||
|
extra_keys = ", ".join(extra_keywords)
|
||||||
|
raise TemplateSyntaxError(f"'{tag_name}' received unexpected kwargs: {extra_keys}")
|
||||||
|
|
||||||
|
return ParsedTag(
|
||||||
|
id=tag_id,
|
||||||
|
name=tag_name,
|
||||||
|
flags=flags_dict,
|
||||||
|
args=args,
|
||||||
|
named_args=named_args,
|
||||||
|
kwargs=RuntimeKwargs(kwargs),
|
||||||
|
kwarg_pairs=RuntimeKwargPairs(kwarg_pairs),
|
||||||
|
# NOTE: We defer parsing of the body, so we have the chance to call the tracing
|
||||||
|
# loggers before the parsing. This is because, if the body contains any other
|
||||||
|
# tags, it will trigger their tag handlers. So the code called AFTER
|
||||||
|
# `parse_body()` is already after all the nested tags were processed.
|
||||||
|
parse_body=lambda: _parse_tag_body(parser, tag_spec.end_tag, is_inline) if tag_spec.end_tag else NodeList(),
|
||||||
|
is_inline=is_inline,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_tag_body(parser: Parser, end_tag: str, inline: bool) -> NodeList:
|
||||||
|
if inline:
|
||||||
|
body = NodeList()
|
||||||
|
else:
|
||||||
|
body = parser.parse(parse_until=[end_tag])
|
||||||
|
parser.delete_first_token()
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
def fix_nested_tags(parser: Parser, block_token: Token) -> None:
|
||||||
|
# Since the nested tags MUST be wrapped in quotes, e.g.
|
||||||
|
# `{% component 'test' "{% lorem var_a w %}" %}`
|
||||||
|
# `{% component 'test' key="{% lorem var_a w %}" %}`
|
||||||
|
#
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
# If there are no attributes, then there are no nested tags
|
||||||
|
if not attrs:
|
||||||
|
return
|
||||||
|
|
||||||
|
last_attr = attrs[-1]
|
||||||
|
|
||||||
|
# TODO: Currently, using a nested template inside a list or dict
|
||||||
|
# e.g. `{% component ... key=["{% nested %}"] %}` is NOT supported.
|
||||||
|
# Hence why we leave if value is not "simple" (which means the value is list or dict).
|
||||||
|
if last_attr.value.type != "simple":
|
||||||
|
return
|
||||||
|
|
||||||
|
last_attr_value = cast(TagValue, last_attr.value.entries[0])
|
||||||
|
last_token = last_attr_value.parts[-1]
|
||||||
|
|
||||||
|
# User probably forgot to wrap the nested tag in quotes, or this is the end of the input.
|
||||||
|
# `{% component ... key={% nested %} %}`
|
||||||
|
# `{% component ... key= %}`
|
||||||
|
if not last_token.value:
|
||||||
|
return
|
||||||
|
|
||||||
|
# When our template tag contains a nested tag, e.g.:
|
||||||
|
# `{% component 'test' "{% lorem var_a w %}" %}`
|
||||||
|
#
|
||||||
|
# Django parses this into:
|
||||||
|
# `TokenType.BLOCK: 'component 'test' "{% lorem var_a w'`
|
||||||
|
#
|
||||||
|
# Above you can see that the token ends at the end of the NESTED tag,
|
||||||
|
# and includes `{%`. So that's what we use to identify if we need to fix
|
||||||
|
# nested tags or not.
|
||||||
|
has_unclosed_tag = (
|
||||||
|
(last_token.value.count("{%") > last_token.value.count("%}"))
|
||||||
|
# Moreover we need to also check for unclosed quotes for this edge case:
|
||||||
|
# `{% component 'test' "{%}" %}`
|
||||||
|
#
|
||||||
|
# Which Django parses this into:
|
||||||
|
# `TokenType.BLOCK: 'component 'test' "{'`
|
||||||
|
#
|
||||||
|
# Here we cannot see any unclosed tags, but there is an unclosed double quote at the end.
|
||||||
|
#
|
||||||
|
# But we cannot naively search the full contents for unclosed quotes, but
|
||||||
|
# only within the last 'bit'. Consider this:
|
||||||
|
# `{% component 'test' '"' "{%}" %}`
|
||||||
|
#
|
||||||
|
or (last_token.value in ("'{", '"{'))
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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 ('"', "'")
|
||||||
|
|
||||||
|
needs_fixing = has_unclosed_tag and has_unclosed_quote
|
||||||
|
|
||||||
|
if not needs_fixing:
|
||||||
|
return
|
||||||
|
|
||||||
|
block_token.contents += "%}" if has_unclosed_quote else " %}"
|
||||||
|
expects_text = True
|
||||||
|
while True:
|
||||||
|
# This is where we need to take parsing in our own hands, because Django parser parsed
|
||||||
|
# only up to the first closing tag `%}`, but that closing tag corresponds to a nested tag,
|
||||||
|
# and not to the end of the outer template tag.
|
||||||
|
#
|
||||||
|
# NOTE: If we run out of tokens, this will raise, and break out of the loop
|
||||||
|
token = parser.next_token()
|
||||||
|
|
||||||
|
# If there is a nested BLOCK `{% %}`, VAR `{{ }}`, or COMMENT `{# #}` tag inside the template tag,
|
||||||
|
# then the way Django parses it results in alternating Tokens of TEXT and non-TEXT types.
|
||||||
|
#
|
||||||
|
# We use `expects_text` to know which type to handle.
|
||||||
|
if expects_text:
|
||||||
|
if token.token_type != TokenType.TEXT:
|
||||||
|
raise TemplateSyntaxError(f"Template parser received TokenType '{token.token_type}' instead of 'TEXT'")
|
||||||
|
|
||||||
|
expects_text = False
|
||||||
|
|
||||||
|
# Once we come across a closing tag in the text, we know that's our original
|
||||||
|
# end tag. Until then, append all the text to the block token and continue
|
||||||
|
if "%}" not in token.contents:
|
||||||
|
block_token.contents += token.contents
|
||||||
|
continue
|
||||||
|
|
||||||
|
# This is the ACTUAL end of the block template tag
|
||||||
|
remaining_block_content, text_content = token.contents.split("%}", 1)
|
||||||
|
block_token.contents += remaining_block_content
|
||||||
|
|
||||||
|
# We put back into the Parser the remaining bit of the text.
|
||||||
|
# NOTE: Looking at the implementation, `parser.prepend_token()` is the opposite
|
||||||
|
# of `parser.next_token()`.
|
||||||
|
parser.prepend_token(Token(TokenType.TEXT, contents=text_content))
|
||||||
|
break
|
||||||
|
|
||||||
|
# In this case we've come across a next block tag `{% %}` inside the template tag
|
||||||
|
# This isn't the first occurence, where the `{%` was ignored. And so, the content
|
||||||
|
# between the `{% %}` is correctly captured, e.g.
|
||||||
|
#
|
||||||
|
# `{% firstof False 0 is_active %}`
|
||||||
|
# gives
|
||||||
|
# `TokenType.BLOCK: 'firstof False 0 is_active'`
|
||||||
|
#
|
||||||
|
# But we don't want to evaluate this as a standalone BLOCK tag, and instead append
|
||||||
|
# it to the block tag that this nested block is part of
|
||||||
|
else:
|
||||||
|
if token.token_type == TokenType.TEXT:
|
||||||
|
raise TemplateSyntaxError(
|
||||||
|
f"Template parser received TokenType '{token.token_type}' instead of 'BLOCK', 'VAR', 'COMMENT'"
|
||||||
|
)
|
||||||
|
|
||||||
|
if token.token_type == TokenType.BLOCK:
|
||||||
|
block_token.contents += "{% " + token.contents + " %}"
|
||||||
|
elif token.token_type == TokenType.VAR:
|
||||||
|
block_token.contents += "{{ " + token.contents + " }}"
|
||||||
|
elif token.token_type == TokenType.COMMENT:
|
||||||
|
pass # Comments are ignored
|
||||||
|
else:
|
||||||
|
raise TemplateSyntaxError(f"Unknown token type '{token.token_type}'")
|
||||||
|
|
||||||
|
expects_text = True
|
||||||
|
continue
|
|
@ -8,7 +8,7 @@ from django_components.expression import (
|
||||||
safe_resolve_dict,
|
safe_resolve_dict,
|
||||||
safe_resolve_list,
|
safe_resolve_list,
|
||||||
)
|
)
|
||||||
from django_components.templatetags.component_tags import TagSpec, _parse_tag
|
from django_components.util.template_tag import TagSpec, parse_template_tag
|
||||||
|
|
||||||
from .django_test_setup import setup_test_config
|
from .django_test_setup import setup_test_config
|
||||||
from .testutils import BaseTestCase, parametrize_context_behavior
|
from .testutils import BaseTestCase, parametrize_context_behavior
|
||||||
|
@ -26,7 +26,7 @@ class ParserTest(BaseTestCase):
|
||||||
pos_or_keyword_args=["num", "var"],
|
pos_or_keyword_args=["num", "var"],
|
||||||
keywordonly_args=True,
|
keywordonly_args=True,
|
||||||
)
|
)
|
||||||
tag = _parse_tag(parser, parser.tokens[0], tag_spec=spec, tag_id="my-id")
|
tag = parse_template_tag(parser, parser.tokens[0], tag_spec=spec, tag_id="my-id")
|
||||||
|
|
||||||
ctx = {"myvar": {"a": "b"}, "val2": 1}
|
ctx = {"myvar": {"a": "b"}, "val2": 1}
|
||||||
args = safe_resolve_list(ctx, tag.args)
|
args = safe_resolve_list(ctx, tag.args)
|
||||||
|
@ -42,7 +42,7 @@ class ParserTest(BaseTestCase):
|
||||||
tokens = Lexer(template_str).tokenize()
|
tokens = Lexer(template_str).tokenize()
|
||||||
parser = Parser(tokens=tokens)
|
parser = Parser(tokens=tokens)
|
||||||
spec = TagSpec(tag="component", keywordonly_args=True)
|
spec = TagSpec(tag="component", keywordonly_args=True)
|
||||||
tag = _parse_tag(parser, parser.tokens[0], tag_spec=spec, tag_id="my-id")
|
tag = parse_template_tag(parser, parser.tokens[0], tag_spec=spec, tag_id="my-id")
|
||||||
|
|
||||||
ctx = Context({"date": 2024, "bzz": "fzz"})
|
ctx = Context({"date": 2024, "bzz": "fzz"})
|
||||||
args = safe_resolve_list(ctx, tag.args)
|
args = safe_resolve_list(ctx, tag.args)
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue