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

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(
{