feat: add spread operator (#596)

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-24 15:01:18 +02:00 committed by GitHub
parent 36b8fcfbe6
commit d6ec62c6be
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 637 additions and 108 deletions

243
README.md
View file

@ -50,6 +50,7 @@ And this is what gets rendered (plus the CSS and Javascript you've specified):
- [Using slots in templates](#using-slots-in-templates)
- [Passing data to components](#passing-data-to-components)
- [Rendering HTML attributes](#rendering-html-attributes)
- [Template tag syntax](#template-tag-syntax)
- [Prop drilling and dependency injection (provide / inject)](#prop-drilling-and-dependency-injection-provide--inject)
- [Component context and scope](#component-context-and-scope)
- [Customizing component tags with TagFormatter](#customizing-component-tags-with-tagformatter)
@ -1446,105 +1447,6 @@ As seen above, you can pass arguments to components like so:
</body>
```
### Special characters
_New in version 0.71_:
Keyword arguments can contain special characters `# @ . - _`, so keywords like
so are still valid:
```django
<body>
{% component "calendar" my-date="2015-06-19" @click.native=do_something #some_id=True / %}
</body>
```
These can then be accessed inside `get_context_data` so:
```py
@register("calendar")
class Calendar(Component):
# Since # . @ - are not valid identifiers, we have to
# use `**kwargs` so the method can accept these args.
def get_context_data(self, **kwargs):
return {
"date": kwargs["my-date"],
"id": kwargs["#some_id"],
"on_click": kwargs["@click.native"]
}
```
### Pass dictonary by its key-value pairs
_New in version 0.74_:
Sometimes, a component may expect a dictionary as one of its inputs.
Most commonly, this happens when a component accepts a dictionary
of HTML attributes (usually called `attrs`) to pass to the underlying template.
In such cases, we may want to define some HTML attributes statically, and other dynamically.
But for that, we need to define this dictionary on Python side:
```py
@register("my_comp")
class MyComp(Component):
template = """
{% component "other" attrs=attrs / %}
"""
def get_context_data(self, some_id: str):
attrs = {
"class": "pa-4 flex",
"data-some-id": some_id,
"@click.stop": "onClickHandler",
}
return {"attrs": attrs}
```
But as you can see in the case above, the event handler `@click.stop` and styling `pa-4 flex`
are disconnected from the template. If the component grew in size and we moved the HTML
to a separate file, we would have hard time reasoning about the component's template.
Luckily, there's a better way.
When we want to pass a dictionary to a component, we can define individual key-value pairs
as component kwargs, so we can keep all the relevant information in the template. For that,
we prefix the key with the name of the dict and `:`. So key `class` of input `attrs` becomes
`attrs:class`. And our example becomes:
```py
@register("my_comp")
class MyComp(Component):
template = """
{% component "other"
attrs:class="pa-4 flex"
attrs:data-some-id=some_id
attrs:@click.stop="onClickHandler"
/ %}
"""
def get_context_data(self, some_id: str):
return {"some_id": some_id}
```
Sweet! Now all the relevant HTML is inside the template, and we can move it to a separate file with confidence:
```django
{% component "other"
attrs:class="pa-4 flex"
attrs:data-some-id=some_id
attrs:@click.stop="onClickHandler"
/ %}
```
> Note: It is NOT possible to define nested dictionaries, so
> `attrs:my_key:two=2` would be interpreted as:
>
> ```py
> {"attrs": {"my_key:two": 2}}
> ```
### Accessing data passed to the component
When you call `Component.render` or `Component.render_to_response`, the inputs to these methods can be accessed from within the instance under `self.input`.
@ -1953,6 +1855,149 @@ attributes_to_string(attrs)
# 'class="my-class text-red pa-4" data-id="123" required'
```
## Template tag syntax
All template tags in django_component, like `{% component %}` or `{% slot %}`, and so on,
support extra syntax that makes it possible to write components like in Vue or React.
### Special characters
_New in version 0.71_:
Keyword arguments can contain special characters `# @ . - _`, so keywords like
so are still valid:
```django
<body>
{% component "calendar" my-date="2015-06-19" @click.native=do_something #some_id=True / %}
</body>
```
These can then be accessed inside `get_context_data` so:
```py
@register("calendar")
class Calendar(Component):
# Since # . @ - are not valid identifiers, we have to
# use `**kwargs` so the method can accept these args.
def get_context_data(self, **kwargs):
return {
"date": kwargs["my-date"],
"id": kwargs["#some_id"],
"on_click": kwargs["@click.native"]
}
```
### Spread operator
_New in version 0.93_:
Instead of passing keyword arguments one-by-one:
```django
{% component "calendar" title="How to abc" date="2015-06-19" author="John Wick" / %}
```
You can use a spread operator `...dict` to apply key-value pairs from a dictionary:
```py
post_data = {
"title": "How to...",
"date": "2015-06-19",
"author": "John Wick",
}
```
```django
{% component "calendar" ...post_data / %}
```
This behaves similar to [JSX's spread operator](https://kevinyckim33.medium.com/jsx-spread-operator-component-props-meaning-3c9bcadd2493)
or [Vue's `v-bind`](https://vuejs.org/api/built-in-directives.html#v-bind).
Spread operators are treated as keyword arguments, which means that:
1. Spread operators must come after positional arguments.
2. You cannot use spread operators for [positional-only arguments](https://martinxpn.medium.com/positional-only-and-keyword-only-arguments-in-python-37-100-days-of-python-310c311657b0).
Other than that, you can use spread operators multiple times, and even put keyword arguments in-between or after them:
```django
{% component "calendar" ...post_data id=post.id ...extra / %}
```
In a case of conflicts, the values added later (right-most) overwrite previous values.
### Pass dictonary by its key-value pairs
_New in version 0.74_:
Sometimes, a component may expect a dictionary as one of its inputs.
Most commonly, this happens when a component accepts a dictionary
of HTML attributes (usually called `attrs`) to pass to the underlying template.
In such cases, we may want to define some HTML attributes statically, and other dynamically.
But for that, we need to define this dictionary on Python side:
```py
@register("my_comp")
class MyComp(Component):
template = """
{% component "other" attrs=attrs / %}
"""
def get_context_data(self, some_id: str):
attrs = {
"class": "pa-4 flex",
"data-some-id": some_id,
"@click.stop": "onClickHandler",
}
return {"attrs": attrs}
```
But as you can see in the case above, the event handler `@click.stop` and styling `pa-4 flex`
are disconnected from the template. If the component grew in size and we moved the HTML
to a separate file, we would have hard time reasoning about the component's template.
Luckily, there's a better way.
When we want to pass a dictionary to a component, we can define individual key-value pairs
as component kwargs, so we can keep all the relevant information in the template. For that,
we prefix the key with the name of the dict and `:`. So key `class` of input `attrs` becomes
`attrs:class`. And our example becomes:
```py
@register("my_comp")
class MyComp(Component):
template = """
{% component "other"
attrs:class="pa-4 flex"
attrs:data-some-id=some_id
attrs:@click.stop="onClickHandler"
/ %}
"""
def get_context_data(self, some_id: str):
return {"some_id": some_id}
```
Sweet! Now all the relevant HTML is inside the template, and we can move it to a separate file with confidence:
```django
{% component "other"
attrs:class="pa-4 flex"
attrs:data-some-id=some_id
attrs:@click.stop="onClickHandler"
/ %}
```
> Note: It is NOT possible to define nested dictionaries, so
> `attrs:my_key:two=2` would be interpreted as:
>
> ```py
> {"attrs": {"my_key:two": 2}}
> ```
## Prop drilling and dependency injection (provide / inject)
_New in version 0.80_:

View file

@ -1,11 +1,36 @@
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
Expression = Union[FilterExpression]
RuntimeKwargsInput = Dict[str, Expression]
RuntimeKwargPairsInput = List[Tuple[str, Expression]]
RuntimeKwargsInput = Dict[str, Union[Expression, "Operator"]]
RuntimeKwargPairsInput = List[Tuple[str, Union[Expression, "Operator"]]]
class Operator(ABC):
"""
Operator describes something that somehow changes the inputs
to template tags (the `{% %}`).
For example, a SpreadOperator inserts one or more kwargs at the
specified location.
"""
@abstractmethod
def resolve(self, context: Context) -> Any: ... # noqa E704
class SpreadOperator(Operator):
"""Operator that inserts one or more kwargs at the specified location."""
def __init__(self, expr: Expression) -> None:
self.expr = expr
def resolve(self, context: Context) -> Dict[str, Any]:
return self.expr.resolve(context)
class RuntimeKwargs:
@ -24,6 +49,11 @@ class RuntimeKwargPairs:
def resolve(self, context: Context) -> List[Tuple[str, Any]]:
resolved_kwarg_pairs: List[Tuple[str, Any]] = []
for key, kwarg in self.kwarg_pairs:
if isinstance(kwarg, SpreadOperator):
spread_kwargs = kwarg.resolve(context)
for spread_key, spread_value in spread_kwargs.items():
resolved_kwarg_pairs.append((spread_key, spread_value))
else:
resolved_kwarg_pairs.append((key, kwarg.resolve(context)))
return resolved_kwarg_pairs
@ -43,11 +73,18 @@ def safe_resolve_list(context: Context, args: List[Expression]) -> List:
def safe_resolve_dict(
context: Context,
kwargs: Dict[str, Expression],
kwargs: Dict[str, Union[Expression, "Operator"]],
) -> Dict[str, Any]:
result = {}
for key, kwarg in kwargs.items():
# If we've come across a Spread Operator (...), we insert the kwargs from it here
if isinstance(kwarg, SpreadOperator):
spread_dict = kwarg.resolve(context)
if spread_dict is not None:
for spreadkey, spreadkwarg in spread_dict.items():
result[spreadkey] = spreadkwarg
else:
result[key] = kwarg.resolve(context)
return result
@ -72,6 +109,31 @@ def is_aggregate_key(key: str) -> bool:
return ":" in key and not key.startswith(":")
def is_spread_operator(value: Any) -> bool:
if not isinstance(value, str) or not value:
return False
return value.startswith("...")
# A string that starts with `...1=`, `...29=`, etc.
# We convert the spread syntax to this, so Django parses
# it as a kwarg, so it remains in the original position.
#
# So from `...dict`, we make `...1=dict`
#
# That way it's trivial to merge the kwargs after the spread
# operator is replaced with actual values.
INTERNAL_SPREAD_OPERATOR_RE = re.compile(r"^\.\.\.\d+=")
def is_internal_spread_operator(value: Any) -> bool:
if not isinstance(value, str) or not value:
return False
return bool(INTERNAL_SPREAD_OPERATOR_RE.match(value))
def process_aggregate_kwargs(kwargs: Mapping[str, Any]) -> Dict[str, Any]:
"""
This function aggregates "prefixed" kwargs into dicts. "Prefixed" kwargs

View file

@ -12,11 +12,16 @@ from django_components.component_registry import ComponentRegistry
from django_components.component_registry import registry as component_registry
from django_components.expression import (
Expression,
Operator,
RuntimeKwargPairs,
RuntimeKwargPairsInput,
RuntimeKwargs,
RuntimeKwargsInput,
SpreadOperator,
is_aggregate_key,
is_internal_spread_operator,
is_kwarg,
is_spread_operator,
resolve_string,
)
from django_components.logger import trace_msg
@ -389,6 +394,7 @@ def _parse_tag(
else:
seen_kwargs.add(key)
spread_count = 0
for bit in bits:
value = bit
bit_is_kwarg = is_kwarg(bit)
@ -411,6 +417,21 @@ def _parse_tag(
parsed_flags[value] = True
continue
# Extract spread operator (...dict)
elif is_spread_operator(value):
if value == "...":
raise TemplateSyntaxError("Syntax operator is missing a value")
# Replace the leading `...` with `...=`, so the parser
# interprets it as a kwargs, and keeps it in the correct
# position.
# Since there can be multiple spread operators, we suffix
# them with an index, e.g. `...0=`
internal_spread_bit = f"...{spread_count}={value[3:]}"
bits_without_flags.append(internal_spread_bit)
spread_count += 1
continue
bits_without_flags.append(bit)
bits = bits_without_flags
@ -450,13 +471,25 @@ 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, kwarg_pairs = parse_bits(
args, raw_kwarg_pairs = parse_bits(
parser=parser,
bits=bits,
params=[] if isinstance(params, bool) else params,
name=tag_name,
)
# Post-process args/kwargs - Mark special cases like aggregate dicts
# or dynamic expressions
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)
kwarg_pairs.append((key, SpreadOperator(expr)))
else:
kwarg_pairs.append((key, val))
# Allow only as many positional args as given
if params != True and len(args) > len(params): # noqa F712
raise TemplateSyntaxError(f"Tag '{tag_name}' received unexpected positional arguments: {args[len(params):]}")
@ -471,6 +504,11 @@ def _parse_tag(
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 keywordonly_kwargs:
is_key_allowed = False

View file

@ -1,14 +1,15 @@
"""Catch-all for tests that use template tags and don't fit other files"""
from typing import Dict
from typing import Any, Dict
from django.template import Context, Template
from django.template import Context, Template, TemplateSyntaxError
from django.template.base import Parser
from django_components import Component, register, types
from django_components.expression import safe_resolve_dict, safe_resolve_list
from .django_test_setup import setup_test_config
from .testutils import BaseTestCase
from .testutils import BaseTestCase, parametrize_context_behavior
setup_test_config({"autodiscover": False})
@ -58,3 +59,386 @@ class ResolveTests(BaseTestCase):
safe_resolve_dict(ctx, exprs),
{"a": 123, "b": [{}, {}], "c": ""},
)
class SpreadOperatorTests(BaseTestCase):
@parametrize_context_behavior(["django", "isolated"])
def test_component(self):
captured = {}
@register("test")
class SimpleComponent(Component):
def get_context_data(
self,
pos_var1: Any,
*args: Any,
**kwargs: Any,
):
nonlocal captured
captured = kwargs
return {
"pos_var1": pos_var1,
**kwargs,
}
template: types.django_html = """
<div>{{ pos_var1 }}</div>
<div>{{ attrs }}</div>
<div>{{ items }}</div>
<div>{{ a }}</div>
<div>{{ x }}</div>
"""
template_str: types.django_html = (
"""
{% load component_tags %}
{% component 'test'
var_a
...my_dict
...item
x=123
/ %}
""".replace(
"\n", " "
)
)
template = Template(template_str)
rendered = template.render(
Context(
{
"var_a": "LoREM",
"my_dict": {
"attrs:@click": "() => {}",
"attrs:style": "height: 20px",
"items": [1, 2, 3],
},
"item": {"a": 1},
}
),
)
# Check that variables passed to the component are of correct type
self.assertEqual(captured["attrs"], {"@click": "() => {}", "style": "height: 20px"})
self.assertEqual(captured["items"], [1, 2, 3])
self.assertEqual(captured["a"], 1)
self.assertEqual(captured["x"], 123)
self.assertHTMLEqual(
rendered,
"""
<div>LoREM</div>
<div>{'@click': '() =&gt; {}', 'style': 'height: 20px'}</div>
<div>[1, 2, 3]</div>
<div>1</div>
<div>123</div>
""",
)
@parametrize_context_behavior(["django", "isolated"])
def test_slot(self):
@register("test")
class SimpleComponent(Component):
def get_context_data(self):
return {
"my_dict": {
"attrs:@click": "() => {}",
"attrs:style": "height: 20px",
"items": [1, 2, 3],
},
"item": {"a": 1},
}
template: types.django_html = """
{% load component_tags %}
{% slot "my_slot" ...my_dict ...item x=123 default / %}
"""
template_str: types.django_html = """
{% load component_tags %}
{% component 'test' %}
{% fill "my_slot" data="slot_data" %}
{{ slot_data }}
{% endfill %}
{% endcomponent %}
"""
template = Template(template_str)
rendered = template.render(Context({}))
self.assertHTMLEqual(
rendered,
"""
{'items': [1, 2, 3], 'a': 1, 'x': 123, 'attrs': {'@click': '() =&gt; {}', 'style': 'height: 20px'}}
""",
)
@parametrize_context_behavior(["django", "isolated"])
def test_fill(self):
@register("test")
class SimpleComponent(Component):
def get_context_data(self):
return {
"my_dict": {
"attrs:@click": "() => {}",
"attrs:style": "height: 20px",
"items": [1, 2, 3],
},
"item": {"a": 1},
}
template: types.django_html = """
{% load component_tags %}
{% slot "my_slot" ...my_dict ...item x=123 default %}
__SLOT_DEFAULT__
{% endslot %}
"""
template_str: types.django_html = """
{% load component_tags %}
{% component 'test' %}
{% fill "my_slot" ...fill_data %}
{{ slot_data }}
{{ slot_default }}
{% endfill %}
{% endcomponent %}
"""
template = Template(template_str)
rendered = template.render(
Context(
{
"fill_data": {
"data": "slot_data",
"default": "slot_default",
},
}
),
)
self.assertHTMLEqual(
rendered,
"""
{'items': [1, 2, 3], 'a': 1, 'x': 123, 'attrs': {'@click': '() =&gt; {}', 'style': 'height: 20px'}}
__SLOT_DEFAULT__
""",
)
@parametrize_context_behavior(["django", "isolated"])
def test_provide(self):
@register("test")
class SimpleComponent(Component):
def get_context_data(self):
data = self.inject("test")
return {
"attrs": data.attrs,
"items": data.items,
"a": data.a,
}
template: types.django_html = """
{% load component_tags %}
<div>{{ attrs }}</div>
<div>{{ items }}</div>
<div>{{ a }}</div>
"""
template_str: types.django_html = """
{% load component_tags %}
{% provide 'test' ...my_dict ...item %}
{% component 'test' / %}
{% endprovide %}
"""
template = Template(template_str)
rendered = template.render(
Context(
{
"my_dict": {
"attrs:@click": "() => {}",
"attrs:style": "height: 20px",
"items": [1, 2, 3],
},
"item": {"a": 1},
}
),
)
self.assertHTMLEqual(
rendered,
"""
<div>{'@click': '() =&gt; {}', 'style': 'height: 20px'}</div>
<div>[1, 2, 3]</div>
<div>1</div>
""",
)
@parametrize_context_behavior(["django", "isolated"])
def test_html_attrs(self):
template_str: types.django_html = """
{% load component_tags %}
<div {% html_attrs defaults:test="hi" ...my_dict attrs:lol="123" %}>
"""
template = Template(template_str)
rendered = template.render(
Context(
{
"my_dict": {
"attrs:style": "height: 20px",
"class": "button",
"defaults:class": "my-class",
"defaults:style": "NONO",
},
}
),
)
self.assertHTMLEqual(
rendered,
"""
<div test="hi" class="my-class button" style="height: 20px" lol="123">
""",
)
@parametrize_context_behavior(["django", "isolated"])
def test_later_spreads_overwrite_earlier(self):
@register("test")
class SimpleComponent(Component):
def get_context_data(
self,
*args: Any,
**kwargs: Any,
):
return {
**kwargs,
}
template: types.django_html = """
<div>{{ attrs }}</div>
<div>{{ items }}</div>
<div>{{ a }}</div>
<div>{{ x }}</div>
"""
template_str: types.django_html = (
"""
{% load component_tags %}
{% component 'test'
...my_dict
attrs:style="OVERWRITTEN"
x=123
...item
/ %}
""".replace(
"\n", " "
)
)
template = Template(template_str)
rendered = template.render(
Context(
{
"my_dict": {
"attrs:@click": "() => {}",
"attrs:style": "height: 20px",
"items": [1, 2, 3],
},
"item": {"a": 1, "x": "OVERWRITTEN_X"},
}
),
)
self.assertHTMLEqual(
rendered,
"""
<div>{'@click': '() =&gt; {}', 'style': 'OVERWRITTEN'}</div>
<div>[1, 2, 3]</div>
<div>1</div>
<div>OVERWRITTEN_X</div>
""",
)
@parametrize_context_behavior(["django", "isolated"])
def test_raises_if_position_arg_after_spread(self):
@register("test")
class SimpleComponent(Component):
pass
template_str: types.django_html = (
"""
{% load component_tags %}
{% component 'test'
...my_dict
var_a
...item
x=123
/ %}
""".replace(
"\n", " "
)
)
with self.assertRaisesMessage(
TemplateSyntaxError, "'component' received some positional argument(s) after some keyword argument(s)"
):
Template(template_str)
@parametrize_context_behavior(["django", "isolated"])
def test_raises_on_missing_value(self):
@register("test")
class SimpleComponent(Component):
pass
template_str: types.django_html = (
"""
{% load component_tags %}
{% component 'test'
var_a
...
/ %}
""".replace(
"\n", " "
)
)
with self.assertRaisesMessage(TemplateSyntaxError, "Syntax operator is missing a value"):
Template(template_str)
@parametrize_context_behavior(["django", "isolated"])
def test_raises_on_non_dict(self):
@register("test")
class SimpleComponent(Component):
pass
template_str: types.django_html = (
"""
{% load component_tags %}
{% component 'test'
var_a
...var_b
/ %}
""".replace(
"\n", " "
)
)
template = Template(template_str)
# List
with self.assertRaisesMessage(AttributeError, "'list' object has no attribute 'items'"):
template.render(
Context(
{
"var_a": "abc",
"var_b": [1, 2, 3],
}
)
)
# String
with self.assertRaisesMessage(AttributeError, "'str' object has no attribute 'items'"):
template.render(
Context(
{
"var_a": "abc",
"var_b": "def",
}
)
)