feat: add dynamic expressions (#605)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
Juro Oravec 2024-08-25 12:53:40 +02:00 committed by GitHub
parent fc5ea78739
commit 39cff5a1d0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 764 additions and 48 deletions

103
README.md
View file

@ -65,6 +65,10 @@ And this is what gets rendered (plus the CSS and Javascript you've specified):
## Release notes
**Version 0.93**
- Spread operator `...dict` inside template tags. See [Spread operator](#spread-operator))
- Use template tags inside string literals in component inputs. See [Use template tags inside component inputs](#use-template-tags-inside-component-inputs))
🚨📢 **Version 0.92**
- BREAKING CHANGE: `Component` class is no longer a subclass of `View`. To configure the `View` class, set the `Component.View` nested class. HTTP methods like `get` or `post` can still be defined directly on `Component` class, and `Component.as_view()` internally calls `Component.View.as_view()`. (See [Modifying the View class](#modifying-the-view-class))
@ -1927,6 +1931,105 @@ Other than that, you can use spread operators multiple times, and even put keywo
In a case of conflicts, the values added later (right-most) overwrite previous values.
### Use template tags inside component inputs
_New in version 0.93_
When passing data around, sometimes you may need to do light transformations, like negating booleans or filtering lists.
Normally, what you would have to do is to define ALL the variables
inside `get_context_data()`. But this can get messy if your components contain a lot of logic.
```py
@register("calendar")
class Calendar(Component):
def get_context_data(self, id: str, editable: bool):
return {
"editable": editable,
"readonly": not editable,
"input_id": f"input-{id}",
"icon_id": f"icon-{id}",
...
}
```
Instead, template tags in django_components (`{% component %}`, `{% slot %}`, `{% provide %}`, etc) allow you to treat literal string values as templates:
```django
{% component 'blog_post'
"As positional arg {# yay #}"
title="{{ person.first_name }} {{ person.last_name }}"
id="{% random_int 10 20 %}"
author="John Wick {# TODO: parametrize #}"
/ %}
```
In the example above:
- Component `test` receives a positional argument with value `"As positional arg "`. The comment is omitted.
- Kwarg `title` is passed as a string, e.g. `John Doe`
- Kwarg `id` is passed as `int`, e.g. `15`
- Kwarg `author` is passed as a string, e.g. `John Wick ` (Comment omitted)
This is inspired by [django-cotton](https://github.com/wrabit/django-cotton#template-expressions-in-attributes).
#### Passing data as string vs original values
Sometimes you may want to use the template tags to transform
or generate the data that is then passed to the component.
The data doesn't necessarily have to be strings. In the example above, the kwarg `id` was passed as an integer, NOT a string.
Although the string literals for components inputs are treated as regular Django templates, there is one special case:
When the string literal contains only a single template tag, with no extra text, then the value is passed as the original type instead of a string.
Here, `page` is an integer:
```django
{% component 'blog_post' page="{% random_int 10 20 %}" / %}
```
Here, `page` is a string:
```django
{% component 'blog_post' page=" {% random_int 10 20 %} " / %}
```
And same applies to the `{{ }}` variable tags:
Here, `items` is a list:
```django
{% component 'cat_list' items="{{ cats|slice:':2' }}" / %}
```
Here, `items` is a string:
```django
{% component 'cat_list' items="{{ cats|slice:':2' }} See more" / %}
```
#### Evaluating Python expressions in template
You can even go a step further and have a similar experience to Vue or React,
where you can evaluate arbitrary code expressions:
```jsx
<MyForm
value={ isEnabled ? inputValue : null }
/>
```
Similar is possible with [`django-expr`](https://pypi.org/project/django-expr/), which adds an `expr` tag and filter that you can use to evaluate Python expressions from within the template:
```django
{% component "my_form"
value="{% expr 'input_value if is_enabled else None' %}"
/ %}
```
> Note: Never use this feature to mix business logic and template logic. Business logic should still be in the template!
### Pass dictonary by its key-value pairs
_New in version 0.74_:

View file

@ -2,10 +2,10 @@ import re
from abc import ABC, abstractmethod
from typing import Any, Dict, List, Mapping, Optional, Tuple, Union
from django.template import Context, TemplateSyntaxError
from django.template.base import FilterExpression, Parser
from django.template import Context, Node, NodeList, TemplateSyntaxError
from django.template.base import FilterExpression, Lexer, Parser, VariableNode
Expression = Union[FilterExpression]
Expression = Union[FilterExpression, "DynamicFilterExpression"]
RuntimeKwargsInput = Dict[str, Union[Expression, "Operator"]]
RuntimeKwargPairsInput = List[Tuple[str, Union[Expression, "Operator"]]]
@ -30,7 +30,77 @@ class SpreadOperator(Operator):
self.expr = expr
def resolve(self, context: Context) -> Dict[str, Any]:
return self.expr.resolve(context)
data = self.expr.resolve(context)
if not isinstance(data, dict):
raise RuntimeError(f"Spread operator expression must resolve to a Dict, got {data}")
return data
class DynamicFilterExpression:
def __init__(self, parser: Parser, expr_str: str) -> None:
if not is_dynamic_expression(expr_str):
raise TemplateSyntaxError(f"Not a valid dynamic expression: '{expr_str}'")
# Drop the leading and trailing quote
self.expr = expr_str[1:-1]
# Copy the Parser, and pass through the tags and filters available
# in the current context. Thus, if user calls `{% load %}` inside
# the expression, it won't spill outside.
lexer = Lexer(self.expr)
tokens = lexer.tokenize()
expr_parser = Parser(tokens=tokens)
expr_parser.tags = {**parser.tags}
expr_parser.filters = {**parser.filters}
self.nodelist = expr_parser.parse()
def resolve(self, context: Context) -> Any:
# If the expression consists of a single node, we return the node's value
# directly, skipping stringification that would occur by rendering the node
# via nodelist.
#
# This make is possible to pass values from the nested tag expressions
# and use them as component inputs.
# E.g. below, the value of `value_from_tag` kwarg would be a dictionary,
# not a string.
#
# `{% component "my_comp" value_from_tag="{% gen_dict %}" %}`
#
# But if it already container spaces, e.g.
#
# `{% component "my_comp" value_from_tag=" {% gen_dict %} " %}`
#
# Then we'd treat it as a regular template and pass it as string.
if len(self.nodelist) == 1:
node = self.nodelist[0]
# Handle `{{ }}` tags, where we need to access the expression directly
# to avoid it being stringified
if isinstance(node, VariableNode):
return node.filter_expression.resolve(context)
else:
# For any other tags `{% %}`, we're at a mercy of the authors, and
# we don't know if the result comes out stringified or not.
return node.render(context)
else:
# Lastly, if there's multiple nodes, we render it to a string
#
# NOTE: When rendering a NodeList, it expects that each node is a string.
# However, we want to support tags that return non-string results, so we can pass
# them as inputs to components. So we wrap the nodes in `StringifiedNode`
nodelist = NodeList(StringifiedNode(node) for node in self.nodelist)
return nodelist.render(context)
class StringifiedNode(Node):
def __init__(self, wrapped_node: Node) -> None:
super().__init__()
self.wrapped_node = wrapped_node
def render(self, context: Context) -> str:
result = self.wrapped_node.render(context)
return str(result)
class RuntimeKwargs:
@ -109,6 +179,34 @@ def is_aggregate_key(key: str) -> bool:
return ":" in key and not key.startswith(":")
# A string that must start and end with quotes, and somewhere inside includes
# at least one tag. Tag may be variable (`{{ }}`), block (`{% %}`), or comment (`{# #}`).
DYNAMIC_EXPR_RE = re.compile(
r"^{start_quote}.*?(?:{var_tag}|{block_tag}|{comment_tag}).*?{end_quote}$".format(
var_tag=r"(?:\{\{.*?\}\})",
block_tag=r"(?:\{%.*?%\})",
comment_tag=r"(?:\{#.*?#\})",
start_quote=r"(?P<quote>['\"])", # NOTE: Capture group so we check for the same quote at the end
end_quote=r"(?P=quote)",
)
)
def is_dynamic_expression(value: Any) -> bool:
# NOTE: Currently dynamic expression need at least 6 characters
# for the opening and closing tags, and quotes
MIN_EXPR_LEN = 6
if not isinstance(value, str) or not value or len(value) < MIN_EXPR_LEN:
return False
# Is not wrapped in quotes, or does not contain any tags
if not DYNAMIC_EXPR_RE.match(value):
return False
return True
def is_spread_operator(value: Any) -> bool:
if not isinstance(value, str) or not value:
return False

View file

@ -1,9 +1,10 @@
from typing import TYPE_CHECKING, Callable, Dict, List, NamedTuple, Optional, Set, Union
import django.template
from django.template.base import FilterExpression, NodeList, Parser, Token
from django.template.base import FilterExpression, NodeList, Parser, Token, TokenType
from django.template.exceptions import TemplateSyntaxError
from django.utils.safestring import SafeString, mark_safe
from django.utils.text import smart_split
from django_components.app_settings import ContextBehavior, app_settings
from django_components.attributes import HTML_ATTRS_ATTRS_KEY, HTML_ATTRS_DEFAULTS_KEY, HtmlAttrsNode
@ -11,6 +12,7 @@ from django_components.component import RENDERED_COMMENT_TEMPLATE, ComponentNode
from django_components.component_registry import ComponentRegistry
from django_components.component_registry import registry as component_registry
from django_components.expression import (
DynamicFilterExpression,
Expression,
Operator,
RuntimeKwargPairs,
@ -19,6 +21,7 @@ from django_components.expression import (
RuntimeKwargsInput,
SpreadOperator,
is_aggregate_key,
is_dynamic_expression,
is_internal_spread_operator,
is_kwarg,
is_spread_operator,
@ -132,11 +135,10 @@ def component_js_dependencies(preload: str = "") -> SafeString:
@register.tag("slot")
def slot(parser: Parser, token: Token) -> SlotNode:
bits = token.split_contents()
tag = _parse_tag(
"slot",
parser,
bits,
token,
params=["name"],
flags=[SLOT_DEFAULT_KEYWORD, SLOT_REQUIRED_KEYWORD],
keywordonly_kwargs=True,
@ -171,11 +173,10 @@ def fill(parser: Parser, token: Token) -> FillNode:
This tag is available only within a {% component %}..{% endcomponent %} block.
Runtime checks should prohibit other usages.
"""
bits = token.split_contents()
tag = _parse_tag(
"fill",
parser,
bits,
token,
params=["name"],
keywordonly_kwargs=[SLOT_DATA_KWARG, SLOT_DEFAULT_KWARG],
repeatable_kwargs=False,
@ -210,6 +211,7 @@ def component(parser: Parser, token: Token, tag_name: str) -> ComponentNode:
be either the first positional argument or, if there are no positional
arguments, passed as 'name'.
"""
_fix_nested_tags(parser, token)
bits = token.split_contents()
# Let the TagFormatter pre-process the tokens
@ -219,11 +221,12 @@ def component(parser: Parser, token: Token, tag_name: str) -> ComponentNode:
# NOTE: The tokens returned from TagFormatter.parse do NOT include the tag itself
bits = [bits[0], *result.tokens]
token.contents = " ".join(bits)
tag = _parse_tag(
tag_name,
parser,
bits,
token,
params=True, # Allow many args
flags=["only"],
keywordonly_kwargs=True,
@ -260,11 +263,10 @@ def component(parser: Parser, token: Token, tag_name: str) -> ComponentNode:
@register.tag("provide")
def provide(parser: Parser, token: Token) -> ProvideNode:
# e.g. {% provide <name> key=val key2=val2 %}
bits = token.split_contents()
tag = _parse_tag(
"provide",
parser,
bits,
token,
params=["name"],
flags=[],
keywordonly_kwargs=True,
@ -315,12 +317,10 @@ def html_attrs(parser: Parser, token: Token) -> HtmlAttrsNode:
{% html_attrs attrs defaults:class="default-class" class="extra-class" data-id="123" %}
```
"""
bits = token.split_contents()
tag = _parse_tag(
"html_attrs",
parser,
bits,
token,
params=[HTML_ATTRS_ATTRS_KEY, HTML_ATTRS_DEFAULTS_KEY],
optional_params=[HTML_ATTRS_ATTRS_KEY, HTML_ATTRS_DEFAULTS_KEY],
flags=[],
@ -350,7 +350,7 @@ class ParsedTag(NamedTuple):
def _parse_tag(
tag: str,
parser: Parser,
bits: List[str],
token: Token,
params: Union[List[str], bool] = False,
flags: Optional[List[str]] = None,
end_tag: Optional[str] = None,
@ -364,8 +364,10 @@ def _parse_tag(
params = params or []
_fix_nested_tags(parser, token)
# e.g. {% slot <name> ... %}
tag_name, *bits = bits
tag_name, *bits = token.split_contents()
if tag_name != tag:
raise TemplateSyntaxError(f"Start tag parser received tag '{tag_name}', expected '{tag}'")
@ -471,7 +473,7 @@ def _parse_tag(
params = [param for param in params_to_sort if param not in optional_params]
# Parse args/kwargs that will be passed to the fill
args, raw_kwarg_pairs = parse_bits(
raw_args, raw_kwarg_pairs = parse_bits(
parser=parser,
bits=bits,
params=[] if isinstance(params, bool) else params,
@ -480,13 +482,27 @@ def _parse_tag(
# Post-process args/kwargs - Mark special cases like aggregate dicts
# or dynamic expressions
args: List[Expression] = []
for val in raw_args:
if is_dynamic_expression(val.token):
args.append(DynamicFilterExpression(parser, val.token))
else:
args.append(val)
kwarg_pairs: RuntimeKwargPairsInput = []
for key, val in raw_kwarg_pairs:
is_spread_op = is_internal_spread_operator(key + "=")
if is_spread_op:
expr = parser.compile_filter(val.token)
# Allow to use dynamic expressions with spread operator, e.g.
# `..."{{ }}"`
if is_dynamic_expression(val.token):
expr = DynamicFilterExpression(parser, val.token)
else:
expr = parser.compile_filter(val.token)
kwarg_pairs.append((key, SpreadOperator(expr)))
elif is_dynamic_expression(val.token) and not is_spread_op:
kwarg_pairs.append((key, DynamicFilterExpression(parser, val.token)))
else:
kwarg_pairs.append((key, val))
@ -560,6 +576,106 @@ def _parse_tag_body(parser: Parser, end_tag: str, inline: bool) -> NodeList:
return body
def _fix_nested_tags(parser: Parser, block_token: Token) -> None:
# 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 = block_token.contents.count("{%") > block_token.contents.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' '"' "{%}" %}`
#
# 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.
# Hence, for this we use Django's `smart_split()`, which can detect quoted text.
last_bit = list(smart_split(block_token.contents))[-1]
has_unclosed_quote = last_bit.count("'") % 2 or last_bit.count('"') % 2
needs_fixing = has_unclosed_tag or 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
class ParsedSlotTag(NamedTuple):
name: str

View file

@ -3,10 +3,10 @@
from typing import Any, Dict
from django.template import Context, Template, TemplateSyntaxError
from django.template.base import Parser
from django.template.base import FilterExpression, Node, Parser, Token
from django_components import Component, register, types
from django_components.expression import safe_resolve_dict, safe_resolve_list
from django_components import Component, register, registry, types
from django_components.expression import DynamicFilterExpression, safe_resolve_dict, safe_resolve_list
from .django_test_setup import setup_test_config
from .testutils import BaseTestCase, parametrize_context_behavior
@ -18,6 +18,23 @@ engine = Template("").engine
default_parser = Parser("", engine.template_libraries, engine.template_builtins)
# A tag that just returns the value, so we can
# check if the value is stringified
class NoopNode(Node):
def __init__(self, expr: FilterExpression):
self.expr = expr
def render(self, context: Context):
return self.expr.resolve(context)
def noop(parser: Parser, token: Token):
tag, raw_expr = token.split_contents()
expr = parser.compile_filter(raw_expr)
return NoopNode(expr)
def make_context(d: Dict):
ctx = Context(d)
ctx.template = Template("")
@ -61,6 +78,389 @@ class ResolveTests(BaseTestCase):
)
# NOTE: Django calls the `{{ }}` syntax "variables" and `{% %}` "blocks"
class DynamicExprTests(BaseTestCase):
def test_variable_resolve_dynamic_expr(self):
expr = DynamicFilterExpression(default_parser, '"{{ var_a|lower }}"')
ctx = make_context({"var_a": "LoREM"})
self.assertEqual(expr.resolve(ctx), "lorem")
def test_variable_raises_on_dynamic_expr_with_quotes_mismatch(self):
with self.assertRaises(TemplateSyntaxError):
DynamicFilterExpression(default_parser, "'{{ var_a|lower }}\"")
@parametrize_context_behavior(["django", "isolated"])
def test_variable_in_template(self):
captured = {}
@register("test")
class SimpleComponent(Component):
def get_context_data(
self,
pos_var1: Any,
*args: Any,
bool_var: bool,
list_var: Dict,
):
captured["pos_var1"] = pos_var1
captured["bool_var"] = bool_var
captured["list_var"] = list_var
return {
"pos_var1": pos_var1,
"bool_var": bool_var,
"list_var": list_var,
}
template: types.django_html = """
<div>{{ pos_var1 }}</div>
<div>{{ bool_var }}</div>
<div>{{ list_var|safe }}</div>
"""
template_str: types.django_html = (
"""
{% load component_tags %}
{% component 'test'
"{{ var_a|lower }}"
bool_var="{{ is_active }}"
list_var="{{ list|slice:':-1' }}"
/ %}
""".replace(
"\n", " "
)
)
template = Template(template_str)
rendered = template.render(
Context(
{
"var_a": "LoREM",
"is_active": True,
"list": [{"a": 1}, {"a": 2}, {"a": 3}],
}
),
)
# Check that variables passed to the component are of correct type
self.assertEqual(captured["pos_var1"], "lorem")
self.assertEqual(captured["bool_var"], True)
self.assertEqual(captured["list_var"], [{"a": 1}, {"a": 2}])
self.assertEqual(
rendered.strip(),
"<div>lorem</div>\n <div>True</div>\n <div>[{'a': 1}, {'a': 2}]</div>",
)
@parametrize_context_behavior(["django", "isolated"])
def test_block_in_template(self):
registry.library.tag(noop)
captured = {}
@register("test")
class SimpleComponent(Component):
def get_context_data(
self,
pos_var1: Any,
*args: Any,
bool_var: bool,
list_var: Dict,
dict_var: Dict,
):
captured["pos_var1"] = pos_var1
captured["bool_var"] = bool_var
captured["list_var"] = list_var
captured["dict_var"] = dict_var
return {
"pos_var1": pos_var1,
"bool_var": bool_var,
"list_var": list_var,
"dict_var": dict_var,
}
template: types.django_html = """
<div>{{ pos_var1 }}</div>
<div>{{ bool_var }}</div>
<div>{{ list_var|safe }}</div>
<div>{{ dict_var|safe }}</div>
"""
template_str: types.django_html = (
"""
{% load component_tags %}
{% component 'test'
"{% lorem var_a w %}"
bool_var="{% noop is_active %}"
list_var="{% noop list %}"
dict_var="{% noop dict %}"
/ %}
""".replace(
"\n", " "
)
)
template = Template(template_str)
rendered = template.render(
Context(
{
"var_a": 3,
"is_active": True,
"list": [{"a": 1}, {"a": 2}],
"dict": {"a": 3},
}
),
)
# Check that variables passed to the component are of correct type
self.assertEqual(captured["bool_var"], True)
self.assertEqual(captured["dict_var"], {"a": 3})
self.assertEqual(captured["list_var"], [{"a": 1}, {"a": 2}])
self.assertEqual(
rendered.strip(),
"<div>lorem ipsum dolor</div>\n <div>True</div>\n <div>[{'a': 1}, {'a': 2}]</div>\n <div>{'a': 3}</div>", # noqa E501
)
@parametrize_context_behavior(["django", "isolated"])
def test_comment_in_template(self):
registry.library.tag(noop)
captured = {}
@register("test")
class SimpleComponent(Component):
def get_context_data(
self,
pos_var1: Any,
pos_var2: Any,
*args: Any,
bool_var: bool,
list_var: Dict,
):
captured["pos_var1"] = pos_var1
captured["pos_var2"] = pos_var2
captured["bool_var"] = bool_var
captured["list_var"] = list_var
return {
"pos_var1": pos_var1,
"pos_var2": pos_var2,
"bool_var": bool_var,
"list_var": list_var,
}
template: types.django_html = """
<div>{{ pos_var1 }}</div>
<div>{{ pos_var2 }}</div>
<div>{{ bool_var }}</div>
<div>{{ list_var|safe }}</div>
"""
template_str: types.django_html = (
"""
{% load component_tags %}
{% component 'test'
"{# lorem var_a w #}"
" {# lorem var_a w #} abc"
bool_var="{# noop is_active #}"
list_var=" {# noop list #} "
/ %}
""".replace(
"\n", " "
)
)
template = Template(template_str)
rendered = template.render(
Context(
{
"var_a": 3,
"is_active": True,
"list": [{"a": 1}, {"a": 2}],
}
),
)
# Check that variables passed to the component are of correct type
self.assertEqual(captured["pos_var1"], "")
self.assertEqual(captured["pos_var2"], " abc")
self.assertEqual(captured["bool_var"], "")
self.assertEqual(captured["list_var"], " ")
self.assertEqual(
rendered.strip(),
"<div></div>\n <div> abc</div>\n <div></div>\n <div> </div>", # noqa E501
)
@parametrize_context_behavior(["django", "isolated"])
def test_mixed_in_template(self):
registry.library.tag(noop)
captured = {}
@register("test")
class SimpleComponent(Component):
def get_context_data(
self,
pos_var1: Any,
pos_var2: Any,
*args: Any,
bool_var: bool,
list_var: Dict,
dict_var: Dict,
):
captured["pos_var1"] = pos_var1
captured["bool_var"] = bool_var
captured["list_var"] = list_var
captured["dict_var"] = dict_var
return {
"pos_var1": pos_var1,
"pos_var2": pos_var2,
"bool_var": bool_var,
"list_var": list_var,
"dict_var": dict_var,
}
template: types.django_html = """
<div>{{ pos_var1 }}</div>
<div>{{ pos_var2 }}</div>
<div>{{ bool_var }}</div>
<div>{{ list_var|safe }}</div>
<div>{{ dict_var|safe }}</div>
"""
template_str: types.django_html = (
"""
{% load component_tags %}
{% component 'test'
" {% lorem var_a w %} "
" {% lorem var_a w %} {{ list|slice:':-1' }} "
bool_var=" {% noop is_active %} "
list_var=" {% noop list %} "
dict_var=" {% noop dict %} "
/ %}
""".replace(
"\n", " "
)
)
template = Template(template_str)
rendered = template.render(
Context(
{
"var_a": 3,
"is_active": True,
"list": [{"a": 1}, {"a": 2}],
"dict": {"a": 3},
}
),
)
# Check that variables passed to the component are of correct type
self.assertEqual(captured["bool_var"], " True ")
self.assertEqual(captured["dict_var"], " {'a': 3} ")
self.assertEqual(captured["list_var"], " [{'a': 1}, {'a': 2}] ")
self.assertEqual(
rendered.strip(),
"<div> lorem ipsum dolor </div>\n <div> lorem ipsum dolor [{&#x27;a&#x27;: 1}] </div>\n <div> True </div>\n <div> [{'a': 1}, {'a': 2}] </div>\n <div> {'a': 3} </div>", # noqa E501
)
@parametrize_context_behavior(["django", "isolated"])
def test_ignores_invalid_tag(self):
registry.library.tag(noop)
@register("test")
class SimpleComponent(Component):
def get_context_data(
self,
pos_var1: Any,
pos_var2: Any,
*args: Any,
bool_var: bool,
):
return {
"pos_var1": pos_var1,
"pos_var2": pos_var2,
"bool_var": bool_var,
}
template: types.django_html = """
<div>{{ pos_var1 }}</div>
<div>{{ pos_var2 }}</div>
<div>{{ bool_var }}</div>
"""
template_str: types.django_html = (
"""
{% load component_tags %}
{% component 'test' '"' "{%}" bool_var="{% noop is_active %}" / %}
""".replace(
"\n", " "
)
)
template = Template(template_str)
rendered = template.render(
Context({"is_active": True}),
)
self.assertEqual(
rendered.strip(),
'<div>"</div>\n <div>{%}</div>\n <div>True</div>',
)
@parametrize_context_behavior(["django", "isolated"])
def test_nested_in_template(self):
registry.library.tag(noop)
@register("test")
class SimpleComponent(Component):
def get_context_data(
self,
pos_var1: Any,
*args: Any,
bool_var: bool,
):
return {
"pos_var1": pos_var1,
"bool_var": bool_var,
}
template: types.django_html = """
<div>{{ pos_var1 }}</div>
<div>{{ bool_var }}</div>
"""
template_str: types.django_html = (
"""
{% load component_tags %}
{% component 'test'
"{% component 'test' '{{ var_a }}' bool_var=is_active / %}"
bool_var="{% noop is_active %}"
/ %}
""".replace(
"\n", " "
)
)
template = Template(template_str)
rendered = template.render(
Context(
{
"var_a": 3,
"is_active": True,
}
),
)
self.assertEqual(
rendered.strip(),
"<div>\n <div>3</div>\n <div>True</div>\n </div>\n <div>True</div>", # noqa E501
)
class SpreadOperatorTests(BaseTestCase):
@parametrize_context_behavior(["django", "isolated"])
def test_component(self):
@ -96,7 +496,7 @@ class SpreadOperatorTests(BaseTestCase):
{% component 'test'
var_a
...my_dict
...item
..."{{ list|first }}"
x=123
/ %}
""".replace(
@ -114,7 +514,7 @@ class SpreadOperatorTests(BaseTestCase):
"attrs:style": "height: 20px",
"items": [1, 2, 3],
},
"item": {"a": 1},
"list": [{"a": 1}, {"a": 2}, {"a": 3}],
}
),
)
@ -147,12 +547,12 @@ class SpreadOperatorTests(BaseTestCase):
"attrs:style": "height: 20px",
"items": [1, 2, 3],
},
"item": {"a": 1},
"list": [{"a": 1}, {"a": 2}, {"a": 3}],
}
template: types.django_html = """
{% load component_tags %}
{% slot "my_slot" ...my_dict ...item x=123 default / %}
{% slot "my_slot" ...my_dict ..."{{ list|first }}" x=123 default / %}
"""
template_str: types.django_html = """
@ -184,12 +584,12 @@ class SpreadOperatorTests(BaseTestCase):
"attrs:style": "height: 20px",
"items": [1, 2, 3],
},
"item": {"a": 1},
"list": [{"a": 1}, {"a": 2}, {"a": 3}],
}
template: types.django_html = """
{% load component_tags %}
{% slot "my_slot" ...my_dict ...item x=123 default %}
{% slot "my_slot" ...my_dict ..."{{ list|first }}" x=123 default %}
__SLOT_DEFAULT__
{% endslot %}
"""
@ -244,7 +644,7 @@ class SpreadOperatorTests(BaseTestCase):
template_str: types.django_html = """
{% load component_tags %}
{% provide 'test' ...my_dict ...item %}
{% provide 'test' ...my_dict ..."{{ list|first }}" %}
{% component 'test' / %}
{% endprovide %}
"""
@ -257,7 +657,7 @@ class SpreadOperatorTests(BaseTestCase):
"attrs:style": "height: 20px",
"items": [1, 2, 3],
},
"item": {"a": 1},
"list": [{"a": 1}, {"a": 2}, {"a": 3}],
}
),
)
@ -324,7 +724,7 @@ class SpreadOperatorTests(BaseTestCase):
...my_dict
attrs:style="OVERWRITTEN"
x=123
...item
..."{{ list|first }}"
/ %}
""".replace(
"\n", " "
@ -340,7 +740,7 @@ class SpreadOperatorTests(BaseTestCase):
"attrs:style": "height: 20px",
"items": [1, 2, 3],
},
"item": {"a": 1, "x": "OVERWRITTEN_X"},
"list": [{"a": 1, "x": "OVERWRITTEN_X"}, {"a": 2}, {"a": 3}],
}
),
)
@ -356,7 +756,7 @@ class SpreadOperatorTests(BaseTestCase):
)
@parametrize_context_behavior(["django", "isolated"])
def test_raises_if_position_arg_after_spread(self):
def test_raises_if_positional_arg_after_spread(self):
@register("test")
class SimpleComponent(Component):
pass
@ -367,7 +767,7 @@ class SpreadOperatorTests(BaseTestCase):
{% component 'test'
...my_dict
var_a
...item
..."{{ list|first }}"
x=123
/ %}
""".replace(
@ -422,7 +822,9 @@ class SpreadOperatorTests(BaseTestCase):
template = Template(template_str)
# List
with self.assertRaisesMessage(AttributeError, "'list' object has no attribute 'items'"):
with self.assertRaisesMessage(
RuntimeError, "Spread operator expression must resolve to a Dict, got [1, 2, 3]"
):
template.render(
Context(
{
@ -433,7 +835,7 @@ class SpreadOperatorTests(BaseTestCase):
)
# String
with self.assertRaisesMessage(AttributeError, "'str' object has no attribute 'items'"):
with self.assertRaisesMessage(RuntimeError, "Spread operator expression must resolve to a Dict, got def"):
template.render(
Context(
{

View file

@ -1,5 +1,5 @@
from django.template import Context, Template
from django.template.base import Parser
from django.template.base import Lexer, Parser
from django_components import Component, registry, types
from django_components.expression import (
@ -18,8 +18,10 @@ setup_test_config({"autodiscover": False})
class ParserTest(BaseTestCase):
def test_parses_args_kwargs(self):
bits = ["component", "42", "myvar", "key='val'", "key2=val2"]
tag = _parse_tag("component", Parser(""), bits, params=["num", "var"], keywordonly_kwargs=True)
template_str = "{% component 42 myvar key='val' key2=val2 %}"
tokens = Lexer(template_str).tokenize()
parser = Parser(tokens=tokens)
tag = _parse_tag("component", parser, parser.tokens[0], params=["num", "var"], keywordonly_kwargs=True)
ctx = {"myvar": {"a": "b"}, "val2": 1}
args = safe_resolve_list(ctx, tag.args)
@ -31,15 +33,10 @@ class ParserTest(BaseTestCase):
self.assertDictEqual(kwargs, {"key": "val", "key2": 1})
def test_parses_special_kwargs(self):
bits = [
"component",
"date=date",
"@lol=2",
"na-me=bzz",
"@event:na-me.mod=bzz",
"#my-id=True",
]
tag = _parse_tag("component", Parser(""), bits, keywordonly_kwargs=True)
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)
tag = _parse_tag("component", parser, parser.tokens[0], keywordonly_kwargs=True)
ctx = Context({"date": 2024, "bzz": "fzz"})
args = safe_resolve_list(ctx, tag.args)