mirror of
https://github.com/django-components/django-components.git
synced 2025-08-04 14:28:18 +00:00
feat: support special chars # @ - . :
in component kwargs (#477)
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
parent
4646695b3e
commit
bf61df81b7
4 changed files with 332 additions and 7 deletions
38
README.md
38
README.md
|
@ -667,6 +667,44 @@ COMPONENTS = {
|
|||
}
|
||||
```
|
||||
|
||||
## Passing data to components
|
||||
|
||||
As seen above, you can pass arguments to components like so:
|
||||
|
||||
```django
|
||||
<body>
|
||||
{% component "calendar" date="2015-06-19" %}
|
||||
{% endcomponent %}
|
||||
</body>
|
||||
```
|
||||
|
||||
### Special characters
|
||||
|
||||
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 %}
|
||||
{% endcomponent %}
|
||||
</body>
|
||||
```
|
||||
|
||||
These can then be accessed inside `get_context_data` so:
|
||||
|
||||
```py
|
||||
@component.register("calendar")
|
||||
class Calendar(component.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"],
|
||||
"@click.native": kwargs["@click.native"]
|
||||
}
|
||||
```
|
||||
|
||||
## Component context and scope
|
||||
|
||||
By default, components are ISOLATED and CANNOT access context variables from the parent template. This is useful if you want to make sure that components don't accidentally access the outer context.
|
||||
|
|
204
src/django_components/template_parser.py
Normal file
204
src/django_components/template_parser.py
Normal file
|
@ -0,0 +1,204 @@
|
|||
"""
|
||||
Overrides for the Django Template system to allow finer control over template parsing.
|
||||
|
||||
Based on Django Slippers v0.6.2 - https://github.com/mixxorz/slippers/blob/main/slippers/template.py
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
from django.template.base import (
|
||||
FILTER_ARGUMENT_SEPARATOR,
|
||||
FILTER_SEPARATOR,
|
||||
FilterExpression,
|
||||
Parser,
|
||||
Variable,
|
||||
VariableDoesNotExist,
|
||||
constant_string,
|
||||
)
|
||||
from django.template.exceptions import TemplateSyntaxError
|
||||
from django.utils.regex_helper import _lazy_re_compile
|
||||
|
||||
######################################################################################################################
|
||||
# Custom FilterExpression
|
||||
#
|
||||
# This is a copy of the original FilterExpression. The only difference is to allow variable names to have extra special
|
||||
# characters: - : . @ #
|
||||
######################################################################################################################
|
||||
filter_raw_string = r"""
|
||||
^(?P<constant>{constant})|
|
||||
^(?P<var>[{var_chars}]+|{num})|
|
||||
(?:\s*{filter_sep}\s*
|
||||
(?P<filter_name>\w+)
|
||||
(?:{arg_sep}
|
||||
(?:
|
||||
(?P<constant_arg>{constant})|
|
||||
(?P<var_arg>[{var_chars}]+|{num})
|
||||
)
|
||||
)?
|
||||
)""".format(
|
||||
constant=constant_string,
|
||||
num=r"[-+\.]?\d[\d\.e]*",
|
||||
# The following is the only difference from the original FilterExpression. We allow variable names to have extra
|
||||
# special characters: - : . @ #
|
||||
var_chars=r"\w\-\:\@\.\#",
|
||||
filter_sep=re.escape(FILTER_SEPARATOR),
|
||||
arg_sep=re.escape(FILTER_ARGUMENT_SEPARATOR),
|
||||
)
|
||||
|
||||
filter_re = _lazy_re_compile(filter_raw_string, re.VERBOSE)
|
||||
|
||||
|
||||
class ComponentsFilterExpression(FilterExpression):
|
||||
def __init__(self, token: str, parser: Parser) -> None:
|
||||
# This method is exactly the same as the original FilterExpression.__init__ method, the only difference being
|
||||
# the value of `filter_re`.
|
||||
self.token = token
|
||||
matches = filter_re.finditer(token)
|
||||
var_obj = None
|
||||
filters: List[Any] = []
|
||||
upto = 0
|
||||
for match in matches:
|
||||
start = match.start()
|
||||
if upto != start:
|
||||
raise TemplateSyntaxError(
|
||||
"Could not parse some characters: " "%s|%s|%s" % (token[:upto], token[upto:start], token[start:])
|
||||
)
|
||||
if var_obj is None:
|
||||
var, constant = match["var"], match["constant"]
|
||||
if constant:
|
||||
try:
|
||||
var_obj = Variable(constant).resolve({})
|
||||
except VariableDoesNotExist:
|
||||
var_obj = None
|
||||
elif var is None:
|
||||
raise TemplateSyntaxError("Could not find variable at " "start of %s." % token)
|
||||
else:
|
||||
var_obj = Variable(var)
|
||||
else:
|
||||
filter_name = match["filter_name"]
|
||||
args = []
|
||||
constant_arg, var_arg = match["constant_arg"], match["var_arg"]
|
||||
if constant_arg:
|
||||
args.append((False, Variable(constant_arg).resolve({})))
|
||||
elif var_arg:
|
||||
args.append((True, Variable(var_arg)))
|
||||
filter_func = parser.find_filter(filter_name)
|
||||
self.args_check(filter_name, filter_func, args)
|
||||
filters.append((filter_func, args))
|
||||
upto = match.end()
|
||||
if upto != len(token):
|
||||
raise TemplateSyntaxError("Could not parse the remainder: '%s' " "from '%s'" % (token[upto:], token))
|
||||
|
||||
self.filters = filters
|
||||
self.var = var_obj
|
||||
self.is_var = isinstance(var_obj, Variable)
|
||||
|
||||
|
||||
######################################################################################################################
|
||||
# Custom token_kwargs
|
||||
#
|
||||
# Same as the original token_kwargs, but uses the ComponentsFilterExpression instead of the original FilterExpression.
|
||||
######################################################################################################################
|
||||
|
||||
# Regex for token keyword arguments
|
||||
kwarg_re = _lazy_re_compile(r"(?:([\w\-\:\@\.\#]+)=)?(.+)")
|
||||
|
||||
|
||||
def token_kwargs(bits: List[str], parser: Parser) -> Dict[str, FilterExpression]:
|
||||
"""
|
||||
Parse token keyword arguments and return a dictionary of the arguments
|
||||
retrieved from the ``bits`` token list.
|
||||
|
||||
`bits` is a list containing the remainder of the token (split by spaces)
|
||||
that is to be checked for arguments. Valid arguments are removed from this
|
||||
list.
|
||||
|
||||
There is no requirement for all remaining token ``bits`` to be keyword
|
||||
arguments, so return the dictionary as soon as an invalid argument format
|
||||
is reached.
|
||||
"""
|
||||
if not bits:
|
||||
return {}
|
||||
match = kwarg_re.match(bits[0])
|
||||
kwarg_format = match and match[1]
|
||||
if not kwarg_format:
|
||||
return {}
|
||||
|
||||
kwargs: Dict[str, FilterExpression] = {}
|
||||
while bits:
|
||||
if kwarg_format:
|
||||
match = kwarg_re.match(bits[0])
|
||||
if not match or not match[1]:
|
||||
return kwargs
|
||||
key, value = match.groups()
|
||||
del bits[:1]
|
||||
else:
|
||||
if len(bits) < 3 or bits[1] != "as":
|
||||
return kwargs
|
||||
key, value = bits[2], bits[0]
|
||||
del bits[:3]
|
||||
|
||||
# This is the only difference from the original token_kwargs. We use
|
||||
# the ComponentsFilterExpression instead of the original FilterExpression.
|
||||
kwargs[key] = ComponentsFilterExpression(value, parser)
|
||||
if bits and not kwarg_format:
|
||||
if bits[0] != "and":
|
||||
return kwargs
|
||||
del bits[:1]
|
||||
return kwargs
|
||||
|
||||
|
||||
def parse_bits(
|
||||
parser: Parser,
|
||||
bits: List[str],
|
||||
params: List[str],
|
||||
name: str,
|
||||
) -> Tuple[List, Dict]:
|
||||
"""
|
||||
Parse bits for template tag helpers simple_tag and inclusion_tag, in
|
||||
particular by detecting syntax errors and by extracting positional and
|
||||
keyword arguments.
|
||||
|
||||
This is a simplified version of `django.template.library.parse_bits`
|
||||
where we use custom regex to handle special characters in keyword names.
|
||||
"""
|
||||
args = []
|
||||
kwargs = {}
|
||||
unhandled_params = list(params)
|
||||
for bit in bits:
|
||||
# First we try to extract a potential kwarg from the bit
|
||||
kwarg = token_kwargs([bit], parser)
|
||||
if kwarg:
|
||||
# The kwarg was successfully extracted
|
||||
param, value = kwarg.popitem()
|
||||
if param in kwargs:
|
||||
# The keyword argument has already been supplied once
|
||||
raise TemplateSyntaxError("'%s' received multiple values for keyword argument '%s'" % (name, param))
|
||||
else:
|
||||
# All good, record the keyword argument
|
||||
kwargs[str(param)] = value
|
||||
if param in unhandled_params:
|
||||
# If using the keyword syntax for a positional arg, then
|
||||
# consume it.
|
||||
unhandled_params.remove(param)
|
||||
else:
|
||||
if kwargs:
|
||||
raise TemplateSyntaxError(
|
||||
"'%s' received some positional argument(s) after some " "keyword argument(s)" % name
|
||||
)
|
||||
else:
|
||||
# Record the positional argument
|
||||
args.append(parser.compile_filter(bit))
|
||||
try:
|
||||
# Consume from the list of expected positional arguments
|
||||
unhandled_params.pop(0)
|
||||
except IndexError:
|
||||
pass
|
||||
if unhandled_params:
|
||||
# Some positional arguments were not supplied
|
||||
raise TemplateSyntaxError(
|
||||
"'%s' did not receive value(s) for the argument(s): %s"
|
||||
% (name, ", ".join("'%s'" % p for p in unhandled_params))
|
||||
)
|
||||
return args, kwargs
|
|
@ -3,7 +3,6 @@ from typing import TYPE_CHECKING, List, Mapping, Optional, Tuple
|
|||
import django.template
|
||||
from django.template.base import FilterExpression, Node, NodeList, Parser, TextNode, Token, TokenType
|
||||
from django.template.exceptions import TemplateSyntaxError
|
||||
from django.template.library import parse_bits
|
||||
from django.utils.safestring import SafeString, mark_safe
|
||||
|
||||
from django_components.app_settings import ContextBehavior, app_settings
|
||||
|
@ -17,6 +16,7 @@ from django_components.middleware import (
|
|||
is_dependency_middleware_active,
|
||||
)
|
||||
from django_components.slots import FillNode, SlotNode, parse_slot_fill_nodes_from_component_nodelist
|
||||
from django_components.template_parser import parse_bits
|
||||
from django_components.utils import gen_id
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
@ -279,13 +279,7 @@ def parse_component_with_args(
|
|||
parser=parser,
|
||||
bits=bits,
|
||||
params=["tag_name", "name"],
|
||||
takes_context=False,
|
||||
name=tag_name,
|
||||
varargs=True,
|
||||
varkw=[],
|
||||
defaults=None,
|
||||
kwonly=[],
|
||||
kwonly_defaults=None,
|
||||
)
|
||||
|
||||
if tag_name != tag_args[0].token:
|
||||
|
|
89
tests/test_template_parser.py
Normal file
89
tests/test_template_parser.py
Normal file
|
@ -0,0 +1,89 @@
|
|||
from django.template import Context, Template
|
||||
from django.template.base import Parser
|
||||
|
||||
# isort: off
|
||||
from .django_test_setup import * # NOQA
|
||||
from .testutils import BaseTestCase
|
||||
|
||||
# isort: on
|
||||
|
||||
from django_components import component, types
|
||||
from django_components.component import safe_resolve_dict, safe_resolve_list
|
||||
from django_components.templatetags.component_tags import parse_component_with_args
|
||||
|
||||
|
||||
class ParserTest(BaseTestCase):
|
||||
def test_parses_args_kwargs(self):
|
||||
bits = ["component", "my_component", "42", "myvar", "key='val'", "key2=val2"]
|
||||
name, raw_args, raw_kwargs = parse_component_with_args(Parser(""), bits, "component")
|
||||
|
||||
ctx = {"myvar": {"a": "b"}, "val2": 1}
|
||||
args = safe_resolve_list(raw_args, ctx)
|
||||
kwargs = safe_resolve_dict(raw_kwargs, ctx)
|
||||
|
||||
self.assertEqual(name, "my_component")
|
||||
self.assertListEqual(args, [42, {"a": "b"}])
|
||||
self.assertDictEqual(kwargs, {"key": "val", "key2": 1})
|
||||
|
||||
def test_parses_special_kwargs(self):
|
||||
bits = [
|
||||
"component",
|
||||
"my_component",
|
||||
"date=date",
|
||||
"@lol=2",
|
||||
"na-me=bzz",
|
||||
"@event:na-me.mod=bzz",
|
||||
"#my-id=True",
|
||||
]
|
||||
name, raw_args, raw_kwargs = parse_component_with_args(Parser(""), bits, "component")
|
||||
|
||||
ctx = Context({"date": 2024, "bzz": "fzz"})
|
||||
args = safe_resolve_list(raw_args, ctx)
|
||||
kwargs = safe_resolve_dict(raw_kwargs, ctx)
|
||||
|
||||
self.assertEqual(name, "my_component")
|
||||
self.assertListEqual(args, [])
|
||||
self.assertDictEqual(
|
||||
kwargs,
|
||||
{
|
||||
"@event:na-me.mod": "fzz",
|
||||
"@lol": 2,
|
||||
"date": 2024,
|
||||
"na-me": "fzz",
|
||||
"#my-id": True,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class ParserComponentTest(BaseTestCase):
|
||||
def test_special_chars_accessible_via_kwargs(self):
|
||||
@component.register(name="test")
|
||||
class SimpleComponent(component.Component):
|
||||
template: types.django_html = """
|
||||
{{ date }}
|
||||
{{ id }}
|
||||
{{ on_click }}
|
||||
"""
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
return {
|
||||
"date": kwargs["my-date"],
|
||||
"id": kwargs["#some_id"],
|
||||
"on_click": kwargs["@click.native"],
|
||||
}
|
||||
|
||||
template_str: types.django_html = """
|
||||
{% load component_tags %}
|
||||
{% component "test" my-date="2015-06-19" @click.native=do_something #some_id=True %}
|
||||
{% endcomponent %}
|
||||
"""
|
||||
template = Template(template_str)
|
||||
rendered = template.render(Context({"do_something": "abc"}))
|
||||
self.assertHTMLEqual(
|
||||
rendered,
|
||||
"""
|
||||
2015-06-19
|
||||
True
|
||||
abc
|
||||
""",
|
||||
)
|
Loading…
Add table
Add a link
Reference in a new issue