feat: TagFormatter - Allow users to customize component template tags (#572)

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-18 16:58:56 +02:00 committed by GitHub
parent b89c09aa5f
commit 71d8679e8d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 1593 additions and 474 deletions

238
README.md
View file

@ -37,6 +37,7 @@ And this is what gets rendered (plus the CSS and Javascript you've specified):
- [Rendering HTML attributes](#rendering-html-attributes)
- [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)
- [Defining HTML/JS/CSS files](#defining-htmljscss-files)
- [Rendering JS/CSS dependencies](#rendering-jscss-dependencies)
- [Available settings](#available-settings)
@ -48,6 +49,34 @@ And this is what gets rendered (plus the CSS and Javascript you've specified):
## Release notes
**Version 0.90**
- All tags (`component`, `slot`, `fill`, ...) now support "self-closing" or "inline" form, where you can omit the closing tag:
```django
{# Before #}
{% component "button" %}{% endcomponent %}
{# After #}
{% component "button" / %}
```
- All tags now support the "dictionary key" or "aggregate" syntax (`kwarg:key=val`):
```django
{% component "button" attrs:class="hidden" %}
```
- You can change how the components are written in the template with [TagFormatter](#customizing-component-tags-with-tagformatter).
The default is `django_components.component_formatter`:
```django
{% component "button" href="..." disabled %}
Click me!
{% endcomponent %}
```
While `django_components.shorthand_component_formatter` allows you to write components like so:
```django
{% button href="..." disabled %}
Click me!
{% endbutton %}
🚨📢 **Version 0.85** Autodiscovery module resolution changed. Following undocumented behavior was removed:
- Previously, autodiscovery also imported any `[app]/components.py` files, and used `SETTINGS_MODULE` to search for component dirs.
@ -420,6 +449,10 @@ First load the `component_tags` tag library, then use the `component_[js/css]_de
<html>
```
> NOTE: Instead of writing `{% endcomponent %}` at the end, you can use a self-closing tag:
>
> `{% component "calendar" date="2015-06-19" / %}`
The output from the above template will be:
```html
@ -461,7 +494,7 @@ class SimpleComponent(Component):
hello: {{ hello }}
foo: {{ foo }}
kwargs: {{ kwargs|safe }}
slot_first: {% slot "first" required %}{% endslot %}
slot_first: {% slot "first" required / %}
"""
def get_context_data(self, arg1, arg2, **kwargs):
@ -597,7 +630,7 @@ class Calendar(Component):
template = """
<div class="calendar-component">
<div class="header">
{% slot "header" %}{% endslot %}
{% slot "header" / %}
</div>
<div class="body">
Today's date is <span>{{ date }}</span>
@ -1134,7 +1167,7 @@ To negate the meaning of `component_vars.is_filled`, simply treat it as boolean
```htmldjango
{% if not component_vars.is_filled.subtitle %}
<div class="subtitle">
{% slot "subtitle" %}{% endslot %}
{% slot "subtitle" / %}
</div>
{% endif %}
```
@ -1266,8 +1299,7 @@ so are still valid:
```django
<body>
{% component "calendar" my-date="2015-06-19" @click.native=do_something #some_id=True %}
{% endcomponent %}
{% component "calendar" my-date="2015-06-19" @click.native=do_something #some_id=True / %}
</body>
```
@ -1302,8 +1334,7 @@ But for that, we need to define this dictionary on Python side:
@register("my_comp")
class MyComp(Component):
template = """
{% component "other" attrs=attrs %}
{% endcomponent %}
{% component "other" attrs=attrs / %}
"""
def get_context_data(self, some_id: str):
@ -1334,8 +1365,7 @@ class MyComp(Component):
attrs:class="pa-4 flex"
attrs:data-some-id=some_id
attrs:@click.stop="onClickHandler"
%}
{% endcomponent %}
/ %}
"""
def get_context_data(self, some_id: str):
@ -1349,8 +1379,7 @@ Sweet! Now all the relevant HTML is inside the template, and we can move it to a
attrs:class="pa-4 flex"
attrs:data-some-id=some_id
attrs:@click.stop="onClickHandler"
%}
{% endcomponent %}
/ %}
```
> Note: It is NOT possible to define nested dictionaries, so
@ -1646,8 +1675,7 @@ class Parent(Component):
attrs:class="pa-0 border-solid border-red"
attrs:data-json=json_data
attrs:@click="(e) => onClick(e, 'from_parent')"
%}
{% endcomponent %}
/ %}
"""
def get_context_data(self, date: Date):
@ -1775,12 +1803,10 @@ First we use the `{% provide %}` tag to define the data we want to "provide" (ma
```django
{% provide "my_data" key="hi" another=123 %}
{% component "child" %} <--- Can access "my_data"
{% endcomponent %}
{% component "child" / %} <--- Can access "my_data"
{% endprovide %}
{% component "child" %} <--- Cannot access "my_data"
{% endcomponent %}
{% component "child" / %} <--- Cannot access "my_data"
```
Notice that the `provide` tag REQUIRES a name as a first argument. This is the _key_ by which we can then access the data passed to this tag.
@ -1854,8 +1880,7 @@ class ChildComponent(Component):
template_str = """
{% load component_tags %}
{% provide "my_data" key="hi" another=123 %}
{% component "child" %}
{% endcomponent %}
{% component "child" / %}
{% endprovide %}
"""
```
@ -1873,7 +1898,7 @@ By default, context variables are passed down the template as in regular Django
With this in mind, the `{% component %}` tag behaves similarly to `{% include %}` tag - inside the component tag, you can access all variables that were defined outside of it.
And just like with `{% include %}`, if you don't want a specific component template to have access to the parent context, add `only` to the end of the `{% component %}` tag:
And just like with `{% include %}`, if you don't want a specific component template to have access to the parent context, add `only` to the `{% component %}` tag:
```htmldjango
{% component "calendar" date="2015-06-19" only %}{% endcomponent %}
@ -1885,6 +1910,155 @@ If you find yourself using the `only` modifier often, you can set the [context_b
Components can also access the outer context in their context methods like `get_context_data` by accessing the property `self.outer_context`.
## Customizing component tags with TagFormatter
_New in version 0.89_
By default, components are rendered using the pair of `{% component %}` / `{% endcomponent %}` template tags:
```django
{% component "button" href="..." disabled %}
Click me!
{% endcomponent %}
{# or #}
{% component "button" href="..." disabled / %}
```
You can change this behaviour in the settings under the [`COMPONENTS.tag_formatter`](#tag-formatter-setting).
For example, if you set the tag formatter to `django_components.shorthand_component_formatter`, the components will use their name as the template tags:
```django
{% button href="..." disabled %}
Click me!
{% endbutton %}
{# or #}
{% button href="..." disabled / %}
```
### Available TagFormatters
django_components provides following predefined TagFormatters:
- **`ComponentFormatter` (`django_components.component_formatter`)**
Default
Uses the `component` and `endcomponent` tags, and the component name is gives as the first positional argument.
Example as block:
```django
{% component "button" href="..." %}
{% fill "content" %}
...
{% endfill %}
{% endcomponent %}
```
Example as inlined tag:
```django
{% component "button" href="..." / %}
```
- **`ShorthandComponentFormatter` (`django_components.shorthand_component_formatter`)**
Uses the component name as start tag, and `end<component_name>`
as an end tag.
Example as block:
```django
{% button href="..." %}
Click me!
{% endbutton %}
```
Example as inlined tag:
```django
{% button href="..." / %}
```
### Writing your own TagFormatter
#### Background
First, let's discuss how TagFormatters work, and how components are rendered in django_components.
When you render a component with `{% component %}` (or your own tag), the following happens:
1. `component` must be registered as a Django's template tag
2. Django triggers django_components's tag handler for tag `component`.
3. The tag handler passes the tag contents for pre-processing to `TagFormatter.parse()`.
So if you render this:
```django
{% component "button" href="..." disabled %}
{% endcomponent %}
```
Then `TagFormatter.parse()` will receive a following input:
```py
["component", '"button"', 'href="..."', 'disabled']
```
4. `TagFormatter` extracts the component name and the remaining input.
So, given the above, `TagFormatter.parse()` returns the following:
```py
TagResult(
component_name="button",
tokens=['href="..."', 'disabled']
)
```
5. The tag handler resumes, using the tokens returned from `TagFormatter`.
So, continuing the example, at this point the tag handler practically behaves as if you rendered:
```django
{% component href="..." disabled %}
```
6. Tag handler looks up the component `button`, and passes the args, kwargs, and slots to it.
#### TagFormatter
`TagFormatter` handles following parts of the process above:
- Generates start/end tags, given a component. This is what you then call from within your template as `{% component %}`.
- When you `{% component %}`, tag formatter pre-processes the tag contents, so it can link back the custom template tag to the right component.
To do so, subclass from `TagFormatterABC` and implement following method:
- `start_tag`
- `end_tag`
- `parse`
For example, this is the implementation of [`ShorthandComponentFormatter`](#available-tagformatters)
```py
class ShorthandComponentFormatter(TagFormatterABC):
# Given a component name, generate the start template tag
def start_tag(self, name: str) -> str:
return name # e.g. 'button'
# Given a component name, generate the start template tag
def end_tag(self, name: str) -> str:
return f"end{name}" # e.g. 'endbutton'
# Given a tag, e.g.
# `{% button href="..." disabled %}`
#
# The parser receives:
# `['button', 'href="..."', 'disabled']`
def parse(self, tokens: List[str]) -> TagResult:
tokens = [*tokens]
name = tokens.pop(0)
return TagResult(
name, # e.g. 'button'
tokens # e.g. ['href="..."', 'disabled']
)
```
That's it! And once your `TagFormatter` is ready, don't forget to update the settings!
## Defining HTML/JS/CSS files
django_component's management of files builds on top of [Django's `Media` class](https://docs.djangoproject.com/en/5.0/topics/forms/media/).
@ -2187,7 +2361,7 @@ COMPONENTS = {
}
```
### Context behavior
### Context behavior setting
> NOTE: `context_behavior` and `slot_context_behavior` options were merged in v0.70.
>
@ -2292,6 +2466,28 @@ But since `"cheese"` is not defined there, it's empty.
Notice that the variables defined with the `{% with %}` tag are ignored inside the `{% fill %}` tag with the `"isolated"` mode.
### Tag formatter setting
Set the [`TagFormatter`](#available-tagformatters) instance.
Can be set either as direct reference, or as an import string;
```py
COMPONENTS = {
"tag_formatter": "django_components.component_formatter"
}
```
Or
```py
from django_components import component_formatter
COMPONENTS = {
"tag_formatter": component_formatter
}
```
## Logging and debugging
Django components supports [logging with Django](https://docs.djangoproject.com/en/5.0/howto/logging/#logging-how-to). This can help with troubleshooting.

View file

@ -3,14 +3,27 @@ import django
# Public API
# isort: off
from django_components.autodiscover import autodiscover as autodiscover
from django_components.autodiscover import import_libraries as import_libraries
from django_components.autodiscover import (
autodiscover as autodiscover,
import_libraries as import_libraries,
)
from django_components.component import Component as Component
from django_components.component_registry import AlreadyRegistered as AlreadyRegistered
from django_components.component_registry import ComponentRegistry as ComponentRegistry
from django_components.component_registry import NotRegistered as NotRegistered
from django_components.component_registry import register as register
from django_components.component_registry import registry as registry
from django_components.component_registry import (
AlreadyRegistered as AlreadyRegistered,
ComponentRegistry as ComponentRegistry,
NotRegistered as NotRegistered,
register as register,
registry as registry,
)
from django_components.library import TagProtectedError as TagProtectedError
from django_components.tag_formatter import (
ComponentFormatter as ComponentFormatter,
ShorthandComponentFormatter as ShorthandComponentFormatter,
TagFormatterABC as TagFormatterABC,
TagResult as TagResult,
component_formatter as component_formatter,
component_shorthand_formatter as component_shorthand_formatter,
)
import django_components.types as types
# isort: on

View file

@ -1,8 +1,11 @@
from enum import Enum
from typing import Dict, List
from typing import TYPE_CHECKING, Dict, List, Union
from django.conf import settings
if TYPE_CHECKING:
from django_components.tag_formatter import TagFormatterABC
class ContextBehavior(str, Enum):
DJANGO = "django"
@ -115,5 +118,9 @@ class AppSettings:
valid_values = [behavior.value for behavior in ContextBehavior]
raise ValueError(f"Invalid context behavior: {raw_value}. Valid options are {valid_values}")
@property
def TAG_FORMATTER(self) -> Union["TagFormatterABC", str]:
return self.settings.get("tag_formatter", "django_components.component_formatter")
app_settings = AppSettings()

View file

@ -5,69 +5,42 @@
from typing import Any, Dict, List, Mapping, Optional, Tuple
from django.template import Context, Node
from django.template.base import FilterExpression
from django.utils.html import conditional_escape, format_html
from django.utils.safestring import SafeString, mark_safe
from django_components.template_parser import process_aggregate_kwargs
from django_components.expression import Expression, safe_resolve
HTML_ATTRS_DEFAULTS_KEY = "defaults"
HTML_ATTRS_ATTRS_KEY = "attrs"
def _default(val: Any, default_val: Any) -> Any:
return val if val is not None else default_val
class HtmlAttrsNode(Node):
def __init__(
self,
attributes: Optional[FilterExpression],
default_attrs: Optional[FilterExpression],
kwargs: List[Tuple[str, FilterExpression]],
attributes: Optional[Expression],
defaults: Optional[Expression],
kwargs: List[Tuple[str, Expression]],
):
self.attributes = attributes
self.default_attrs = default_attrs
self.defaults = defaults
self.kwargs = kwargs
def render(self, context: Context) -> str:
append_attrs: List[Tuple[str, Any]] = []
attrs_and_defaults_from_kwargs = {}
# Resolve kwargs, while also extracting attrs and defaults keys
# Resolve all data
for key, value in self.kwargs:
resolved_value = value.resolve(context)
if key.startswith(f"{HTML_ATTRS_ATTRS_KEY}:") or key.startswith(f"{HTML_ATTRS_DEFAULTS_KEY}:"):
attrs_and_defaults_from_kwargs[key] = resolved_value
continue
# NOTE: These were already extracted into separate variables, so
# ignore them here.
elif key == HTML_ATTRS_ATTRS_KEY or key == HTML_ATTRS_DEFAULTS_KEY:
continue
resolved_value = safe_resolve(value, context)
append_attrs.append((key, resolved_value))
# NOTE: Here we delegate validation to `process_aggregate_kwargs`, which should
# raise error if the dict includes both `attrs` and `attrs:` keys.
#
# So by assigning the `attrs` and `defaults` keys, users are forced to use only
# one approach or the other, but not both simultaneously.
if self.attributes:
attrs_and_defaults_from_kwargs[HTML_ATTRS_ATTRS_KEY] = self.attributes.resolve(context)
if self.default_attrs:
attrs_and_defaults_from_kwargs[HTML_ATTRS_DEFAULTS_KEY] = self.default_attrs.resolve(context)
defaults = safe_resolve(self.defaults, context) if self.defaults else {}
attrs = safe_resolve(self.attributes, context) if self.attributes else {}
# Turn `{"attrs:blabla": 1}` into `{"attrs": {"blabla": 1}}`
attrs_and_defaults_from_kwargs = process_aggregate_kwargs(attrs_and_defaults_from_kwargs)
# NOTE: We want to allow to use `html_attrs` even without `attrs` or `defaults` params
# Or when they are None
attrs = _default(attrs_and_defaults_from_kwargs.get(HTML_ATTRS_ATTRS_KEY, None), {})
default_attrs = _default(attrs_and_defaults_from_kwargs.get(HTML_ATTRS_DEFAULTS_KEY, None), {})
final_attrs = {**default_attrs, **attrs}
# Merge it
final_attrs = {**defaults, **attrs}
final_attrs = append_attributes(*final_attrs.items(), *append_attrs)
# Render to HTML attributes
return attributes_to_string(final_attrs)

View file

@ -477,7 +477,7 @@ class ComponentNode(Node):
def __init__(
self,
name_fexp: FilterExpression,
name: str,
context_args: List[FilterExpression],
context_kwargs: Mapping[str, FilterExpression],
isolated_context: bool = False,
@ -485,7 +485,7 @@ class ComponentNode(Node):
component_id: Optional[str] = None,
) -> None:
self.component_id = component_id or gen_id()
self.name_fexp = name_fexp
self.name = name
self.context_args = context_args or []
self.context_kwargs = context_kwargs or {}
self.isolated_context = isolated_context
@ -494,15 +494,14 @@ class ComponentNode(Node):
def __repr__(self) -> str:
return "<ComponentNode: {}. Contents: {!r}>".format(
self.name_fexp,
self.name,
getattr(self, "nodelist", None), # 'nodelist' attribute only assigned later.
)
def render(self, context: Context) -> str:
trace_msg("RENDR", "COMP", self.name_fexp, self.component_id)
trace_msg("RENDR", "COMP", self.name, self.component_id)
resolved_component_name = self.name_fexp.resolve(context)
component_cls: Type[Component] = registry.get(resolved_component_name)
component_cls: Type[Component] = registry.get(self.name)
# Resolve FilterExpressions and Variables that were passed as args to the
# component, then call component's context method
@ -532,8 +531,8 @@ class ComponentNode(Node):
f"Detected duplicate fill tag name '{resolved_name}'."
)
resolved_slot_default_var = fill_node.resolve_slot_default(context, resolved_component_name)
resolved_slot_data_var = fill_node.resolve_slot_data(context, resolved_component_name)
resolved_slot_default_var = fill_node.resolve_slot_default(context, self.name)
resolved_slot_data_var = fill_node.resolve_slot_data(context, self.name)
fill_content[resolved_name] = FillContent(
content_func=_nodelist_to_slot_render_func(fill_node.nodelist),
slot_default_var=resolved_slot_default_var,
@ -541,7 +540,7 @@ class ComponentNode(Node):
)
component: Component = component_cls(
registered_name=resolved_component_name,
registered_name=self.name,
outer_context=context,
fill_content=fill_content,
component_id=self.component_id,
@ -557,7 +556,7 @@ class ComponentNode(Node):
kwargs=resolved_context_kwargs,
)
trace_msg("RENDR", "COMP", self.name_fexp, self.component_id, "...Done!")
trace_msg("RENDR", "COMP", self.name, self.component_id, "...Done!")
return output

View file

@ -1,25 +1,16 @@
from typing import TYPE_CHECKING, Callable, Dict, List, NamedTuple, Optional, Set, Type, TypeVar
from typing import TYPE_CHECKING, Callable, Dict, NamedTuple, Optional, Set, Type, TypeVar
from django.template import Library
from django_components.library import is_tag_protected, mark_protected_tags, register_tag_from_formatter
from django_components.tag_formatter import get_tag_formatter
if TYPE_CHECKING:
from django_components.component import Component
_TComp = TypeVar("_TComp", bound=Type["Component"])
PROTECTED_TAGS = [
"component",
"component_dependencies",
"component_css_dependencies",
"component_js_dependencies",
"fill",
"html_attrs",
"provide",
"slot",
]
class AlreadyRegistered(Exception):
pass
@ -28,20 +19,10 @@ class NotRegistered(Exception):
pass
# Why do we store the tags with the component?
# Why do we store the tags with the components?
#
# Each component may be associated with two template tags - One for "block"
# and one for "inline" usage. E.g. in the following snippets, the template
# tags are `component` and `#component`:
#
# `{% component "abc" %}{% endcomponent %}`
# `{% #component "abc" %}`
#
# (NOTE: While `endcomponent` also looks like a template tag, we don't have to register
# it, because it simply marks the end of body.)
#
# With the component tag formatter (configurable tags per component class),
# each component may have a unique set of template tags.
# With the addition of TagFormatter, each component class may have a unique
# set of template tags.
#
# For user's convenience, we automatically add/remove the tags from Django's tag Library,
# when a component is (un)registered.
@ -49,12 +30,7 @@ class NotRegistered(Exception):
# Thus we need to remember which component used which template tags.
class ComponentRegistryEntry(NamedTuple):
cls: Type["Component"]
block_tag: str
inline_tag: str
@property
def tags(self) -> List[str]:
return [self.block_tag, self.inline_tag]
tag: str
class ComponentRegistry:
@ -111,7 +87,7 @@ class ComponentRegistry:
# On the other hand, if user provided their own Library instance,
# it is up to the user to use `mark_protected_tags` if they want
# to protect any tags.
mark_protected_tags(tag_library, PROTECTED_TAGS)
mark_protected_tags(tag_library)
lib = self._library = tag_library
return lib
@ -137,21 +113,14 @@ class ComponentRegistry:
if existing_component and existing_component.cls._class_hash != component._class_hash:
raise AlreadyRegistered('The component "%s" has already been registered' % name)
block_tag = "component"
inline_tag = "#component"
entry = ComponentRegistryEntry(
cls=component,
block_tag=block_tag,
inline_tag=inline_tag,
)
entry = self._register_to_library(name, component)
# Keep track of which components use which tags, because multiple components may
# use the same tag.
for tag in entry.tags:
if tag not in self._tags:
self._tags[tag] = set()
self._tags[tag].add(name)
tag = entry.tag
if tag not in self._tags:
self._tags[tag] = set()
self._tags[tag].add(name)
self._registry[name] = entry
@ -180,22 +149,20 @@ class ComponentRegistry:
self.get(name)
entry = self._registry[name]
tag = entry.tag
# Unregister the tag from library if this was the last component using this tag
for tag in entry.tags:
# Unlink component from tag
self._tags[tag].remove(name)
# Unlink component from tag
self._tags[tag].remove(name)
# Cleanup
is_tag_empty = not len(self._tags[tag])
if is_tag_empty:
del self._tags[tag]
# Do NOT unregister tag if it's protected
is_protected = is_tag_protected(self.library, tag)
if is_protected:
continue
# Cleanup
is_tag_empty = not len(self._tags[tag])
if is_tag_empty:
del self._tags[tag]
# Only unregister a tag if it's NOT protected
is_protected = is_tag_protected(self.library, tag)
if not is_protected:
# Unregister the tag from library if this was the last component using this tag
if is_tag_empty and tag in self.library.tags:
del self.library.tags[tag]
@ -268,6 +235,19 @@ class ComponentRegistry:
self._registry = {}
self._tags = {}
def _register_to_library(
self,
comp_name: str,
component: Type["Component"],
) -> ComponentRegistryEntry:
# Lazily import to avoid circular dependencies
from django_components.templatetags.component_tags import component as do_component
formatter = get_tag_formatter()
tag = register_tag_from_formatter(self.library, do_component, formatter, comp_name)
return ComponentRegistryEntry(cls=component, tag=tag)
# This variable represents the global component registry
registry: ComponentRegistry = ComponentRegistry()
@ -326,13 +306,3 @@ def register(name: str, registry: Optional[ComponentRegistry] = None) -> Callabl
return component
return decorator
def mark_protected_tags(lib: Library, tags: List[str]) -> None:
# By marking the library as default,
lib._protected_tags = [*tags]
def is_tag_protected(lib: Library, tag: str) -> bool:
protected_tags = getattr(lib, "_protected_tags", [])
return tag in protected_tags

View file

@ -4,6 +4,14 @@ from django.template import Context
from django.template.base import FilterExpression, Parser
class AggregateFilterExpression:
def __init__(self, dict: Dict[str, FilterExpression]) -> None:
self.dict = dict
Expression = Union[FilterExpression, AggregateFilterExpression]
def resolve_expression_as_identifier(
context: Context,
fexp: FilterExpression,
@ -20,19 +28,22 @@ def resolve_expression_as_identifier(
return resolved
def safe_resolve_list(args: List[FilterExpression], context: Context) -> List:
def safe_resolve_list(args: List[Expression], context: Context) -> List:
return [safe_resolve(arg, context) for arg in args]
def safe_resolve_dict(
kwargs: Union[Mapping[str, FilterExpression], Dict[str, FilterExpression]],
kwargs: Union[Mapping[str, Expression], Dict[str, Expression]],
context: Context,
) -> Dict:
return {key: safe_resolve(kwarg, context) for key, kwarg in kwargs.items()}
def safe_resolve(context_item: FilterExpression, context: Context) -> Any:
def safe_resolve(context_item: Expression, context: Context) -> Any:
"""Resolve FilterExpressions and Variables in context if possible. Return other items unchanged."""
if isinstance(context_item, AggregateFilterExpression):
return safe_resolve_dict(context_item.dict, context)
return context_item.resolve(context) if hasattr(context_item, "resolve") else context_item
@ -42,5 +53,11 @@ def resolve_string(
context: Optional[Mapping[str, Any]] = None,
) -> str:
parser = parser or Parser([])
context = context or {}
context = Context(context or {})
return parser.compile_filter(s).resolve(context)
def is_aggregate_key(key: str) -> bool:
# NOTE: If we get a key that starts with `:`, like `:class`, we do not split it.
# This syntax is used by Vue and AlpineJS.
return ":" in key and not key.startswith(":")

View file

@ -0,0 +1,60 @@
"""Module for interfacing with Django's Library (`django.template.library`)"""
from typing import Callable, List, Optional
from django.template.base import Node, Parser, Token
from django.template.library import Library
from django_components.tag_formatter import InternalTagFormatter
class TagProtectedError(Exception):
pass
PROTECTED_TAGS = [
"component_dependencies",
"component_css_dependencies",
"component_js_dependencies",
"fill",
"html_attrs",
"provide",
"slot",
]
"""
These are the names that users cannot choose for their components,
as they would conflict with other tags in the Library.
"""
def register_tag(
library: Library,
tag: str,
tag_fn: Callable[[Parser, Token, str], Node],
) -> None:
# Register inline tag
if is_tag_protected(library, tag):
raise TagProtectedError('Cannot register tag "%s", this tag name is protected' % tag)
else:
library.tag(tag, lambda parser, token: tag_fn(parser, token, tag))
def register_tag_from_formatter(
library: Library,
tag_fn: Callable[[Parser, Token, str], Node],
formatter: InternalTagFormatter,
component_name: str,
) -> str:
tag = formatter.start_tag(component_name)
register_tag(library, tag, tag_fn)
return tag
def mark_protected_tags(lib: Library, tags: Optional[List[str]] = None) -> None:
protected_tags = tags if tags is not None else PROTECTED_TAGS
lib._protected_tags = [*protected_tags]
def is_tag_protected(lib: Library, tag: str) -> bool:
protected_tags = getattr(lib, "_protected_tags", [])
return tag in protected_tags

View file

@ -16,10 +16,9 @@ from django_components.context import (
_INJECT_CONTEXT_KEY_PREFIX,
_ROOT_CTX_CONTEXT_KEY,
)
from django_components.expression import resolve_expression_as_identifier, safe_resolve_dict
from django_components.expression import Expression, resolve_expression_as_identifier, safe_resolve_dict
from django_components.logger import trace_msg
from django_components.node import NodeTraverse, nodelist_has_content, walk_nodelist
from django_components.template_parser import process_aggregate_kwargs
from django_components.utils import gen_id
DEFAULT_SLOT_KEY = "_DJANGO_COMPONENTS_DEFAULT_SLOT"
@ -119,7 +118,7 @@ class SlotNode(Node):
is_required: bool = False,
is_default: bool = False,
node_id: Optional[str] = None,
slot_kwargs: Optional[Dict[str, FilterExpression]] = None,
slot_kwargs: Optional[Dict[str, Expression]] = None,
):
self.name = name
self.nodelist = nodelist
@ -173,7 +172,6 @@ class SlotNode(Node):
# are made available through a variable name that was set on the `{% fill %}`
# tag.
slot_kwargs = safe_resolve_dict(self.slot_kwargs, context)
slot_kwargs = process_aggregate_kwargs(slot_kwargs)
data_var = slot_fill.slot_data_var
if data_var:
if not data_var.isidentifier():

View file

@ -0,0 +1,219 @@
import abc
import re
from typing import List, NamedTuple
from django.template import TemplateSyntaxError
from django.utils.module_loading import import_string
from django_components.app_settings import app_settings
from django_components.expression import resolve_string
from django_components.template_parser import VAR_CHARS
from django_components.utils import is_str_wrapped_in_quotes
TAG_RE = re.compile(r"^[{chars}]+$".format(chars=VAR_CHARS))
class TagResult(NamedTuple):
"""The return value from `TagFormatter.parse()`"""
component_name: str
"""Component name extracted from the template tag"""
tokens: List[str]
"""Remaining tokens (words) that were passed to the tag, with component name removed"""
class TagFormatterABC(abc.ABC):
@abc.abstractmethod
def start_tag(self, name: str) -> str:
"""Formats the start tag of a component."""
...
@abc.abstractmethod
def end_tag(self, name: str) -> str:
"""Formats the end tag of a block component."""
...
@abc.abstractmethod
def parse(self, tokens: List[str]) -> TagResult:
"""
Given the tokens (words) of a component start tag, this function extracts
the component name from the tokens list, and returns `TagResult`, which
is a tuple of `(component_name, remaining_tokens)`.
Example:
Given a component declarations:
`{% component "my_comp" key=val key2=val2 %}`
This function receives a list of tokens
`['component', '"my_comp"', 'key=val', 'key2=val2']`
`component` is the tag name, which we drop. `"my_comp"` is the component name,
but we must remove the extra quotes. And we pass remaining tokens unmodified,
as that's the input to the component.
So in the end, we return a tuple:
`('my_comp', ['key=val', 'key2=val2'])`
"""
...
class InternalTagFormatter:
"""
Internal wrapper around user-provided TagFormatters, so that we validate the outputs.
"""
def __init__(self, tag_formatter: TagFormatterABC):
self.tag_formatter = tag_formatter
def start_tag(self, name: str) -> str:
tag = self.tag_formatter.start_tag(name)
self._validate_tag(tag, "start_tag")
return tag
def end_tag(self, name: str) -> str:
tag = self.tag_formatter.end_tag(name)
self._validate_tag(tag, "end_tag")
return tag
def parse(self, tokens: List[str]) -> TagResult:
return self.tag_formatter.parse(tokens)
# NOTE: We validate the generated tags, so they contain only valid characters (\w - : . @ #)
# and NO SPACE. Otherwise we wouldn't be able to distinguish a "multi-word" tag from several
# single-word tags.
def _validate_tag(self, tag: str, tag_type: str) -> None:
if not tag:
raise ValueError(
f"{self.tag_formatter.__class__.__name__} returned an invalid tag for {tag_type}: '{tag}'."
f" Tag cannot be empty"
)
if not TAG_RE.match(tag):
raise ValueError(
f"{self.tag_formatter.__class__.__name__} returned an invalid tag for {tag_type}: '{tag}'."
f" Tag must contain only following chars: {VAR_CHARS}"
)
class ComponentFormatter(TagFormatterABC):
"""
The original django_component's component tag formatter, it uses the `component`
and `endcomponent` tags, and the component name is gives as the first positional arg.
Example as block:
```django
{% component "mycomp" abc=123 %}
{% fill "myfill" %}
...
{% endfill %}
{% endcomponent %}
```
Example as inlined tag:
```django
{% component "mycomp" abc=123 / %}
```
"""
def __init__(self, tag: str):
self.tag = tag
def start_tag(self, name: str) -> str:
return self.tag
def end_tag(self, name: str) -> str:
return f"end{self.tag}"
def parse(self, tokens: List[str]) -> TagResult:
tag, *args = tokens
if not args:
raise TemplateSyntaxError(f"{self.__class__.__name__}: Component tag did not receive tag name")
# If the first arg is a kwarg, not a positional arg, then look for the "name" kwarg
# for component name.
if "=" in args[0]:
comp_name = None
final_args = []
for kwarg in args:
if not kwarg.startswith("name="):
final_args.append(kwarg)
continue
if comp_name:
raise TemplateSyntaxError(
f"ComponentFormatter: 'name' kwarg for component '{comp_name}'" " was defined more than once."
)
# NOTE: We intentionally do NOT add to `final_args` here
# because we want to remove the the `name=` kwarg from args list
comp_name = kwarg[5:]
else:
comp_name = args.pop(0)
final_args = args
if not comp_name:
raise TemplateSyntaxError("Component name must be a non-empty quoted string, e.g. 'my_comp'")
if not is_str_wrapped_in_quotes(comp_name):
raise TemplateSyntaxError(f"Component name must be a string 'literal', got: {comp_name}")
# Remove the quotes
comp_name = resolve_string(comp_name)
return TagResult(comp_name, final_args)
class ShorthandComponentFormatter(TagFormatterABC):
"""
The component tag formatter that uses `<name>` / `end<name>` tags.
This is similar to django-web-components and django-slippers syntax.
Example as block:
```django
{% mycomp abc=123 %}
{% fill "myfill" %}
...
{% endfill %}
{% endmycomp %}
```
Example as inlined tag:
```django
{% mycomp abc=123 / %}
```
"""
def start_tag(self, name: str) -> str:
return name
def end_tag(self, name: str) -> str:
return f"end{name}"
def parse(self, tokens: List[str]) -> TagResult:
tokens = [*tokens]
name = tokens.pop(0)
return TagResult(name, tokens)
def get_tag_formatter() -> InternalTagFormatter:
"""Returns an instance of the currently configured component tag formatter."""
# Allow users to configure the component TagFormatter
formatter_cls_or_str = app_settings.TAG_FORMATTER
if isinstance(formatter_cls_or_str, str):
tag_formatter: TagFormatterABC = import_string(formatter_cls_or_str)
else:
tag_formatter = formatter_cls_or_str
return InternalTagFormatter(tag_formatter)
# Default formatters
component_formatter = ComponentFormatter("component")
component_shorthand_formatter = ShorthandComponentFormatter()

View file

@ -25,6 +25,9 @@ from django.utils.regex_helper import _lazy_re_compile
# This is a copy of the original FilterExpression. The only difference is to allow variable names to have extra special
# characters: - : . @ #
######################################################################################################################
VAR_CHARS = r"\w\-\:\@\.\#"
filter_raw_string = r"""
^(?P<constant>{constant})|
^(?P<var>[{var_chars}]+|{num})|
@ -41,7 +44,7 @@ filter_raw_string = r"""
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\-\:\@\.\#",
var_chars=VAR_CHARS,
filter_sep=re.escape(FILTER_SEPARATOR),
arg_sep=re.escape(FILTER_ARGUMENT_SEPARATOR),
)
@ -102,7 +105,7 @@ class ComponentsFilterExpression(FilterExpression):
######################################################################################################################
# Regex for token keyword arguments
kwarg_re = _lazy_re_compile(r"(?:([\w\-\:\@\.\#]+)=)?(.+)")
kwarg_re = _lazy_re_compile(r"(?:([{var_chars}]+)=)?(.+)".format(var_chars=VAR_CHARS))
def token_kwargs(bits: List[str], parser: Parser) -> Dict[str, FilterExpression]:
@ -261,9 +264,7 @@ def process_aggregate_kwargs(kwargs: Mapping[str, Any]) -> Dict[str, Any]:
processed_kwargs = {}
nested_kwargs: Dict[str, Dict[str, Any]] = {}
for key, val in kwargs.items():
# NOTE: If we get a key that starts with `:`, like `:class`, we do not split it.
# This syntax is used by Vue and AlpineJS.
if ":" not in key or key.startswith(":"):
if not is_aggregate_key(key):
processed_kwargs[key] = val
continue
@ -283,3 +284,9 @@ def process_aggregate_kwargs(kwargs: Mapping[str, Any]) -> Dict[str, Any]:
processed_kwargs[key] = val
return processed_kwargs
def is_aggregate_key(key: str) -> bool:
# NOTE: If we get a key that starts with `:`, like `:class`, we do not split it.
# This syntax is used by Vue and AlpineJS.
return ":" in key and not key.startswith(":")

View file

@ -1,4 +1,4 @@
from typing import TYPE_CHECKING, Dict, List, Mapping, Optional, Tuple
from typing import TYPE_CHECKING, Callable, Dict, List, NamedTuple, Optional, Set, Tuple, Union
import django.template
from django.template.base import FilterExpression, NodeList, Parser, Token
@ -10,7 +10,7 @@ from django_components.attributes import HtmlAttrsNode
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 resolve_string
from django_components.expression import AggregateFilterExpression, Expression, resolve_string
from django_components.logger import trace_msg
from django_components.middleware import (
CSS_DEPENDENCY_PLACEHOLDER,
@ -19,13 +19,16 @@ from django_components.middleware import (
)
from django_components.provide import ProvideNode
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
from django_components.tag_formatter import get_tag_formatter
from django_components.template_parser import is_aggregate_key, parse_bits, process_aggregate_kwargs
from django_components.utils import gen_id, is_str_wrapped_in_quotes
if TYPE_CHECKING:
from django_components.component import Component
# 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
register = django.template.Library()
@ -35,7 +38,7 @@ SLOT_DATA_ATTR = "data"
SLOT_DEFAULT_ATTR = "default"
def get_components_from_registry(registry: ComponentRegistry) -> List["Component"]:
def _get_components_from_registry(registry: ComponentRegistry) -> List["Component"]:
"""Returns a list unique components from the registry."""
unique_component_classes = set(registry.all().values())
@ -47,7 +50,7 @@ def get_components_from_registry(registry: ComponentRegistry) -> List["Component
return components
def get_components_from_preload_str(preload_str: str) -> List["Component"]:
def _get_components_from_preload_str(preload_str: str) -> List["Component"]:
"""Returns a list of unique components from a comma-separated str"""
components = []
@ -62,83 +65,89 @@ def get_components_from_preload_str(preload_str: str) -> List["Component"]:
@register.simple_tag(name="component_dependencies")
def component_dependencies_tag(preload: str = "") -> SafeString:
def component_dependencies(preload: str = "") -> SafeString:
"""Marks location where CSS link and JS script tags should be rendered."""
if is_dependency_middleware_active():
preloaded_dependencies = []
for component in get_components_from_preload_str(preload):
for component in _get_components_from_preload_str(preload):
preloaded_dependencies.append(RENDERED_COMMENT_TEMPLATE.format(name=component.registered_name))
return mark_safe("\n".join(preloaded_dependencies) + CSS_DEPENDENCY_PLACEHOLDER + JS_DEPENDENCY_PLACEHOLDER)
else:
rendered_dependencies = []
for component in get_components_from_registry(component_registry):
for component in _get_components_from_registry(component_registry):
rendered_dependencies.append(component.render_dependencies())
return mark_safe("\n".join(rendered_dependencies))
@register.simple_tag(name="component_css_dependencies")
def component_css_dependencies_tag(preload: str = "") -> SafeString:
def component_css_dependencies(preload: str = "") -> SafeString:
"""Marks location where CSS link tags should be rendered."""
if is_dependency_middleware_active():
preloaded_dependencies = []
for component in get_components_from_preload_str(preload):
for component in _get_components_from_preload_str(preload):
preloaded_dependencies.append(RENDERED_COMMENT_TEMPLATE.format(name=component.registered_name))
return mark_safe("\n".join(preloaded_dependencies) + CSS_DEPENDENCY_PLACEHOLDER)
else:
rendered_dependencies = []
for component in get_components_from_registry(component_registry):
for component in _get_components_from_registry(component_registry):
rendered_dependencies.append(component.render_css_dependencies())
return mark_safe("\n".join(rendered_dependencies))
@register.simple_tag(name="component_js_dependencies")
def component_js_dependencies_tag(preload: str = "") -> SafeString:
def component_js_dependencies(preload: str = "") -> SafeString:
"""Marks location where JS script tags should be rendered."""
if is_dependency_middleware_active():
preloaded_dependencies = []
for component in get_components_from_preload_str(preload):
for component in _get_components_from_preload_str(preload):
preloaded_dependencies.append(RENDERED_COMMENT_TEMPLATE.format(name=component.registered_name))
return mark_safe("\n".join(preloaded_dependencies) + JS_DEPENDENCY_PLACEHOLDER)
else:
rendered_dependencies = []
for component in get_components_from_registry(component_registry):
for component in _get_components_from_registry(component_registry):
rendered_dependencies.append(component.render_js_dependencies())
return mark_safe("\n".join(rendered_dependencies))
@register.tag("slot")
def do_slot(parser: Parser, token: Token) -> SlotNode:
# e.g. {% slot <name> ... %}
tag_name, *args = token.split_contents()
slot_name, is_default, is_required, slot_kwargs = _parse_slot_args(parser, args, tag_name)
# Use a unique ID to be able to tie the fill nodes with components and slots
# NOTE: MUST be called BEFORE `parser.parse()` to ensure predictable numbering
slot_id = gen_id()
trace_msg("PARSE", "SLOT", slot_name, slot_id)
def slot(parser: Parser, token: Token) -> SlotNode:
bits = token.split_contents()
tag = _parse_tag(
"slot",
parser,
bits,
params=["name"],
flags=[SLOT_DEFAULT_OPTION_KEYWORD, SLOT_REQUIRED_OPTION_KEYWORD],
keywordonly_kwargs=True,
repeatable_kwargs=False,
end_tag="endslot",
)
data = _parse_slot_args(parser, tag)
nodelist = parser.parse(parse_until=["endslot"])
parser.delete_first_token()
trace_msg("PARSE", "SLOT", data.name, tag.id)
body = tag.parse_body()
slot_node = SlotNode(
slot_name,
nodelist,
is_required=is_required,
is_default=is_default,
node_id=slot_id,
slot_kwargs=slot_kwargs,
name=data.name,
nodelist=body,
is_required=tag.flags[SLOT_REQUIRED_OPTION_KEYWORD],
is_default=tag.flags[SLOT_DEFAULT_OPTION_KEYWORD],
node_id=tag.id,
slot_kwargs=tag.kwargs,
)
trace_msg("PARSE", "SLOT", slot_name, slot_id, "...Done!")
trace_msg("PARSE", "SLOT", data.name, tag.id, "...Done!")
return slot_node
@register.tag("fill")
def do_fill(parser: Parser, token: Token) -> FillNode:
def fill(parser: Parser, token: Token) -> FillNode:
"""
Block tag whose contents 'fill' (are inserted into) an identically named
'slot'-block in the component template referred to by a parent component.
@ -147,32 +156,34 @@ def do_fill(parser: Parser, token: Token) -> FillNode:
This tag is available only within a {% component %}..{% endcomponent %} block.
Runtime checks should prohibit other usages.
"""
# e.g. {% fill <name> %}
tag_name, *args = token.split_contents()
slot_name_fexp, slot_default_var_fexp, slot_data_var_fexp = _parse_fill_args(parser, args, tag_name)
bits = token.split_contents()
tag = _parse_tag(
"fill",
parser,
bits,
params=["name"],
keywordonly_kwargs=[SLOT_DATA_ATTR, SLOT_DEFAULT_ATTR],
repeatable_kwargs=False,
end_tag="endfill",
)
data = _parse_fill_args(parser, tag)
# Use a unique ID to be able to tie the fill nodes with components and slots
# NOTE: MUST be called BEFORE `parser.parse()` to ensure predictable numbering
fill_id = gen_id()
trace_msg("PARSE", "FILL", str(slot_name_fexp), fill_id)
nodelist = parser.parse(parse_until=["endfill"])
parser.delete_first_token()
trace_msg("PARSE", "FILL", str(data.slot_name), tag.id)
body = tag.parse_body()
fill_node = FillNode(
nodelist,
name_fexp=slot_name_fexp,
slot_default_var_fexp=slot_default_var_fexp,
slot_data_var_fexp=slot_data_var_fexp,
node_id=fill_id,
nodelist=body,
name_fexp=data.slot_name,
slot_default_var_fexp=data.slot_default_var,
slot_data_var_fexp=data.slot_data_var,
node_id=tag.id,
)
trace_msg("PARSE", "FILL", str(slot_name_fexp), fill_id, "...Done!")
trace_msg("PARSE", "FILL", str(data.slot_name), tag.id, "...Done!")
return fill_node
@register.tag(name="component")
def do_component(parser: Parser, token: Token) -> ComponentNode:
def component(parser: Parser, token: Token, tag_name: str) -> ComponentNode:
"""
To give the component access to the template context:
{% component "name" positional_arg keyword_arg=value ... %}
@ -185,64 +196,83 @@ def do_component(parser: Parser, token: Token) -> ComponentNode:
be either the first positional argument or, if there are no positional
arguments, passed as 'name'.
"""
bits = token.split_contents()
bits, isolated_context = _check_for_isolated_context_keyword(bits)
component_name, context_args, context_kwargs = _parse_component_with_args(parser, bits, "component")
# Use a unique ID to be able to tie the fill nodes with components and slots
# NOTE: MUST be called BEFORE `parser.parse()` to ensure predictable numbering
component_id = gen_id()
trace_msg("PARSE", "COMP", component_name, component_id)
# Let the TagFormatter pre-process the tokens
formatter = get_tag_formatter()
result = formatter.parse([*bits])
end_tag = formatter.end_tag(result.component_name)
body: NodeList = parser.parse(parse_until=["endcomponent"])
parser.delete_first_token()
# NOTE: The tokens returned from TagFormatter.parse do NOT include the tag itself
bits = [bits[0], *result.tokens]
tag = _parse_tag(
tag_name,
parser,
bits,
params=True, # Allow many args
flags=["only"],
keywordonly_kwargs=True,
repeatable_kwargs=False,
end_tag=end_tag,
)
data = _parse_component_args(parser, tag)
trace_msg("PARSE", "COMP", result.component_name, tag.id)
body = tag.parse_body()
fill_nodes = parse_slot_fill_nodes_from_component_nodelist(body, ComponentNode)
# Tag all fill nodes as children of this particular component instance
for node in fill_nodes:
trace_msg("ASSOC", "FILL", node.name_fexp, node.node_id, component_id=component_id)
node.component_id = component_id
trace_msg("ASSOC", "FILL", node.name_fexp, node.node_id, component_id=tag.id)
node.component_id = tag.id
component_node = ComponentNode(
FilterExpression(component_name, parser),
context_args,
context_kwargs,
isolated_context=isolated_context,
name=result.component_name,
context_args=tag.args,
context_kwargs=tag.kwargs,
isolated_context=data.isolated_context,
fill_nodes=fill_nodes,
component_id=component_id,
component_id=tag.id,
)
trace_msg("PARSE", "COMP", component_name, component_id, "...Done!")
trace_msg("PARSE", "COMP", result.component_name, tag.id, "...Done!")
return component_node
@register.tag("provide")
def do_provide(parser: Parser, token: Token) -> SlotNode:
def provide(parser: Parser, token: Token) -> ProvideNode:
# e.g. {% provide <name> key=val key2=val2 %}
tag_name, *args = token.split_contents()
provide_key, kwargs = _parse_provide_args(parser, args, tag_name)
bits = token.split_contents()
tag = _parse_tag(
"provide",
parser,
bits,
params=["name"],
flags=[],
keywordonly_kwargs=True,
repeatable_kwargs=False,
end_tag="endprovide",
)
data = _parse_provide_args(parser, tag)
# Use a unique ID to be able to tie the fill nodes with components and slots
# NOTE: MUST be called BEFORE `parser.parse()` to ensure predictable numbering
slot_id = gen_id()
trace_msg("PARSE", "PROVIDE", provide_key, slot_id)
trace_msg("PARSE", "PROVIDE", data.key, tag.id)
nodelist = parser.parse(parse_until=["endprovide"])
parser.delete_first_token()
body = tag.parse_body()
slot_node = ProvideNode(
provide_key,
nodelist,
node_id=slot_id,
provide_kwargs=kwargs,
name=data.key,
nodelist=body,
node_id=tag.id,
provide_kwargs=tag.kwargs,
)
trace_msg("PARSE", "PROVIDE", provide_key, slot_id, "...Done!")
trace_msg("PARSE", "PROVIDE", data.key, tag.id, "...Done!")
return slot_node
@register.tag("html_attrs")
def do_html_attrs(parser: Parser, token: Token) -> HtmlAttrsNode:
def html_attrs(parser: Parser, token: Token) -> HtmlAttrsNode:
"""
This tag takes:
- Optional dictionary of attributes (`attrs`)
@ -270,243 +300,315 @@ def do_html_attrs(parser: Parser, token: Token) -> HtmlAttrsNode:
```
"""
bits = token.split_contents()
attributes, default_attrs, append_attrs = _parse_html_attrs_args(parser, bits, "html_attrs")
return HtmlAttrsNode(attributes, default_attrs, append_attrs)
tag = _parse_tag(
"html_attrs",
parser,
bits,
params=["attrs", "defaults"],
optional_params=["attrs", "defaults"],
flags=[],
keywordonly_kwargs=True,
repeatable_kwargs=True,
)
return HtmlAttrsNode(
attributes=tag.kwargs.get("attrs"),
defaults=tag.kwargs.get("defaults"),
kwargs=[(key, val) for key, val in tag.kwarg_pairs if key != "attrs" and key != "defaults"],
)
def _check_for_isolated_context_keyword(bits: List[str]) -> Tuple[List[str], bool]:
"""Return True and strip the last word if token ends with 'only' keyword or if CONTEXT_BEHAVIOR is 'isolated'."""
if bits[-1] == "only":
return bits[:-1], True
if app_settings.CONTEXT_BEHAVIOR == ContextBehavior.ISOLATED:
return bits, True
return bits, False
class ParsedTag(NamedTuple):
id: str
name: str
bits: List[str]
flags: Dict[str, bool]
args: List[FilterExpression]
named_args: Dict[str, FilterExpression]
kwargs: Dict[str, Expression]
kwarg_pairs: List[Tuple[str, Expression]]
is_inline: bool
parse_body: Callable[[], NodeList]
def _parse_component_with_args(
parser: Parser, bits: List[str], tag_name: str
) -> Tuple[str, List[FilterExpression], Mapping[str, FilterExpression]]:
tag_args, tag_kwarg_pairs = parse_bits(
def _parse_tag(
tag: str,
parser: Parser,
bits: List[str],
params: Union[List[str], bool] = False,
flags: Optional[List[str]] = None,
end_tag: Optional[str] = None,
optional_params: Optional[List[str]] = None,
keywordonly_kwargs: Optional[Union[bool, List[str]]] = False,
repeatable_kwargs: Optional[Union[bool, List[str]]] = False,
) -> ParsedTag:
# Use a unique ID to be able to tie the fill nodes with components and slots
# NOTE: MUST be called BEFORE `parse_body()` to ensure predictable numbering
tag_id = gen_id()
params = params or []
# e.g. {% slot <name> ... %}
tag_name, *bits = bits
if tag_name != tag:
raise TemplateSyntaxError(f"Start tag parser received tag '{tag_name}', expected '{tag}'")
# Decide if the template tag is inline or block and strip the trailing slash
last_token = bits[-1] if len(bits) else None
if last_token == "/":
bits.pop()
is_inline = True
else:
# If no end tag was given, we assume that the tag is inline-only
is_inline = not end_tag
parsed_flags = {flag: False for flag in (flags or [])}
bits_without_flags: List[str] = []
seen_kwargs: Set[str] = set()
seen_agg_keys: Set[str] = set()
def mark_kwarg_key(key: str, is_agg_key: bool) -> None:
if (is_agg_key and key in seen_kwargs) or (not is_agg_key and key in seen_agg_keys):
raise TemplateSyntaxError(
f"Received argument '{key}' both as a regular input ({key}=...)"
f" and as an aggregate dict ('{key}:key=...'). Must be only one of the two"
)
if is_agg_key:
seen_agg_keys.add(key)
else:
seen_kwargs.add(key)
for bit in bits:
# Extract flags, which are like keywords but without the value part
if bit in parsed_flags:
parsed_flags[bit] = True
continue
else:
bits_without_flags.append(bit)
# Record which kwargs we've seen, to detect if kwargs were passed in
# as both aggregate and regular kwargs
if "=" in bit:
key = bit.split("=")[0]
# Also pick up on aggregate keys like `attr:key=val`
if is_aggregate_key(key):
key = key.split(":")[0]
mark_kwarg_key(key, True)
else:
mark_kwarg_key(key, False)
bits = bits_without_flags
# To support optional args, we need to convert these to kwargs, so `parse_bits`
# can handle them. So we assign the keys to matched positional args,
# and then move the kwarg AFTER the pos args.
#
# TODO: This following section should live in `parse_bits`, but I don't want to
# modify it much to maintain some sort of compatibility with Django's version of
# `parse_bits`.
# Ideally, Django's parser would be expanded to support our use cases.
if params != True: # noqa F712
params_to_sort = [param for param in params if param not in seen_kwargs]
new_args = []
new_params = []
new_kwargs = []
for index, bit in enumerate(bits):
if "=" in bit or not len(params_to_sort):
# Pass all remaining bits (including current one) as kwargs
new_kwargs.extend(bits[index:])
break
param = params_to_sort.pop(0)
if optional_params and param in optional_params:
mark_kwarg_key(param, False)
new_kwargs.append(f"{param}={bit}")
continue
new_args.append(bit)
new_params.append(param)
bits = [*new_args, *new_kwargs]
params = [*new_params, *params_to_sort]
# Remove any remaining optional positional args if they were not given
if optional_params:
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(
parser=parser,
bits=bits,
params=["tag_name", "name"],
params=[] if isinstance(params, bool) else params,
name=tag_name,
)
tag_kwargs = {}
for key, val in tag_kwarg_pairs:
if key in tag_kwargs:
# The keyword argument has already been supplied once
raise TemplateSyntaxError(f"'{tag_name}' received multiple values for keyword argument '{key}'")
tag_kwargs[key] = val
# Post-process args/kwargs - Mark special cases like aggregate dicts
# or dynamic expressions
pre_aggregate_kwargs: Dict[str, FilterExpression] = {}
kwarg_pairs: List[Tuple[str, Expression]] = []
for key, val in raw_kwarg_pairs:
# NOTE: If a tag allows mutliple kwargs, and we provide a same aggregate key
# multiple times (e.g. `attr:class="hidden" and `attr:class="another"`), then
# we take only the last instance.
if is_aggregate_key(key):
pre_aggregate_kwargs[key] = val
else:
kwarg_pairs.append((key, val))
aggregate_kwargs: Dict[str, Dict[str, FilterExpression]] = process_aggregate_kwargs(pre_aggregate_kwargs)
if tag_name != tag_args[0].token:
raise RuntimeError(f"Internal error: Expected tag_name to be {tag_name}, but it was {tag_args[0].token}")
for key, agg_dict in aggregate_kwargs.items():
entry = (key, AggregateFilterExpression(agg_dict))
kwarg_pairs.append(entry)
component_name = _get_positional_param(tag_name, "name", 1, tag_args, tag_kwargs).token
if len(tag_args) > 1:
# Positional args given. Skip tag and component name and take the rest
context_args = tag_args[2:]
else: # No positional args
context_args = []
# 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):]}")
return component_name, context_args, tag_kwargs
# For convenience, allow to access named args by their name instead of index
if params != True: # noqa F712
named_args = {param: args[index] for index, param in enumerate(params)}
else:
named_args = {}
# Validate kwargs
kwargs: Dict[str, Expression] = {}
extra_keywords: Set[str] = set()
for key, val in kwarg_pairs:
# Check if key allowed
if not keywordonly_kwargs:
is_key_allowed = False
else:
is_key_allowed = keywordonly_kwargs == True or key in keywordonly_kwargs # noqa: E712
if not is_key_allowed:
extra_keywords.add(key)
def _parse_html_attrs_args(
parser: Parser, bits: List[str], tag_name: str
) -> Tuple[Optional[FilterExpression], Optional[FilterExpression], List[Tuple[str, FilterExpression]]]:
tag_args, tag_kwarg_pairs = parse_bits(
parser=parser,
bits=bits,
params=["tag_name"],
# Check for repeated keys
if key in kwargs:
if not repeatable_kwargs:
is_key_repeatable = False
else:
is_key_repeatable = repeatable_kwargs == True or key in 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,
bits=bits,
flags=parsed_flags,
args=args,
named_args=named_args,
kwargs=kwargs,
kwarg_pairs=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, end_tag, is_inline) if end_tag else NodeList(),
is_inline=is_inline,
)
# NOTE: Unlike in the `component` tag, in this case we don't care about duplicates,
# as we're constructing the dict simply to find the `attrs` kwarg.
tag_kwargs = {key: val for key, val in tag_kwarg_pairs}
if tag_name != tag_args[0].token:
raise RuntimeError(f"Internal error: Expected tag_name to be {tag_name}, but it was {tag_args[0].token}")
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
# Allow to optioanlly provide `attrs` as positional arg `{% html_attrs attrs %}`
try:
attrs = _get_positional_param(tag_name, "attrs", 1, tag_args, tag_kwargs)
except TemplateSyntaxError:
attrs = None
# Allow to optionally provide `defaults` as positional arg `{% html_attrs attrs defaults %}`
try:
defaults = _get_positional_param(tag_name, "defaults", 2, tag_args, tag_kwargs)
except TemplateSyntaxError:
defaults = None
class ParsedComponentTag(NamedTuple):
isolated_context: bool
# Allow only up to 2 positional args - [0] == tag name, [1] == attrs, [2] == defaults
if len(tag_args) > 3:
raise TemplateSyntaxError(f"Tag '{tag_name}' received unexpected positional arguments: {tag_args[2:]}")
return attrs, defaults, tag_kwarg_pairs
def _parse_component_args(
parser: Parser,
tag: ParsedTag,
) -> ParsedComponentTag:
# Check for isolated context keyword
isolated_context = tag.flags["only"] or app_settings.CONTEXT_BEHAVIOR == ContextBehavior.ISOLATED
return ParsedComponentTag(isolated_context=isolated_context)
class ParsedSlotTag(NamedTuple):
name: str
def _parse_slot_args(
parser: Parser,
bits: List[str],
tag_name: str,
) -> Tuple[str, bool, bool, Dict[str, FilterExpression]]:
if not len(bits):
raise TemplateSyntaxError(
"'slot' tag does not match pattern "
"{% slot <name> ['default'] ['required'] [key=val, ...] %}. "
"Order of options is free."
)
slot_name, *options = bits
if not is_wrapped_in_quotes(slot_name):
raise TemplateSyntaxError(f"'{tag_name}' name must be a string 'literal'.")
tag: ParsedTag,
) -> ParsedSlotTag:
slot_name = tag.named_args["name"].token
if not is_str_wrapped_in_quotes(slot_name):
raise TemplateSyntaxError(f"'{tag.name}' name must be a string 'literal', got {slot_name}.")
slot_name = resolve_string(slot_name, parser)
# Parse flags - Since `parse_bits` doesn't handle "shorthand" kwargs
# (AKA `required` for `required=True`), we have to first get the flags out
# of the way.
def extract_value(lst: List[str], value: str) -> bool:
"""Check if value exists in list, and if so, remove it from said list"""
try:
lst.remove(value)
return True
except ValueError:
return False
return ParsedSlotTag(name=slot_name)
is_default = extract_value(options, SLOT_DEFAULT_OPTION_KEYWORD)
is_required = extract_value(options, SLOT_REQUIRED_OPTION_KEYWORD)
# Parse kwargs that will be passed to the fill
_, tag_kwarg_pairs = parse_bits(
parser=parser,
bits=options,
params=[],
name=tag_name,
)
tag_kwargs: Dict[str, FilterExpression] = {}
for key, val in tag_kwarg_pairs:
if key in tag_kwargs:
# The keyword argument has already been supplied once
raise TemplateSyntaxError(f"'{tag_name}' received multiple values for keyword argument '{key}'")
tag_kwargs[key] = val
return slot_name, is_default, is_required, tag_kwargs
class ParsedFillTag(NamedTuple):
slot_name: FilterExpression
slot_default_var: Optional[FilterExpression]
slot_data_var: Optional[FilterExpression]
def _parse_fill_args(
parser: Parser,
bits: List[str],
tag_name: str,
) -> Tuple[FilterExpression, Optional[FilterExpression], Optional[FilterExpression]]:
if not len(bits):
raise TemplateSyntaxError(
"'fill' tag does not match pattern "
f"{{% fill <name> [{SLOT_DATA_ATTR}=val] [{SLOT_DEFAULT_ATTR}=slot_var] %}} "
)
slot_name = bits.pop(0)
slot_name_fexp = parser.compile_filter(slot_name)
# Even tho we want to parse only single kwarg, we use the same logic for parsing
# as we use for other tags, for consistency.
_, tag_kwarg_pairs = parse_bits(
parser=parser,
bits=bits,
params=[],
name=tag_name,
)
tag_kwargs: Dict[str, FilterExpression] = {}
for key, val in tag_kwarg_pairs:
if key in tag_kwargs:
raise TemplateSyntaxError(f"'{tag_name}' received multiple values for keyword argument '{key}'")
tag_kwargs[key] = val
tag: ParsedTag,
) -> ParsedFillTag:
slot_name_fexp = tag.named_args["name"]
# Extract known kwargs
slot_data_var_fexp: Optional[FilterExpression] = None
if SLOT_DATA_ATTR in tag_kwargs:
slot_data_var_fexp = tag_kwargs.pop(SLOT_DATA_ATTR)
if not is_wrapped_in_quotes(slot_data_var_fexp.token):
raise TemplateSyntaxError(
f"Value of '{SLOT_DATA_ATTR}' in '{tag_name}' tag must be a string literal, got '{slot_data_var_fexp}'"
)
slot_data_var_fexp: Optional[FilterExpression] = tag.kwargs.get(SLOT_DATA_ATTR)
if slot_data_var_fexp and not is_str_wrapped_in_quotes(slot_data_var_fexp.token):
raise TemplateSyntaxError(
f"Value of '{SLOT_DATA_ATTR}' in '{tag.name}' tag must be a string literal, got '{slot_data_var_fexp}'"
)
slot_default_var_fexp: Optional[FilterExpression] = None
if SLOT_DEFAULT_ATTR in tag_kwargs:
slot_default_var_fexp = tag_kwargs.pop(SLOT_DEFAULT_ATTR)
if not is_wrapped_in_quotes(slot_default_var_fexp.token):
raise TemplateSyntaxError(
f"Value of '{SLOT_DEFAULT_ATTR}' in '{tag_name}' tag must be a string literal,"
f" got '{slot_default_var_fexp}'"
)
slot_default_var_fexp: Optional[FilterExpression] = tag.kwargs.get(SLOT_DEFAULT_ATTR)
if slot_default_var_fexp and not is_str_wrapped_in_quotes(slot_default_var_fexp.token):
raise TemplateSyntaxError(
f"Value of '{SLOT_DEFAULT_ATTR}' in '{tag.name}' tag must be a string literal,"
f" got '{slot_default_var_fexp}'"
)
# data and default cannot be bound to the same variable
if slot_data_var_fexp and slot_default_var_fexp and slot_data_var_fexp.token == slot_default_var_fexp.token:
raise TemplateSyntaxError(
f"'{tag_name}' received the same string for slot default ({SLOT_DEFAULT_ATTR}=...)"
f"'{tag.name}' received the same string for slot default ({SLOT_DEFAULT_ATTR}=...)"
f" and slot data ({SLOT_DATA_ATTR}=...)"
)
if len(tag_kwargs):
extra_keywords = tag_kwargs.keys()
extra_keys = ", ".join(extra_keywords)
raise TemplateSyntaxError(f"'{tag_name}' received unexpected kwargs: {extra_keys}")
return ParsedFillTag(
slot_name=slot_name_fexp,
slot_default_var=slot_default_var_fexp,
slot_data_var=slot_data_var_fexp,
)
return slot_name_fexp, slot_default_var_fexp, slot_data_var_fexp
class ParsedProvideTag(NamedTuple):
key: str
def _parse_provide_args(
parser: Parser,
bits: List[str],
tag_name: str,
) -> Tuple[str, Dict[str, FilterExpression]]:
if not len(bits):
raise TemplateSyntaxError("'provide' tag does not match pattern {% provide <key> [key=val, ...] %}. ")
provide_key, *options = bits
if not is_wrapped_in_quotes(provide_key):
raise TemplateSyntaxError(f"'{tag_name}' key must be a string 'literal'.")
tag: ParsedTag,
) -> ParsedProvideTag:
provide_key = tag.named_args["name"].token
if not is_str_wrapped_in_quotes(provide_key):
raise TemplateSyntaxError(f"'{tag.name}' key must be a string 'literal'.")
provide_key = resolve_string(provide_key, parser)
# Parse kwargs that will be 'provided' under the given key
_, tag_kwarg_pairs = parse_bits(parser=parser, bits=options, params=[], name=tag_name)
tag_kwargs: Dict[str, FilterExpression] = {}
for key, val in tag_kwarg_pairs:
if key in tag_kwargs:
# The keyword argument has already been supplied once
raise TemplateSyntaxError(f"'{tag_name}' received multiple values for keyword argument '{key}'")
tag_kwargs[key] = val
return provide_key, tag_kwargs
def _get_positional_param(
tag_name: str,
param_name: str,
param_index: int,
args: List[FilterExpression],
kwargs: Dict[str, FilterExpression],
) -> FilterExpression:
# Param is given as positional arg, e.g. `{% tag param %}`
if len(args) > param_index:
param = args[param_index]
return param
# Check if param was given as kwarg, e.g. `{% tag param_name=param %}`
elif param_name in kwargs:
param = kwargs.pop(param_name)
return param
raise TemplateSyntaxError(f"Param '{param_name}' not found in '{tag_name}' tag")
def is_wrapped_in_quotes(s: str) -> bool:
return s.startswith(('"', "'")) and s[0] == s[-1]
return ParsedProvideTag(key=provide_key)

View file

@ -19,3 +19,7 @@ def find_last_index(lst: List, predicate: Callable[[Any], bool]) -> Any:
if predicate(elem):
return len(lst) - 1 - r_idx
return -1
def is_str_wrapped_in_quotes(s: str) -> bool:
return s.startswith(('"', "'")) and s[0] == s[-1] and len(s) >= 2

View file

@ -128,7 +128,7 @@ class HtmlAttrsTests(BaseTestCase):
template = Template(self.template_str)
with self.assertRaisesMessage(
TemplateSyntaxError, "Tag 'html_attrs' received unexpected positional arguments"
TemplateSyntaxError, "'html_attrs' received some positional argument(s) after some keyword"
):
template.render(Context({"class_var": "padding-top-8"}))

View file

@ -5,7 +5,7 @@ from django_components import Component, registry, types
from .django_test_setup import setup_test_config
from .testutils import BaseTestCase, parametrize_context_behavior
setup_test_config()
setup_test_config({"autodiscover": False})
#########################

View file

@ -1,12 +1,21 @@
import unittest
from django.template import Library
from django.test import override_settings
from django_components import AlreadyRegistered, Component, ComponentRegistry, NotRegistered, register, registry
from django_components import (
AlreadyRegistered,
Component,
ComponentRegistry,
NotRegistered,
TagProtectedError,
register,
registry,
)
from .django_test_setup import setup_test_config
setup_test_config()
setup_test_config({"autodiscover": False})
class MockComponent(Component):
@ -69,7 +78,7 @@ class ComponentRegistryTest(unittest.TestCase):
def test_unregisters_only_unused_tags(self):
self.assertDictEqual(self.registry._tags, {})
# NOTE: We preserve the default component tags
self.assertIn("component", self.registry.library.tags)
self.assertNotIn("component", self.registry.library.tags)
# Register two components that use the same tag
self.registry.register(name="testcomponent", component=MockComponent)
@ -78,7 +87,6 @@ class ComponentRegistryTest(unittest.TestCase):
self.assertDictEqual(
self.registry._tags,
{
"#component": {"testcomponent", "testcomponent2"},
"component": {"testcomponent", "testcomponent2"},
},
)
@ -91,7 +99,6 @@ class ComponentRegistryTest(unittest.TestCase):
self.assertDictEqual(
self.registry._tags,
{
"#component": {"testcomponent2"},
"component": {"testcomponent2"},
},
)
@ -102,7 +109,7 @@ class ComponentRegistryTest(unittest.TestCase):
self.registry.unregister(name="testcomponent2")
self.assertDictEqual(self.registry._tags, {})
self.assertIn("component", self.registry.library.tags)
self.assertNotIn("component", self.registry.library.tags)
def test_prevent_registering_different_components_with_the_same_name(self):
self.registry.register(name="testcomponent", component=MockComponent)
@ -124,3 +131,35 @@ class ComponentRegistryTest(unittest.TestCase):
def test_raises_on_failed_unregister(self):
with self.assertRaises(NotRegistered):
self.registry.unregister(name="testcomponent")
class ProtectedTagsTest(unittest.TestCase):
def setUp(self):
super().setUp()
self.registry = ComponentRegistry()
# NOTE: Use the `component_shorthand_formatter` formatter, so the components
# are registered under that tag
@override_settings(COMPONENTS={"tag_formatter": "django_components.component_shorthand_formatter"})
def test_raises_on_overriding_our_tags(self):
for tag in [
"component_dependencies",
"component_css_dependencies",
"component_js_dependencies",
"fill",
"html_attrs",
"provide",
"slot",
]:
with self.assertRaises(TagProtectedError):
@register(tag)
class TestComponent(Component):
pass
@register("sth_else")
class TestComponent2(Component):
pass
# Cleanup
registry.unregister("sth_else")

402
tests/test_tag_formatter.py Normal file
View file

@ -0,0 +1,402 @@
from django.template import Context, Template
from django_components import Component, register, types
from django_components.tag_formatter import ShorthandComponentFormatter
from .django_test_setup import setup_test_config
from .testutils import BaseTestCase, parametrize_context_behavior
setup_test_config({"autodiscover": False})
class MultiwordStartTagFormatter(ShorthandComponentFormatter):
def start_tag(self, name):
return f"{name} comp"
class MultiwordBlockEndTagFormatter(ShorthandComponentFormatter):
def end_tag(self, name):
return f"end {name}"
# Create a TagFormatter class to validate the public interface
def create_validator_tag_formatter(tag_name: str):
class ValidatorTagFormatter(ShorthandComponentFormatter):
def start_tag(self, name):
assert name == tag_name
return super().start_tag(name)
def end_tag(self, name):
assert name == tag_name
return super().end_tag(name)
def parse(self, tokens):
assert isinstance(tokens, list)
assert tokens[0] == tag_name
return super().parse(tokens)
return ValidatorTagFormatter()
class ComponentTagTests(BaseTestCase):
@parametrize_context_behavior(["django", "isolated"])
def test_formatter_default_inline(self):
@register("simple")
class SimpleComponent(Component):
template: types.django_html = """
{% load component_tags %}
hello1
<div>
{% slot "content" default %} SLOT_DEFAULT {% endslot %}
</div>
hello2
"""
template = Template(
"""
{% load component_tags %}
{% component "simple" / %}
"""
)
rendered = template.render(Context())
self.assertHTMLEqual(
rendered,
"""
hello1
<div>
SLOT_DEFAULT
</div>
hello2
""",
)
@parametrize_context_behavior(["django", "isolated"])
def test_formatter_default_block(self):
@register("simple")
class SimpleComponent(Component):
template: types.django_html = """
{% load component_tags %}
hello1
<div>
{% slot "content" default %} SLOT_DEFAULT {% endslot %}
</div>
hello2
"""
template = Template(
"""
{% load component_tags %}
{% component "simple" %}
OVERRIDEN!
{% endcomponent %}
"""
)
rendered = template.render(Context())
self.assertHTMLEqual(
rendered,
"""
hello1
<div>
OVERRIDEN!
</div>
hello2
""",
)
@parametrize_context_behavior(
cases=["django", "isolated"],
settings={
"COMPONENTS": {
"tag_formatter": "django_components.component_formatter",
},
},
)
def test_formatter_component_inline(self):
@register("simple")
class SimpleComponent(Component):
template: types.django_html = """
{% load component_tags %}
hello1
<div>
{% slot "content" default %} SLOT_DEFAULT {% endslot %}
</div>
hello2
"""
template = Template(
"""
{% load component_tags %}
{% component "simple" / %}
"""
)
rendered = template.render(Context())
self.assertHTMLEqual(
rendered,
"""
hello1
<div>
SLOT_DEFAULT
</div>
hello2
""",
)
@parametrize_context_behavior(
cases=["django", "isolated"],
settings={
"COMPONENTS": {
"tag_formatter": "django_components.component_formatter",
},
},
)
def test_formatter_component_block(self):
@register("simple")
class SimpleComponent(Component):
template: types.django_html = """
{% load component_tags %}
hello1
<div>
{% slot "content" default %} SLOT_DEFAULT {% endslot %}
</div>
hello2
"""
template = Template(
"""
{% load component_tags %}
{% component "simple" %}
OVERRIDEN!
{% endcomponent %}
"""
)
rendered = template.render(Context())
self.assertHTMLEqual(
rendered,
"""
hello1
<div>
OVERRIDEN!
</div>
hello2
""",
)
@parametrize_context_behavior(
cases=["django", "isolated"],
settings={
"COMPONENTS": {
"tag_formatter": "django_components.component_shorthand_formatter",
},
},
)
def test_formatter_shorthand_inline(self):
@register("simple")
class SimpleComponent(Component):
template: types.django_html = """
{% load component_tags %}
hello1
<div>
{% slot "content" default %} SLOT_DEFAULT {% endslot %}
</div>
hello2
"""
template = Template(
"""
{% load component_tags %}
{% simple / %}
"""
)
rendered = template.render(Context())
self.assertHTMLEqual(
rendered,
"""
hello1
<div>
SLOT_DEFAULT
</div>
hello2
""",
)
@parametrize_context_behavior(
cases=["django", "isolated"],
settings={
"COMPONENTS": {
"tag_formatter": "django_components.component_shorthand_formatter",
},
},
)
def test_formatter_shorthand_block(self):
@register("simple")
class SimpleComponent(Component):
template: types.django_html = """
{% load component_tags %}
hello1
<div>
{% slot "content" default %} SLOT_DEFAULT {% endslot %}
</div>
hello2
"""
template = Template(
"""
{% load component_tags %}
{% simple %}
OVERRIDEN!
{% endsimple %}
"""
)
rendered = template.render(Context())
self.assertHTMLEqual(
rendered,
"""
hello1
<div>
OVERRIDEN!
</div>
hello2
""",
)
@parametrize_context_behavior(
cases=["django", "isolated"],
settings={
"COMPONENTS": {
"tag_formatter": ShorthandComponentFormatter(),
},
},
)
def test_import_formatter_by_value(self):
@register("simple")
class SimpleComponent(Component):
template: types.django_html = """
{% load component_tags %}
<div>
{% slot "content" default %} SLOT_DEFAULT {% endslot %}
</div>
"""
template = Template(
"""
{% load component_tags %}
{% simple %}
OVERRIDEN!
{% endsimple %}
"""
)
rendered = template.render(Context())
self.assertHTMLEqual(
rendered,
"""
<div>
OVERRIDEN!
</div>
""",
)
@parametrize_context_behavior(
cases=["django", "isolated"],
settings={
"COMPONENTS": {
"tag_formatter": MultiwordStartTagFormatter(),
},
},
)
def test_raises_on_invalid_start_tag(self):
with self.assertRaisesMessage(
ValueError, "MultiwordStartTagFormatter returned an invalid tag for start_tag: 'simple comp'"
):
@register("simple")
class SimpleComponent(Component):
template = """{% load component_tags %}"""
@parametrize_context_behavior(
cases=["django", "isolated"],
settings={
"COMPONENTS": {
"tag_formatter": MultiwordBlockEndTagFormatter(),
},
},
)
def test_raises_on_invalid_block_end_tag(self):
with self.assertRaisesMessage(
ValueError, "MultiwordBlockEndTagFormatter returned an invalid tag for end_tag: 'end simple'"
):
@register("simple")
class SimpleComponent(Component):
template: types.django_html = """
{% load component_tags %}
<div>
{% slot "content" default %} SLOT_DEFAULT {% endslot %}
</div>
"""
Template(
"""
{% load component_tags %}
{% simple %}
OVERRIDEN!
{% bar %}
"""
)
@parametrize_context_behavior(
cases=["django", "isolated"],
settings={
"COMPONENTS": {
"tag_formatter": create_validator_tag_formatter("simple"),
},
},
)
def test_method_args(self):
@register("simple")
class SimpleComponent(Component):
template: types.django_html = """
{% load component_tags %}
hello1
<div>
{% slot "content" default %} SLOT_DEFAULT {% endslot %}
</div>
hello2
"""
template = Template(
"""
{% load component_tags %}
{% simple / %}
"""
)
rendered = template.render(Context())
self.assertHTMLEqual(
rendered,
"""
hello1
<div>
SLOT_DEFAULT
</div>
hello2
""",
)
template = Template(
"""
{% load component_tags %}
{% simple %}
OVERRIDEN!
{% endsimple %}
"""
)
rendered = template.render(Context())
self.assertHTMLEqual(
rendered,
"""
hello1
<div>
OVERRIDEN!
</div>
hello2
""",
)

View file

@ -3,50 +3,49 @@ from django.template.base import Parser
from django_components import Component, registry, types
from django_components.component import safe_resolve_dict, safe_resolve_list
from django_components.template_parser import process_aggregate_kwargs
from django_components.templatetags.component_tags import _parse_component_with_args
from django_components.template_parser import is_aggregate_key, process_aggregate_kwargs
from django_components.templatetags.component_tags import _parse_tag
from .django_test_setup import setup_test_config
from .testutils import BaseTestCase, parametrize_context_behavior
setup_test_config()
setup_test_config({"autodiscover": False})
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")
bits = ["component", "42", "myvar", "key='val'", "key2=val2"]
tag = _parse_tag("component", Parser(""), bits, params=["num", "var"], keywordonly_kwargs=True)
ctx = {"myvar": {"a": "b"}, "val2": 1}
args = safe_resolve_list(raw_args, ctx)
kwargs = safe_resolve_dict(raw_kwargs, ctx)
args = safe_resolve_list(tag.args, ctx)
named_args = safe_resolve_dict(tag.named_args, ctx)
kwargs = safe_resolve_dict(tag.kwargs, ctx)
self.assertEqual(name, "my_component")
self.assertListEqual(args, [42, {"a": "b"}])
self.assertDictEqual(named_args, {"num": 42, "var": {"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")
tag = _parse_tag("component", Parser(""), bits, keywordonly_kwargs=True)
ctx = Context({"date": 2024, "bzz": "fzz"})
args = safe_resolve_list(raw_args, ctx)
kwargs = safe_resolve_dict(raw_kwargs, ctx)
args = safe_resolve_list(tag.args, ctx)
kwargs = safe_resolve_dict(tag.kwargs, ctx)
self.assertEqual(name, "my_component")
self.assertListEqual(args, [])
self.assertDictEqual(
kwargs,
{
"@event:na-me.mod": "fzz",
"@event": {"na-me.mod": "fzz"},
"@lol": 2,
"date": 2024,
"na-me": "fzz",
@ -117,3 +116,15 @@ class AggregateKwargsTest(BaseTestCase):
":placeholder": "No text",
},
)
def is_aggregate_key(self):
self.assertEqual(is_aggregate_key(""), False)
self.assertEqual(is_aggregate_key(" "), False)
self.assertEqual(is_aggregate_key(" : "), False)
self.assertEqual(is_aggregate_key("attrs"), False)
self.assertEqual(is_aggregate_key(":attrs"), False)
self.assertEqual(is_aggregate_key(" :attrs "), False)
self.assertEqual(is_aggregate_key("attrs:"), False)
self.assertEqual(is_aggregate_key(":attrs:"), False)
self.assertEqual(is_aggregate_key("at:trs"), True)
self.assertEqual(is_aggregate_key(":at:trs"), False)

View file

@ -35,7 +35,9 @@ class SlottedComponentWithContext(Component):
class ComponentTemplateTagTest(BaseTestCase):
class SimpleComponent(Component):
template_name = "simple_template.html"
template: types.django_html = """
Variable: <strong>{{ variable }}</strong>
"""
def get_context_data(self, variable, variable2="default"):
return {
@ -61,7 +63,20 @@ class ComponentTemplateTagTest(BaseTestCase):
self.assertHTMLEqual(rendered, "Variable: <strong>variable</strong>\n")
@parametrize_context_behavior(["django", "isolated"])
def test_call_with_invalid_name(self):
def test_single_component_self_closing(self):
registry.register(name="test", component=self.SimpleComponent)
simple_tag_template: types.django_html = """
{% load component_tags %}
{% component name="test" variable="variable" /%}
"""
template = Template(simple_tag_template)
rendered = template.render(Context({}))
self.assertHTMLEqual(rendered, "Variable: <strong>variable</strong>\n")
@parametrize_context_behavior(["django", "isolated"])
def test_raises_on_no_registered_components(self):
# Note: No tag registered
simple_tag_template: types.django_html = """
@ -69,6 +84,18 @@ class ComponentTemplateTagTest(BaseTestCase):
{% component name="test" variable="variable" %}{% endcomponent %}
"""
with self.assertRaisesMessage(TemplateSyntaxError, "Invalid block tag on line 3: 'component'"):
Template(simple_tag_template)
@parametrize_context_behavior(["django", "isolated"])
def test_call_with_invalid_name(self):
registry.register(name="test_one", component=self.SimpleComponent)
simple_tag_template: types.django_html = """
{% load component_tags %}
{% component name="test" variable="variable" %}{% endcomponent %}
"""
template = Template(simple_tag_template)
with self.assertRaises(NotRegistered):
template.render(Context({}))
@ -131,7 +158,7 @@ class ComponentTemplateTagTest(BaseTestCase):
self.assertHTMLEqual(rendered, "Variable: <strong>variable</strong>\n")
@parametrize_context_behavior(["django", "isolated"])
def test_component_called_with_variable_as_name(self):
def test_raises_on_component_called_with_variable_as_name(self):
registry.register(name="test", component=self.SimpleComponent)
simple_tag_template: types.django_html = """
@ -141,24 +168,11 @@ class ComponentTemplateTagTest(BaseTestCase):
{% endwith %}
"""
template = Template(simple_tag_template)
rendered = template.render(Context({}))
self.assertHTMLEqual(rendered, "Variable: <strong>variable</strong>\n")
@parametrize_context_behavior(["django", "isolated"])
def test_component_called_with_invalid_variable_as_name(self):
registry.register(name="test", component=self.SimpleComponent)
simple_tag_template: types.django_html = """
{% load component_tags %}
{% with component_name="BLAHONGA" %}
{% component component_name variable="variable" %}{% endcomponent %}
{% endwith %}
"""
template = Template(simple_tag_template)
with self.assertRaises(NotRegistered):
template.render(Context({}))
with self.assertRaisesMessage(
TemplateSyntaxError,
"Component name must be a string 'literal', got: component_name",
):
Template(simple_tag_template)
@parametrize_context_behavior(["django", "isolated"])
def test_component_accepts_provided_and_default_parameters(self):

View file

@ -5,7 +5,7 @@ from django_components import Component, register, types
from .django_test_setup import setup_test_config
from .testutils import BaseTestCase, parametrize_context_behavior
setup_test_config()
setup_test_config({"autodiscover": False})
class ProvideTemplateTagTest(BaseTestCase):
@ -38,6 +38,24 @@ class ProvideTemplateTagTest(BaseTestCase):
""",
)
@parametrize_context_behavior(["django", "isolated"])
def test_provide_basic_self_closing(self):
template_str: types.django_html = """
{% load component_tags %}
<div>
{% provide "my_provide" key="hi" another=123 / %}
</div>
"""
template = Template(template_str)
rendered = template.render(Context({}))
self.assertHTMLEqual(
rendered,
"""
<div></div>
""",
)
@parametrize_context_behavior(["django", "isolated"])
def test_provide_access_keys_in_python(self):
@register("injectee")

View file

@ -7,7 +7,7 @@ from django_components import Component, register, registry, types
from .django_test_setup import setup_test_config
from .testutils import BaseTestCase, parametrize_context_behavior
setup_test_config()
setup_test_config({"autodiscover": False})
class SlottedComponent(Component):
@ -75,6 +75,56 @@ class ComponentSlottedTemplateTagTest(BaseTestCase):
""",
)
@parametrize_context_behavior(["django", "isolated"])
def test_slotted_template_basic_self_closing(self):
@register("test1")
class SlottedComponent(Component):
template: types.django_html = """
{% load component_tags %}
<custom-template>
<header>{% slot "header" / %}</header>
<main>{% slot "main" %}Default main{% endslot %}</main>
<footer>{% slot "footer" / %}</footer>
</custom-template>
"""
registry.register(name="test1", component=SlottedComponent)
@register("test2")
class SimpleComponent(Component):
template = """Variable: <strong>{{ variable }}</strong>"""
def get_context_data(self, variable, variable2="default"):
return {
"variable": variable,
"variable2": variable2,
}
template_str: types.django_html = """
{% load component_tags %}
{% component "test1" %}
{% fill "header" %}
{% component "test2" variable="variable" / %}
{% endfill %}
{% fill "main" / %}
{% fill "footer" / %}
{% endcomponent %}
"""
template = Template(template_str)
rendered = template.render(Context({}))
# NOTE: <main> is empty, because the fill is provided, even if empty
self.assertHTMLEqual(
rendered,
"""
<custom-template>
<header> Variable: <strong>variable</strong> </header>
<main></main>
<footer></footer>
</custom-template>
""",
)
# NOTE: Second arg is the expected output of `{{ variable }}`
@parametrize_context_behavior([("django", "test456"), ("isolated", "")])
def test_slotted_template_with_context_var(self, context_behavior_data):

View file

@ -9,7 +9,7 @@ from django_components import Component, registry, types
from .django_test_setup import setup_test_config
from .testutils import BaseTestCase, parametrize_context_behavior
setup_test_config()
setup_test_config({"autodiscover": False})
class SlottedComponent(Component):

20
tests/test_utils.py Normal file
View file

@ -0,0 +1,20 @@
from django_components.utils import is_str_wrapped_in_quotes
from .django_test_setup import setup_test_config
from .testutils import BaseTestCase
setup_test_config({"autodiscover": False})
class UtilsTest(BaseTestCase):
def test_is_str_wrapped_in_quotes(self):
self.assertEqual(is_str_wrapped_in_quotes("word"), False)
self.assertEqual(is_str_wrapped_in_quotes('word"'), False)
self.assertEqual(is_str_wrapped_in_quotes('"word'), False)
self.assertEqual(is_str_wrapped_in_quotes('"word"'), True)
self.assertEqual(is_str_wrapped_in_quotes("\"word'"), False)
self.assertEqual(is_str_wrapped_in_quotes('"word" '), False)
self.assertEqual(is_str_wrapped_in_quotes('"'), False)
self.assertEqual(is_str_wrapped_in_quotes(""), False)
self.assertEqual(is_str_wrapped_in_quotes('""'), True)
self.assertEqual(is_str_wrapped_in_quotes("\"'"), False)