feat: @template_tag and refactor how template tags are defined (#910)

This commit is contained in:
Juro Oravec 2025-01-20 22:47:04 +01:00 committed by GitHub
parent a047908189
commit f908197850
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 2149 additions and 1148 deletions

View file

@ -1,5 +1,66 @@
# Release notes
## v0.125
#### Feat
- `@template_tag` and `BaseNode` - A decorator and a class that allow you to define
custom template tags that will behave similarly to django-components' own template tags.
Read more on [Template tags](https://EmilStenstrom.github.io/django-components/0.125/concepts/advanced/template_tags/).
Template tags defined with `@template_tag` and `BaseNode` will have the following features:
- Accepting args, kwargs, and flags.
- Allowing literal lists and dicts as inputs as:
`key=[1, 2, 3]` or `key={"a": 1, "b": 2}`
- Using template tags tag inputs as:
`{% my_tag key="{% lorem 3 w %}" / %}`
- Supporting the flat dictionary definition:
`attr:key=value`
- Spreading args and kwargs with `...`:
`{% my_tag ...args ...kwargs / %}`
- Being able to call the template tag as:
`{% my_tag %} ... {% endmy_tag %}` or `{% my_tag / %}`
#### Refactor
- Refactored template tag input validation. When you now call template tags like
`{% slot %}`, `{% fill %}`, `{% html_attrs %}`, and others, their inputs are now
validated the same way as Python function inputs are.
So, for example
```django
{% slot "my_slot" name="content" / %}
```
will raise an error, because the positional argument `name` is given twice.
NOTE: Special kwargs whose keys are not valid Python variable names are not affected by this change.
So when you define:
```django
{% component data-id=123 / %}
```
The `data-id` will still be accepted as a valid kwarg, assuming that your `get_context_data()`
accepts `**kwargs`:
```py
def get_context_data(self, **kwargs):
return {
"data_id": kwargs["data-id"],
}
```
## v0.124
#### Feat

View file

@ -1,6 +1,6 @@
---
title: Authoring component libraries
weight: 8
weight: 9
---
You can publish and share your components for others to use. Below you will find the steps to do so.

View file

@ -1,6 +1,6 @@
---
title: Tag formatters
weight: 7
weight: 8
---
## Customizing component tags with TagFormatter

View file

@ -0,0 +1,197 @@
---
title: Custom template tags
weight: 7
---
Template tags introduced by django-components, such as `{% component %}` and `{% slot %}`,
offer additional features over the default Django template tags:
<!-- # TODO - Update docs regarding literal lists and dictionaries
- Using literal lists and dictionaries
- Comments inside and tag with `{# ... #}`
-->
- [Self-closing tags `{% mytag / %}`](../../fundamentals/template_tag_syntax#self-closing-tags)
- [Allowing the use of `:`, `-` (and more) in keys](../../fundamentals/template_tag_syntax#special-characters)
- [Spread operator `...`](../../fundamentals/template_tag_syntax#spread-operator)
- [Using template tags as inputs to other template tags](../../fundamentals/template_tag_syntax#use-template-tags-inside-component-inputs)
- [Flat definition of dictionaries `attr:key=val`](../../fundamentals/template_tag_syntax#pass-dictonary-by-its-key-value-pairs)
- Function-like input validation
You too can easily create custom template tags that use the above features.
## Defining template tags with `@template_tag`
The simplest way to create a custom template tag is using
the [`template_tag`](../../../reference/api#django_components.template_tag) decorator.
This decorator allows you to define a template tag by just writing a function that returns the rendered content.
```python
from django.template import Context, Library
from django_components import BaseNode, template_tag
library = Library()
@template_tag(
library,
tag="mytag",
end_tag="endmytag",
allowed_flags=["required"]
)
def mytag(node: BaseNode, context: Context, name: str, **kwargs) -> str:
return f"Hello, {name}!"
```
This will allow you to use the tag in your templates like this:
```django
{% mytag name="John" %}
{% endmytag %}
{# or with self-closing syntax #}
{% mytag name="John" / %}
{# or with flags #}
{% mytag name="John" required %}
{% endmytag %}
```
### Parameters
The `@template_tag` decorator accepts the following parameters:
- `library`: The Django template library to register the tag with
- `tag`: The name of the template tag (e.g. `"mytag"` for `{% mytag %}`)
- `end_tag`: Optional. The name of the end tag (e.g. `"endmytag"` for `{% endmytag %}`)
- `allowed_flags`: Optional. List of flags that can be used with the tag (e.g. `["required"]` for `{% mytag required %}`)
### Function signature
The function decorated with `@template_tag` must accept at least two arguments:
1. `node`: The node instance (we'll explain this in detail in the next section)
2. `context`: The Django template context
Any additional parameters in your function's signature define what inputs your template tag accepts. For example:
```python
@template_tag(library, tag="greet")
def greet(
node: BaseNode,
context: Context,
name: str, # required positional argument
count: int = 1, # optional positional argument
*, # keyword-only arguments marker
msg: str, # required keyword argument
mode: str = "default", # optional keyword argument
) -> str:
return f"{msg}, {name}!" * count
```
This allows the tag to be used like:
```django
{# All parameters #}
{% greet "John" count=2 msg="Hello" mode="custom" %}
{# Only required parameters #}
{% greet "John" msg="Hello" %}
{# Missing required parameter - will raise error #}
{% greet "John" %} {# Error: missing 'msg' #}
```
When you pass input to a template tag, it behaves the same way as if you passed the input to a function:
- If required parameters are missing, an error is raised
- If unexpected parameters are passed, an error is raised
To accept keys that are not valid Python identifiers (e.g. `data-id`), or would conflict with Python keywords (e.g. `is`), you can use the `**kwargs` syntax:
```python
@template_tag(library, tag="greet")
def greet(
node: BaseNode,
context: Context,
**kwargs,
) -> str:
attrs = kwargs.copy()
is_var = attrs.pop("is", None)
attrs_str = " ".join(f'{k}="{v}"' for k, v in attrs.items())
return mark_safe(f"""
<div {attrs_str}>
Hello, {is_var}!
</div>
""")
```
This allows you to use the tag like this:
```django
{% greet is="John" data-id="123" %}
```
## Defining template tags with `BaseNode`
For more control over your template tag, you can subclass [`BaseNode`](../../../reference/api#django_components.BaseNode) directly instead of using the decorator. This gives you access to additional features like the node's internal state and parsing details.
```python
from django_components import BaseNode
class GreetNode(BaseNode):
tag = "greet"
end_tag = "endgreet"
allowed_flags = ["required"]
def render(self, context: Context, name: str, **kwargs) -> str:
# Access node properties
if self.flags["required"]:
return f"Required greeting: Hello, {name}!"
return f"Hello, {name}!"
# Register the node
GreetNode.register(library)
```
### Node properties
When using `BaseNode`, you have access to several useful properties:
- `node_id`: A unique identifier for this node instance
- `flags`: Dictionary of flag values (e.g. `{"required": True}`)
- `params`: List of raw parameters passed to the tag
- `nodelist`: The template nodes between the start and end tags
- `active_flags`: List of flags that are currently set to True
This is what the `node` parameter in the `@template_tag` decorator gives you access to - it's the instance of the node class that was automatically created for your template tag.
### Rendering content between tags
When your tag has an end tag, you can access and render the content between the tags using `nodelist`:
```python
class WrapNode(BaseNode):
tag = "wrap"
end_tag = "endwrap"
def render(self, context: Context, tag: str = "div", **attrs) -> str:
# Render the content between tags
inner = self.nodelist.render(context)
attrs_str = " ".join(f'{k}="{v}"' for k, v in attrs.items())
return f"<{tag} {attrs_str}>{inner}</{tag}>"
# Usage:
{% wrap tag="section" class="content" %}
Hello, world!
{% endwrap %}
```
### Unregistering nodes
You can unregister a node from a library using the `unregister` method:
```python
GreetNode.unregister(library)
```
This is particularly useful in testing when you want to clean up after registering temporary tags.

View file

@ -49,8 +49,8 @@ from django.urls import URLPattern, URLResolver
from django_components import ComponentVars, TagFormatterABC
from django_components.component import Component
from django_components.node import BaseNode
from django_components.util.misc import get_import_path
from django_components.util.template_tag import TagSpec
# NOTE: This file is an entrypoint for the `gen-files` plugin in `mkdocs.yml`.
# However, `gen-files` plugin runs this file as a script, NOT as a module.
@ -504,17 +504,18 @@ def gen_reference_templatetags():
f"Import as\n```django\n{{% load {mod_name} %}}\n```\n\n"
)
for name, obj in inspect.getmembers(tags_module):
for _, obj in inspect.getmembers(tags_module):
if not _is_template_tag(obj):
continue
tag_spec: TagSpec = obj._tag_spec
tag_signature = _format_tag_signature(tag_spec)
obj_lineno = inspect.findsource(obj)[1]
node_cls: BaseNode = obj
name = node_cls.tag
tag_signature = _format_tag_signature(node_cls)
obj_lineno = inspect.findsource(node_cls)[1]
source_code_link = _format_source_code_html(module_rel_path, obj_lineno)
# Use the tag's function's docstring
docstring = dedent(obj.__doc__ or "").strip()
docstring = dedent(node_cls.__doc__ or "").strip()
# Rebuild (almost) the same documentation than as if we used
# mkdocstrings' `::: path.to.module` syntax.
@ -585,29 +586,29 @@ def _list_urls(urlpatterns: Sequence[Union[URLPattern, URLResolver]], prefix="")
return urls
def _format_tag_signature(tag_spec: TagSpec) -> str:
def _format_tag_signature(node_cls: BaseNode) -> str:
"""
Given the TagSpec instance, format the tag's function signature like:
Given the Node class, format the tag's function signature like:
```django
{% component [arg, ...] **kwargs [only] %}
{% component arg1: int, arg2: str, *args, **kwargs: Any [only] %}
{% endcomponent %}
```
"""
# The signature returns a string like:
# `(arg: Any, **kwargs: Any) -> None`
params_str = str(tag_spec.signature)
params_str = str(node_cls._signature)
# Remove the return type annotation, the `-> None` part
params_str = params_str.rsplit("->", 1)[0]
# Remove brackets around the params, to end up only with `arg: Any, **kwargs: Any`
params_str = params_str.strip()[1:-1]
if tag_spec.flags:
params_str += " " + " ".join([f"[{name}]" for name in tag_spec.flags])
if node_cls.allowed_flags:
params_str += " " + " ".join([f"[{name}]" for name in node_cls.allowed_flags])
# Create the function signature
full_tag = "{% " + tag_spec.tag + " " + params_str + " %}"
if tag_spec.end_tag:
full_tag += f"\n{{% {tag_spec.end_tag} %}}"
full_tag = "{% " + node_cls.tag + " " + params_str + " %}"
if node_cls.end_tag:
full_tag += f"\n{{% {node_cls.end_tag} %}}"
return full_tag
@ -722,7 +723,7 @@ def _is_tag_formatter_instance(obj: Any) -> bool:
def _is_template_tag(obj: Any) -> bool:
return callable(obj) and hasattr(obj, "_tag_spec")
return inspect.isclass(obj) and issubclass(obj, BaseNode)
def gen_reference():

View file

@ -19,6 +19,7 @@ from django_components.component_registry import (
from django_components.components import DynamicComponent
from django_components.dependencies import render_dependencies
from django_components.library import TagProtectedError
from django_components.node import BaseNode, template_tag
from django_components.slots import SlotContent, Slot, SlotFunc, SlotRef, SlotResult
from django_components.tag_formatter import (
ComponentFormatter,
@ -40,6 +41,7 @@ __all__ = [
"AlreadyRegistered",
"autodiscover",
"cached_template",
"BaseNode",
"ContextBehavior",
"ComponentsSettings",
"Component",
@ -72,5 +74,6 @@ __all__ = [
"TagFormatterABC",
"TagProtectedError",
"TagResult",
"template_tag",
"types",
]

View file

@ -2,39 +2,84 @@
# See https://github.com/Xzya/django-web-components/blob/b43eb0c832837db939a6f8c1980334b0adfdd6e4/django_web_components/templatetags/components.py # noqa: E501
# And https://github.com/Xzya/django-web-components/blob/b43eb0c832837db939a6f8c1980334b0adfdd6e4/django_web_components/attributes.py # noqa: E501
from typing import Any, Dict, List, Mapping, Optional, Tuple
from typing import Any, Dict, Mapping, Optional, Tuple
from django.template import Context
from django.utils.html import conditional_escape, format_html
from django.utils.safestring import SafeString, mark_safe
from django_components.node import BaseNode
from django_components.util.template_tag import TagParams
HTML_ATTRS_DEFAULTS_KEY = "defaults"
HTML_ATTRS_ATTRS_KEY = "attrs"
class HtmlAttrsNode(BaseNode):
def __init__(
"""
Generate HTML attributes (`key="value"`), combining data from multiple sources,
whether its template variables or static text.
It is designed to easily merge HTML attributes passed from outside with the internal.
See how to in [Passing HTML attributes to components](../../guides/howto/passing_html_attrs/).
**Args:**
- `attrs` (dict, optional): Optional dictionary that holds HTML attributes. On conflict, overrides
values in the `default` dictionary.
- `default` (str, optional): Optional dictionary that holds HTML attributes. On conflict, is overriden
with values in the `attrs` dictionary.
- Any extra kwargs will be appended to the corresponding keys
The attributes in `attrs` and `defaults` are merged and resulting dict is rendered as HTML attributes
(`key="value"`).
Extra kwargs (`key=value`) are concatenated to existing keys. So if we have
```python
attrs = {"class": "my-class"}
```
Then
```django
{% html_attrs attrs class="extra-class" %}
```
will result in `class="my-class extra-class"`.
**Example:**
```django
<div {% html_attrs
attrs
defaults:class="default-class"
class="extra-class"
data-id="123"
%}>
```
renders
```html
<div class="my-class extra-class" data-id="123">
```
**See more usage examples in
[HTML attributes](../../concepts/fundamentals/html_attributes#examples-for-html_attrs).**
"""
tag = "html_attrs"
end_tag = None # inline-only
allowed_flags = []
def render(
self,
params: TagParams,
node_id: Optional[str] = None,
):
super().__init__(nodelist=None, params=params, node_id=node_id)
def render(self, context: Context) -> str:
append_attrs: List[Tuple[str, Any]] = []
# Resolve all data
args, kwargs = self.params.resolve(context)
attrs = kwargs.pop(HTML_ATTRS_ATTRS_KEY, None) or {}
defaults = kwargs.pop(HTML_ATTRS_DEFAULTS_KEY, None) or {}
append_attrs = list(kwargs.items())
# Merge it
final_attrs = {**defaults, **attrs}
final_attrs = append_attributes(*final_attrs.items(), *append_attrs)
context: Context,
attrs: Optional[Dict] = None,
defaults: Optional[Dict] = None,
**kwargs: Any,
) -> SafeString:
# Merge
final_attrs = {}
final_attrs.update(defaults or {})
final_attrs.update(attrs or {})
final_attrs = append_attributes(*final_attrs.items(), *kwargs.items())
# Render to HTML attributes
return attributes_to_string(final_attrs)

View file

@ -11,6 +11,7 @@ from typing import (
Dict,
Generator,
Generic,
List,
Literal,
Mapping,
NamedTuple,
@ -26,7 +27,7 @@ from typing import (
from django.core.exceptions import ImproperlyConfigured
from django.forms.widgets import Media as MediaCls
from django.http import HttpRequest, HttpResponse
from django.template.base import NodeList, Template, TextNode
from django.template.base import NodeList, Parser, Template, TextNode, Token
from django.template.context import Context, RequestContext
from django.template.loader import get_template
from django.template.loader_tags import BLOCK_CONTEXT_KEY
@ -69,9 +70,8 @@ from django_components.slots import (
)
from django_components.template import cached_template
from django_components.util.django_monkeypatch import is_template_cls_patched
from django_components.util.logger import trace_msg
from django_components.util.misc import gen_id
from django_components.util.template_tag import TagParams
from django_components.util.template_tag import TagAttr
from django_components.util.validation import validate_typed_dict, validate_typed_tuple
# TODO_REMOVE_IN_V1 - Users should use top-level import instead
@ -1209,32 +1209,147 @@ class Component(
class ComponentNode(BaseNode):
"""Django.template.Node subclass that renders a django-components component"""
"""
Renders one of the components that was previously registered with
[`@register()`](./api.md#django_components.register)
decorator.
**Args:**
- `name` (str, required): Registered name of the component to render
- All other args and kwargs are defined based on the component itself.
If you defined a component `"my_table"`
```python
from django_component import Component, register
@register("my_table")
class MyTable(Component):
template = \"\"\"
<table>
<thead>
{% for header in headers %}
<th>{{ header }}</th>
{% endfor %}
</thead>
<tbody>
{% for row in rows %}
<tr>
{% for cell in row %}
<td>{{ cell }}</td>
{% endfor %}
</tr>
{% endfor %}
<tbody>
</table>
\"\"\"
def get_context_data(self, rows: List, headers: List):
return {
"rows": rows,
"headers": headers,
}
```
Then you can render this component by referring to `MyTable` via its
registered name `"my_table"`:
```django
{% component "my_table" rows=rows headers=headers ... / %}
```
### Component input
Positional and keyword arguments can be literals or template variables.
The component name must be a single- or double-quotes string and must
be either:
- The first positional argument after `component`:
```django
{% component "my_table" rows=rows headers=headers ... / %}
```
- Passed as kwarg `name`:
```django
{% component rows=rows headers=headers name="my_table" ... / %}
```
### Inserting into slots
If the component defined any [slots](../concepts/fundamentals/slots.md), you can
pass in the content to be placed inside those slots by inserting [`{% fill %}`](#fill) tags,
directly within the `{% component %}` tag:
```django
{% component "my_table" rows=rows headers=headers ... / %}
{% fill "pagination" %}
< 1 | 2 | 3 >
{% endfill %}
{% endcomponent %}
```
### Isolating components
By default, components behave similarly to Django's
[`{% include %}`](https://docs.djangoproject.com/en/5.1/ref/templates/builtins/#include),
and the template inside the component has access to the variables defined in the outer template.
You can selectively isolate a component, using the `only` flag, so that the inner template
can access only the data that was explicitly passed to it:
```django
{% component "name" positional_arg keyword_arg=value ... only %}
```
"""
tag = "component"
end_tag = "endcomponent"
allowed_flags = [COMP_ONLY_FLAG]
def __init__(
self,
# ComponentNode inputs
name: str,
registry: ComponentRegistry, # noqa F811
nodelist: NodeList,
params: TagParams,
isolated_context: bool = False,
# BaseNode inputs
params: List[TagAttr],
flags: Optional[Dict[str, bool]] = None,
nodelist: Optional[NodeList] = None,
node_id: Optional[str] = None,
) -> None:
super().__init__(nodelist=nodelist or NodeList(), params=params, node_id=node_id)
super().__init__(params=params, flags=flags, nodelist=nodelist, node_id=node_id)
self.name = name
self.isolated_context = isolated_context
self.registry = registry
def __repr__(self) -> str:
return "<ComponentNode: {}. Contents: {!r}>".format(
self.name,
getattr(self, "nodelist", None), # 'nodelist' attribute only assigned later.
@classmethod
def parse( # type: ignore[override]
cls,
parser: Parser,
token: Token,
registry: ComponentRegistry, # noqa F811
name: str,
start_tag: str,
end_tag: str,
) -> "ComponentNode":
# Set the component-specific start and end tags by subclassing the base node
subcls_name = cls.__name__ + "_" + name
subcls: Type[ComponentNode] = type(subcls_name, (cls,), {"tag": start_tag, "end_tag": end_tag})
# Call `BaseNode.parse()` as if with the context of subcls.
node: ComponentNode = super(cls, subcls).parse( # type: ignore[attr-defined]
parser,
token,
registry=registry,
name=name,
)
return node
def render(self, context: Context) -> str:
trace_msg("RENDR", "COMP", self.name, self.node_id)
def render(self, context: Context, *args: Any, **kwargs: Any) -> str:
# Do not render nested `{% component %}` tags in other `{% component %}` tags
# at the stage when we are determining if the latter has named fills or not.
if _is_extracting_fill(context):
@ -1242,11 +1357,6 @@ class ComponentNode(BaseNode):
component_cls: Type[Component] = self.registry.get(self.name)
# Resolve FilterExpressions and Variables that were passed as args to the
# component, then call component's context method
# to get values to insert into the context
args, kwargs = self.params.resolve(context)
slot_fills = resolve_fills(context, self.nodelist, self.name)
component: Component = component_cls(
@ -1256,7 +1366,7 @@ class ComponentNode(BaseNode):
)
# Prevent outer context from leaking into the template of the component
if self.isolated_context or self.registry.settings.context_behavior == ContextBehavior.ISOLATED:
if self.flags[COMP_ONLY_FLAG] or self.registry.settings.context_behavior == ContextBehavior.ISOLATED:
context = make_isolated_context_copy(context)
output = component._render(
@ -1269,7 +1379,6 @@ class ComponentNode(BaseNode):
render_dependencies=False,
)
trace_msg("RENDR", "COMP", self.name, self.node_id, "...Done!")
return output

View file

@ -1,9 +1,10 @@
from typing import TYPE_CHECKING, Callable, Dict, List, NamedTuple, Optional, Set, Type, Union
from django.template import Library
from django.template.base import Parser, Token
from django_components.app_settings import ContextBehaviorType, app_settings
from django_components.library import is_tag_protected, mark_protected_tags, register_tag_from_formatter
from django_components.library import is_tag_protected, mark_protected_tags, register_tag
from django_components.tag_formatter import TagFormatterABC, get_tag_formatter
if TYPE_CHECKING:
@ -462,12 +463,39 @@ class ComponentRegistry:
component: Type["Component"],
) -> ComponentRegistryEntry:
# Lazily import to avoid circular dependencies
from django_components.templatetags.component_tags import component as do_component
from django_components.component import ComponentNode
formatter = get_tag_formatter(self)
tag = register_tag_from_formatter(self, do_component, formatter, comp_name)
registry = self
return ComponentRegistryEntry(cls=component, tag=tag)
# Define a tag function that pre-processes the tokens, extracting
# the component name and passing the rest to the actual tag function.
def tag_fn(parser: Parser, token: Token) -> ComponentNode:
# Let the TagFormatter pre-process the tokens
bits = token.split_contents()
formatter = get_tag_formatter(registry)
result = formatter.parse([*bits])
start_tag = formatter.start_tag(result.component_name)
end_tag = formatter.end_tag(result.component_name)
# NOTE: The tokens returned from TagFormatter.parse do NOT include the tag itself,
# so we add it back in.
bits = [bits[0], *result.tokens]
token.contents = " ".join(bits)
return ComponentNode.parse(
parser,
token,
registry=registry,
name=result.component_name,
start_tag=start_tag,
end_tag=end_tag,
)
formatter = get_tag_formatter(registry)
start_tag = formatter.start_tag(comp_name)
register_tag(self.library, start_tag, tag_fn)
return ComponentRegistryEntry(cls=component, tag=start_tag)
# This variable represents the global component registry

View file

@ -28,11 +28,13 @@ from asgiref.sync import iscoroutinefunction, markcoroutinefunction
from django.forms import Media
from django.http import HttpRequest, HttpResponse, HttpResponseNotAllowed, HttpResponseNotFound, StreamingHttpResponse
from django.http.response import HttpResponseBase
from django.template import Context, TemplateSyntaxError
from django.templatetags.static import static
from django.urls import path, reverse
from django.utils.decorators import sync_and_async_middleware
from django.utils.safestring import SafeString, mark_safe
from django_components.node import BaseNode
from django_components.util.html import SoupNode
from django_components.util.misc import get_import_path, is_nonempty_str
@ -1036,3 +1038,66 @@ class ComponentDependencyMiddleware:
response.content = render_dependencies(response.content, type="document")
return response
#########################################################
# 6. Template tags
#########################################################
def _component_dependencies(type: Literal["js", "css"]) -> SafeString:
"""Marks location where CSS link and JS script tags should be rendered."""
if type == "css":
placeholder = CSS_DEPENDENCY_PLACEHOLDER
elif type == "js":
placeholder = JS_DEPENDENCY_PLACEHOLDER
else:
raise TemplateSyntaxError(
f"Unknown dependency type in {{% component_dependencies %}}. Must be one of 'css' or 'js', got {type}"
)
return mark_safe(placeholder)
class ComponentCssDependenciesNode(BaseNode):
"""
Marks location where CSS link tags should be rendered after the whole HTML has been generated.
Generally, this should be inserted into the `<head>` tag of the HTML.
If the generated HTML does NOT contain any `{% component_css_dependencies %}` tags, CSS links
are by default inserted into the `<head>` tag of the HTML. (See
[JS and CSS output locations](../../concepts/advanced/rendering_js_css/#js-and-css-output-locations))
Note that there should be only one `{% component_css_dependencies %}` for the whole HTML document.
If you insert this tag multiple times, ALL CSS links will be duplicately inserted into ALL these places.
"""
tag = "component_css_dependencies"
end_tag = None # inline-only
allowed_flags = []
def render(self, context: Context) -> str:
return _component_dependencies("css")
class ComponentJsDependenciesNode(BaseNode):
"""
Marks location where JS link tags should be rendered after the whole HTML has been generated.
Generally, this should be inserted at the end of the `<body>` tag of the HTML.
If the generated HTML does NOT contain any `{% component_js_dependencies %}` tags, JS scripts
are by default inserted at the end of the `<body>` tag of the HTML. (See
[JS and CSS output locations](../../concepts/advanced/rendering_js_css/#js-and-css-output-locations))
Note that there should be only one `{% component_js_dependencies %}` for the whole HTML document.
If you insert this tag multiple times, ALL JS scripts will be duplicately inserted into ALL these places.
"""
tag = "component_js_dependencies"
end_tag = None # inline-only
allowed_flags = []
def render(self, context: Context) -> str:
return _component_dependencies("js")

View file

@ -1,15 +1,10 @@
"""Module for interfacing with Django's Library (`django.template.library`)"""
from typing import TYPE_CHECKING, Callable, List, Optional
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
if TYPE_CHECKING:
from django_components.component_registry import ComponentRegistry
class TagProtectedError(Exception):
"""
@ -56,26 +51,15 @@ as they would conflict with other tags in the Library.
def register_tag(
registry: "ComponentRegistry",
library: Library,
tag: str,
tag_fn: Callable[[Parser, Token, "ComponentRegistry", str], Node],
tag_fn: Callable[[Parser, Token], Node],
) -> None:
# Register inline tag
if is_tag_protected(registry.library, tag):
if is_tag_protected(library, tag):
raise TagProtectedError('Cannot register tag "%s", this tag name is protected' % tag)
else:
registry.library.tag(tag, lambda parser, token: tag_fn(parser, token, registry, tag))
def register_tag_from_formatter(
registry: "ComponentRegistry",
tag_fn: Callable[[Parser, Token, "ComponentRegistry", str], Node],
formatter: InternalTagFormatter,
component_name: str,
) -> str:
tag = formatter.start_tag(component_name)
register_tag(registry, tag, tag_fn)
return tag
library.tag(tag, tag_fn)
def mark_protected_tags(lib: Library, tags: Optional[List[str]] = None) -> None:

View file

@ -1,20 +1,475 @@
from typing import Optional
import functools
import inspect
import keyword
from typing import Any, Callable, Dict, List, Optional, Tuple, Type, cast
from django.template.base import Node, NodeList
from django.template import Context, Library
from django.template.base import Node, NodeList, Parser, Token
from django_components.util.logger import trace_msg
from django_components.util.misc import gen_id
from django_components.util.template_tag import TagParams
from django_components.util.template_tag import (
TagAttr,
TagParam,
apply_params_in_original_order,
parse_template_tag,
resolve_params,
validate_params,
)
class BaseNode(Node):
"""Shared behavior for our subclasses of Django's `Node`"""
# Normally, when `Node.render()` is called, it receives only a single argument `context`.
#
# ```python
# def render(self, context: Context) -> str:
# return self.nodelist.render(context)
# ```
#
# In django-components, the input to template tags is treated as function inputs, e.g.
#
# `{% component name="John" age=20 %}`
#
# And, for convenience, we want to allow the `render()` method to accept these extra parameters.
# That way, user can define just the `render()` method and have access to all the information:
#
# ```python
# def render(self, context: Context, name: str, **kwargs: Any) -> str:
# return f"Hello, {name}!"
# ```
#
# So we need to wrap the `render()` method, and for that we need the metaclass.
#
# The outer `render()` (our wrapper) will match the `Node.render()` signature (accepting only `context`),
# while the inner `render()` (the actual implementation) will match the user-defined `render()` method's signature
# (accepting all the parameters).
class NodeMeta(type):
def __new__(
mcs,
name: str,
bases: Tuple[Type, ...],
attrs: Dict[str, Any],
) -> Type["BaseNode"]:
cls = cast(Type["BaseNode"], super().__new__(mcs, name, bases, attrs))
# Ignore the `BaseNode` class itself
if attrs.get("__module__", None) == "django_components.node":
return cls
if not hasattr(cls, "tag"):
raise ValueError(f"Node {name} must have a 'tag' attribute")
# Skip if already wrapped
orig_render = cls.render
if getattr(orig_render, "_djc_wrapped", False):
return cls
signature = inspect.signature(orig_render)
# A full signature of `BaseNode.render()` may look like this:
#
# `def render(self, context: Context, name: str, **kwargs) -> str:`
#
# We need to remove the first two parameters from the signature.
# So we end up only with
#
# `def render(name: str, **kwargs) -> str:`
#
# And this becomes the signature that defines what params the template tag accepts, e.g.
#
# `{% component name="John" age=20 %}`
if len(signature.parameters) < 2:
raise TypeError(f"`render()` method of {name} must have at least two parameters")
validation_params = list(signature.parameters.values())
validation_params = validation_params[2:]
validation_signature = signature.replace(parameters=validation_params)
# NOTE: This is used for creating docs by `_format_tag_signature()` in `docs/scripts/reference.py`
cls._signature = validation_signature
@functools.wraps(orig_render)
def wrapper_render(self: "BaseNode", context: Context) -> str:
trace_msg("RENDR", self.tag, self.node_id)
resolved_params = resolve_params(self.tag, self.params, context)
# Template tags may accept kwargs that are not valid Python identifiers, e.g.
# `{% component data-id="John" class="pt-4" :href="myVar" %}`
#
# Passing them in is still useful, as user may want to pass in arbitrary data
# to their `{% component %}` tags as HTML attributes. E.g. example below passes
# `data-id`, `class` and `:href` as HTML attributes to the `<div>` element:
#
# ```py
# class MyComponent(Component):
# def get_context_data(self, name: str, **kwargs: Any) -> str:
# return {
# "name": name,
# "attrs": kwargs,
# }
# template = """
# <div {% html_attrs attrs %}>
# {{ name }}
# </div>
# """
# ```
#
# HOWEVER, these kwargs like `data-id`, `class` and `:href` may not be valid Python identifiers,
# or like in case of `class`, may be a reserved keyword. Thus, we cannot pass them in to the `render()`
# method as regular kwargs, because that will raise Python's native errors like
# `SyntaxError: invalid syntax`. E.g.
#
# ```python
# def render(self, context: Context, data-id: str, class: str, :href: str) -> str:
# ```
#
# So instead, we filter out any invalid kwargs, and pass those in through a dictionary spread.
# We can do so, because following is allowed in Python:
#
# ```python
# def x(**kwargs):
# print(kwargs)
#
# d = {"data-id": 1}
# x(**d)
# # {'data-id': 1}
# ```
#
# See https://github.com/EmilStenstrom/django-components/discussions/900#discussioncomment-11859970
resolved_params_without_invalid_kwargs = []
invalid_kwargs = {}
did_see_special_kwarg = False
for resolved_param in resolved_params:
key = resolved_param.key
if key is not None:
# Case: Special kwargs
if not key.isidentifier() or keyword.iskeyword(key):
# NOTE: Since these keys are not part of signature validation,
# we have to check ourselves if any args follow them.
invalid_kwargs[key] = resolved_param.value
did_see_special_kwarg = True
else:
# Case: Regular kwargs
resolved_params_without_invalid_kwargs.append(resolved_param)
else:
# Case: Regular positional args
if did_see_special_kwarg:
raise SyntaxError("positional argument follows keyword argument")
resolved_params_without_invalid_kwargs.append(resolved_param)
# Validate the params against the signature
#
# Unlike the call to `apply_params_in_original_order()` further below, this uses a signature
# that has been stripped of the `self` and `context` parameters. E.g.
#
# `def render(name: str, **kwargs: Any) -> None`
#
# If there are any errors in the input, this will trigger Python's
# native error handling (e.g. `TypeError: render() got multiple values for argument 'context'`)
#
# But because we stripped the two parameters, then these errors will correctly
# point to the actual error in the template tag.
#
# E.g. if we supplied one too many positional args,
# `{% mytag "John" 20 %}`
#
# Then without stripping the two parameters, then the error could be:
# `render() takes from 3 positional arguments but 4 were given`
#
# Which is confusing, because we supplied only two positional args.
#
# But cause we stripped the two parameters, then the error will be:
# `render() takes from 1 positional arguments but 2 were given`
validate_params(self.tag, validation_signature, resolved_params_without_invalid_kwargs, invalid_kwargs)
# The code below calls the `orig_render()` function like so:
# `orig_render(self, context, arg1, arg2, kwarg1=val1, kwarg2=val2)`
#
# So it's called in the same order as what was passed to the template tag, e.g.
# `{% component arg1 arg2 kwarg1=val1 kwarg2=val2 %}`
#
# That's why we don't simply spread all args and kwargs as `*args, **kwargs`,
# because then Python's validation wouldn't catch such errors.
resolved_params_with_context = [
TagParam(key=None, value=self),
TagParam(key=None, value=context),
] + resolved_params_without_invalid_kwargs
output = apply_params_in_original_order(orig_render, resolved_params_with_context, invalid_kwargs)
trace_msg("RENDR", self.tag, self.node_id, msg="...Done!")
return output
# Wrap cls.render() so we resolve the args and kwargs and pass them to the
# actual render method.
cls.render = wrapper_render # type: ignore
cls.render._djc_wrapped = True # type: ignore
return cls
class BaseNode(Node, metaclass=NodeMeta):
"""
Node class for all django-components custom template tags.
This class has a dual role:
1. It declares how a particular template tag should be parsed - By setting the
[`tag`](../api#django_components.BaseNode.tag),
[`end_tag`](../api#django_components.BaseNode.end_tag),
and [`allowed_flags`](../api#django_components.BaseNode.allowed_flags)
attributes:
```python
class SlotNode(BaseNode):
tag = "slot"
end_tag = "endslot"
allowed_flags = ["required"]
```
This will allow the template tag `{% slot %}` to be used like this:
```django
{% slot required %} ... {% endslot %}
```
2. The [`render`](../api#django_components.BaseNode.render) method is
the actual implementation of the template tag.
This is where the tag's logic is implemented:
```python
class MyNode(BaseNode):
tag = "mynode"
def render(self, context: Context, name: str, **kwargs: Any) -> str:
return f"Hello, {name}!"
```
This will allow the template tag `{% mynode %}` to be used like this:
```django
{% mynode name="John" %}
```
The template tag accepts parameters as defined on the
[`render`](../api#django_components.BaseNode.render) method's signature.
For more info, see [`BaseNode.render()`](../api#django_components.BaseNode.render).
"""
# #####################################
# PUBLIC API (Configurable by users)
# #####################################
tag: str
"""
The tag name.
E.g. `"component"` or `"slot"` will make this class match
template tags `{% component %}` or `{% slot %}`.
"""
end_tag: Optional[str] = None
"""
The end tag name.
E.g. `"endcomponent"` or `"endslot"` will make this class match
template tags `{% endcomponent %}` or `{% endslot %}`.
If not set, then this template tag has no end tag.
So instead of `{% component %} ... {% endcomponent %}`, you'd use only
`{% component %}`.
"""
allowed_flags: Optional[List[str]] = None
"""
The allowed flags for this tag.
E.g. `["required"]` will allow this tag to be used like `{% slot required %}`.
"""
def render(self, context: Context, *args: Any, **kwargs: Any) -> str:
"""
Render the node. This method is meant to be overridden by subclasses.
The signature of this function decides what input the template tag accepts.
The `render()` method MUST accept a `context` argument. Any arguments after that
will be part of the tag's input parameters.
So if you define a `render` method like this:
```python
def render(self, context: Context, name: str, **kwargs: Any) -> str:
```
Then the tag will require the `name` parameter, and accept any extra keyword arguments:
```django
{% component name="John" age=20 %}
```
"""
return self.nodelist.render(context)
# #####################################
# MISC
# #####################################
def __init__(
self,
params: TagParams,
params: List[TagAttr],
flags: Optional[Dict[str, bool]] = None,
nodelist: Optional[NodeList] = None,
node_id: Optional[str] = None,
):
self.params = params
self.flags = flags or {flag: False for flag in self.allowed_flags or []}
self.nodelist = nodelist or NodeList()
self.node_id = node_id or gen_id()
def __repr__(self) -> str:
return (
f"<{self.__class__.__name__}: {self.node_id}. Contents: {repr(self.nodelist)}."
f" Flags: {self.active_flags}>"
)
@property
def active_flags(self) -> List[str]:
"""Flags that were set for this specific instance."""
flags = []
for flag, value in self.flags.items():
if value:
flags.append(flag)
return flags
@classmethod
def parse(cls, parser: Parser, token: Token, **kwargs: Any) -> "BaseNode":
"""
This function is what is passed to Django's `Library.tag()` when
[registering the tag](https://docs.djangoproject.com/en/5.1/howto/custom-template-tags/#registering-the-tag).
In other words, this method is called by Django's template parser when we encounter
a tag that matches this node's tag, e.g. `{% component %}` or `{% slot %}`.
To register the tag, you can use [`BaseNode.register()`](../api#django_components.BaseNode.register).
"""
tag_id = gen_id()
tag = parse_template_tag(cls.tag, cls.end_tag, cls.allowed_flags, parser, token)
trace_msg("PARSE", cls.tag, tag_id)
body = tag.parse_body()
node = cls(
nodelist=body,
node_id=tag_id,
params=tag.params,
flags=tag.flags,
**kwargs,
)
trace_msg("PARSE", cls.tag, tag_id, "...Done!")
return node
@classmethod
def register(cls, library: Library) -> None:
"""
A convenience method for registering the tag with the given library.
```python
class MyNode(BaseNode):
tag = "mynode"
MyNode.register(library)
```
Allows you to then use the node in templates like so:
```django
{% load mylibrary %}
{% mynode %}
```
"""
library.tag(cls.tag, cls.parse)
@classmethod
def unregister(cls, library: Library) -> None:
"""Unregisters the node from the given library."""
library.tags.pop(cls.tag, None)
def template_tag(
library: Library,
tag: str,
end_tag: Optional[str] = None,
allowed_flags: Optional[List[str]] = None,
) -> Callable[[Callable], Callable]:
"""
A simplified version of creating a template tag based on [`BaseNode`](../api#django_components.BaseNode).
Instead of defining the whole class, you can just define the
[`render()`](../api#django_components.BaseNode.render) method.
```python
from django.template import Context, Library
from django_components import BaseNode, template_tag
library = Library()
@template_tag(
library,
tag="mytag",
end_tag="endmytag",
allowed_flags=["required"],
)
def mytag(node: BaseNode, context: Context, name: str, **kwargs: Any) -> str:
return f"Hello, {name}!"
```
This will allow the template tag `{% mytag %}` to be used like this:
```django
{% mytag name="John" %}
{% mytag name="John" required %} ... {% endmytag %}
```
The given function will be wrapped in a class that inherits from [`BaseNode`](../api#django_components.BaseNode).
And this class will be registered with the given library.
The function MUST accept at least two positional arguments: `node` and `context`
- `node` is the [`BaseNode`](../api#django_components.BaseNode) instance.
- `context` is the [`Context`](https://docs.djangoproject.com/en/5.1/ref/templates/api/#django.template.Context)
of the template.
Any extra parameters defined on this function will be part of the tag's input parameters.
For more info, see [`BaseNode.render()`](../api#django_components.BaseNode.render).
"""
def decorator(fn: Callable) -> Callable:
subcls_name = fn.__name__.title().replace("_", "").replace("-", "") + "Node"
try:
subcls: Type[BaseNode] = type(
subcls_name,
(BaseNode,),
{
"tag": tag,
"end_tag": end_tag,
"allowed_flags": allowed_flags or [],
"render": fn,
},
)
except Exception as e:
raise e.__class__(f"Failed to create node class in 'template_tag()' for '{fn.__name__}'") from e
subcls.register(library)
# Allow to access the node class
fn._node = subcls # type: ignore[attr-defined]
return fn
return decorator

View file

@ -1,42 +1,87 @@
from typing import Dict, Optional, Tuple
from typing import Any
from django.template import Context
from django.template.base import NodeList
from django.utils.safestring import SafeString
from django_components.context import set_provided_context_var
from django_components.node import BaseNode
from django_components.util.logger import trace_msg
from django_components.util.template_tag import TagParams
PROVIDE_NAME_KWARG = "name"
class ProvideNode(BaseNode):
"""
Implementation of the `{% provide %}` tag.
For more info see `Component.inject`.
The "provider" part of the [provide / inject feature](../../concepts/advanced/provide_inject).
Pass kwargs to this tag to define the provider's data.
Any components defined within the `{% provide %}..{% endprovide %}` tags will be able to access this data
with [`Component.inject()`](../api#django_components.Component.inject).
This is similar to React's [`ContextProvider`](https://react.dev/learn/passing-data-deeply-with-context),
or Vue's [`provide()`](https://vuejs.org/guide/components/provide-inject).
**Args:**
- `name` (str, required): Provider name. This is the name you will then use in
[`Component.inject()`](../api#django_components.Component.inject).
- `**kwargs`: Any extra kwargs will be passed as the provided data.
**Example:**
Provide the "user_data" in parent component:
```python
@register("parent")
class Parent(Component):
template = \"\"\"
<div>
{% provide "user_data" user=user %}
{% component "child" / %}
{% endprovide %}
</div>
\"\"\"
def get_context_data(self, user: User):
return {
"user": user,
}
```
Since the "child" component is used within the `{% provide %} / {% endprovide %}` tags,
we can request the "user_data" using `Component.inject("user_data")`:
```python
@register("child")
class Child(Component):
template = \"\"\"
<div>
User is: {{ user }}
</div>
\"\"\"
def get_context_data(self):
user = self.inject("user_data").user
return {
"user": user,
}
```
Notice that the keys defined on the `{% provide %}` tag are then accessed as attributes
when accessing them with [`Component.inject()`](../api#django_components.Component.inject).
Do this
```python
user = self.inject("user_data").user
```
Don't do this
```python
user = self.inject("user_data")["user"]
```
"""
def __init__(
self,
nodelist: NodeList,
params: TagParams,
trace_id: str,
node_id: Optional[str] = None,
):
super().__init__(nodelist=nodelist, params=params, node_id=node_id)
self.trace_id = trace_id
def __repr__(self) -> str:
return f"<Provide Node: {self.node_id}. Contents: {repr(self.nodelist)}>"
def render(self, context: Context) -> SafeString:
trace_msg("RENDR", "PROVIDE", self.trace_id, self.node_id)
name, kwargs = self.resolve_kwargs(context)
tag = "provide"
end_tag = "endprovide"
allowed_flags = []
def render(self, context: Context, name: str, **kwargs: Any) -> SafeString:
# NOTE: The "provided" kwargs are meant to be shared privately, meaning that components
# have to explicitly opt in by using the `Component.inject()` method. That's why we don't
# add the provided kwargs into the Context.
@ -46,14 +91,4 @@ class ProvideNode(BaseNode):
output = self.nodelist.render(context)
trace_msg("RENDR", "PROVIDE", self.trace_id, self.node_id, msg="...Done!")
return output
def resolve_kwargs(self, context: Context) -> Tuple[str, Dict[str, Optional[str]]]:
args, kwargs = self.params.resolve(context)
name = kwargs.pop(PROVIDE_NAME_KWARG, None)
if not name:
raise RuntimeError("Provide tag kwarg 'name' is missing")
return (name, kwargs)

View file

@ -13,7 +13,6 @@ from typing import (
Optional,
Protocol,
Set,
Tuple,
TypeVar,
Union,
cast,
@ -33,9 +32,7 @@ from django_components.context import (
_ROOT_CTX_CONTEXT_KEY,
)
from django_components.node import BaseNode
from django_components.util.logger import trace_msg
from django_components.util.misc import get_last_index, is_identifier
from django_components.util.template_tag import TagParams
if TYPE_CHECKING:
from django_components.component_registry import ComponentRegistry
@ -150,34 +147,132 @@ class ComponentSlotContext:
class SlotNode(BaseNode):
"""Node corresponding to `{% slot %}`"""
"""
Slot tag marks a place inside a component where content can be inserted
from outside.
def __init__(
self,
nodelist: NodeList,
params: TagParams,
trace_id: str,
node_id: Optional[str] = None,
is_required: bool = False,
is_default: bool = False,
):
super().__init__(nodelist=nodelist, params=params, node_id=node_id)
[Learn more](../../concepts/fundamentals/slots) about using slots.
self.is_required = is_required
self.is_default = is_default
self.trace_id = trace_id
This is similar to slots as seen in
[Web components](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/slot),
[Vue](https://vuejs.org/guide/components/slots.html)
or [React's `children`](https://react.dev/learn/passing-props-to-a-component#passing-jsx-as-children).
@property
def active_flags(self) -> List[str]:
flags = []
if self.is_required:
flags.append("required")
if self.is_default:
flags.append("default")
return flags
**Args:**
def __repr__(self) -> str:
return f"<Slot Node: {self.node_id}. Contents: {repr(self.nodelist)}. Options: {self.active_flags}>"
- `name` (str, required): Registered name of the component to render
- `default`: Optional flag. If there is a default slot, you can pass the component slot content
without using the [`{% fill %}`](#fill) tag. See
[Default slot](../../concepts/fundamentals/slots#default-slot)
- `required`: Optional flag. Will raise an error if a slot is required but not given.
- `**kwargs`: Any extra kwargs will be passed as the slot data.
**Example:**
```python
@register("child")
class Child(Component):
template = \"\"\"
<div>
{% slot "content" default %}
This is shown if not overriden!
{% endslot %}
</div>
<aside>
{% slot "sidebar" required / %}
</aside>
\"\"\"
```
```python
@register("parent")
class Parent(Component):
template = \"\"\"
<div>
{% component "child" %}
{% fill "content" %}
🗞📰
{% endfill %}
{% fill "sidebar" %}
🍷🧉🍾
{% endfill %}
{% endcomponent %}
</div>
\"\"\"
```
### Passing data to slots
Any extra kwargs will be considered as slot data, and will be accessible in the [`{% fill %}`](#fill)
tag via fill's `data` kwarg:
```python
@register("child")
class Child(Component):
template = \"\"\"
<div>
{# Passing data to the slot #}
{% slot "content" user=user %}
This is shown if not overriden!
{% endslot %}
</div>
\"\"\"
```
```python
@register("parent")
class Parent(Component):
template = \"\"\"
{# Parent can access the slot data #}
{% component "child" %}
{% fill "content" data="data" %}
<div class="wrapper-class">
{{ data.user }}
</div>
{% endfill %}
{% endcomponent %}
\"\"\"
```
### Accessing default slot content
The content between the `{% slot %}..{% endslot %}` tags is the default content that
will be rendered if no fill is given for the slot.
This default content can then be accessed from within the [`{% fill %}`](#fill) tag using
the fill's `default` kwarg.
This is useful if you need to wrap / prepend / append the original slot's content.
```python
@register("child")
class Child(Component):
template = \"\"\"
<div>
{% slot "content" %}
This is default content!
{% endslot %}
</div>
\"\"\"
```
```python
@register("parent")
class Parent(Component):
template = \"\"\"
{# Parent can access the slot's default content #}
{% component "child" %}
{% fill "content" default="default" %}
{{ default }}
{% endfill %}
{% endcomponent %}
\"\"\"
```
"""
tag = "slot"
end_tag = "endslot"
allowed_flags = [SLOT_DEFAULT_KEYWORD, SLOT_REQUIRED_KEYWORD]
# NOTE:
# In the current implementation, the slots are resolved only at the render time.
@ -200,9 +295,7 @@ class SlotNode(BaseNode):
# for unfilled slots (rendered slots WILL raise an error if the fill is missing).
# 2. User may provide extra fills, but these may belong to slots we haven't
# encountered in this render run. So we CANNOT say which ones are extra.
def render(self, context: Context) -> SafeString:
trace_msg("RENDR", "SLOT", self.trace_id, self.node_id)
def render(self, context: Context, name: str, **kwargs: Any) -> SafeString:
# Do not render `{% slot %}` tags within the `{% component %} .. {% endcomponent %}` tags
# at the fill discovery stage (when we render the component's body to determine if the body
# is a default slot, or contains named slots).
@ -217,10 +310,12 @@ class SlotNode(BaseNode):
)
component_ctx: ComponentSlotContext = context[_COMPONENT_SLOT_CTX_CONTEXT_KEY]
slot_name, kwargs = self.resolve_kwargs(context, component_ctx.component_name)
slot_name = name
is_default = self.flags[SLOT_DEFAULT_KEYWORD]
is_required = self.flags[SLOT_REQUIRED_KEYWORD]
# Check for errors
if self.is_default and not component_ctx.is_dynamic_component:
if is_default and not component_ctx.is_dynamic_component:
# Allow one slot to be marked as 'default', or multiple slots but with
# the same name. If there is multiple 'default' slots with different names, raise.
default_slot_name = component_ctx.default_slot
@ -249,7 +344,7 @@ class SlotNode(BaseNode):
# If slot is marked as 'default', we use the name 'default' for the fill,
# IF SUCH FILL EXISTS. Otherwise, we use the slot's name.
if self.is_default and DEFAULT_SLOT_KEY in component_ctx.fills:
if is_default and DEFAULT_SLOT_KEY in component_ctx.fills:
fill_name = DEFAULT_SLOT_KEY
else:
fill_name = slot_name
@ -284,7 +379,7 @@ class SlotNode(BaseNode):
# Note: Finding a good `cutoff` value may require further trial-and-error.
# Higher values make matching stricter. This is probably preferable, as it
# reduces false positives.
if self.is_required and not slot_fill.is_filled and not component_ctx.is_dynamic_component:
if is_required and not slot_fill.is_filled and not component_ctx.is_dynamic_component:
msg = (
f"Slot '{slot_name}' is marked as 'required' (i.e. non-optional), "
f"yet no fill is provided. Check template.'"
@ -349,7 +444,6 @@ class SlotNode(BaseNode):
# the render function ALWAYS receives them.
output = slot_fill.slot(used_ctx, kwargs, slot_ref)
trace_msg("RENDR", "SLOT", self.trace_id, self.node_id, msg="...Done!")
return output
def _resolve_slot_context(self, context: Context, slot_fill: "SlotFill") -> Context:
@ -368,99 +462,150 @@ class SlotNode(BaseNode):
else:
raise ValueError(f"Unknown value for context_behavior: '{registry.settings.context_behavior}'")
def resolve_kwargs(
self,
context: Context,
component_name: Optional[str] = None,
) -> Tuple[str, Dict[str, Optional[str]]]:
_, kwargs = self.params.resolve(context)
name = kwargs.pop(SLOT_NAME_KWARG, None)
if not name:
raise RuntimeError(f"Slot tag kwarg 'name' is missing in component {component_name}")
return (name, kwargs)
class FillNode(BaseNode):
"""Node corresponding to `{% fill %}`"""
"""
Use this tag to insert content into component's slots.
def __init__(
self,
nodelist: NodeList,
params: TagParams,
trace_id: str,
node_id: Optional[str] = None,
):
super().__init__(nodelist=nodelist, params=params, node_id=node_id)
`{% fill %}` tag may be used only within a `{% component %}..{% endcomponent %}` block.
Runtime checks should prohibit other usages.
self.trace_id = trace_id
**Args:**
def render(self, context: Context) -> str:
if _is_extracting_fill(context):
self._extract_fill(context)
return ""
- `name` (str, required): Name of the slot to insert this content into. Use `"default"` for
the default slot.
- `default` (str, optional): This argument allows you to access the original content of the slot
under the specified variable name. See
[Accessing original content of slots](../../concepts/fundamentals/slots#accessing-original-content-of-slots)
- `data` (str, optional): This argument allows you to access the data passed to the slot
under the specified variable name. See [Scoped slots](../../concepts/fundamentals/slots#scoped-slots)
**Examples:**
Basic usage:
```django
{% component "my_table" %}
{% fill "pagination" %}
< 1 | 2 | 3 >
{% endfill %}
{% endcomponent %}
```
### Accessing slot's default content with the `default` kwarg
```django
{# my_table.html #}
<table>
...
{% slot "pagination" %}
< 1 | 2 | 3 >
{% endslot %}
</table>
```
```django
{% component "my_table" %}
{% fill "pagination" default="default_pag" %}
<div class="my-class">
{{ default_pag }}
</div>
{% endfill %}
{% endcomponent %}
```
### Accessing slot's data with the `data` kwarg
```django
{# my_table.html #}
<table>
...
{% slot "pagination" pages=pages %}
< 1 | 2 | 3 >
{% endslot %}
</table>
```
```django
{% component "my_table" %}
{% fill "pagination" data="slot_data" %}
{% for page in slot_data.pages %}
<a href="{{ page.link }}">
{{ page.index }}
</a>
{% endfor %}
{% endfill %}
{% endcomponent %}
```
### Accessing slot data and default content on the default slot
To access slot data and the default slot content on the default slot,
use `{% fill %}` with `name` set to `"default"`:
```django
{% component "button" %}
{% fill name="default" data="slot_data" default="default_slot" %}
You clicked me {{ slot_data.count }} times!
{{ default_slot }}
{% endfill %}
{% endcomponent %}
```
"""
tag = "fill"
end_tag = "endfill"
allowed_flags = []
def render(self, context: Context, name: str, *, data: Optional[str] = None, default: Optional[str] = None) -> str:
if not _is_extracting_fill(context):
raise TemplateSyntaxError(
"FillNode.render() (AKA {% fill ... %} block) cannot be rendered outside of a Component context. "
"Make sure that the {% fill %} tags are nested within {% component %} tags."
)
def __repr__(self) -> str:
return f"<{self.__class__.__name__} ID: {self.node_id}. Contents: {repr(self.nodelist)}.>"
def resolve_kwargs(self, context: Context) -> "FillWithData":
_, kwargs = self.params.resolve(context)
name = self._process_kwarg(kwargs, SLOT_NAME_KWARG, identifier=False)
default_var = self._process_kwarg(kwargs, SLOT_DEFAULT_KWARG)
data_var = self._process_kwarg(kwargs, SLOT_DATA_KWARG)
# Validate inputs
if not isinstance(name, str):
raise TemplateSyntaxError(f"Fill tag '{SLOT_NAME_KWARG}' kwarg must resolve to a string, got {name}")
if data_var is not None and not isinstance(data_var, str):
raise TemplateSyntaxError(f"Fill tag '{SLOT_DATA_KWARG}' kwarg must resolve to a string, got {data_var}")
if data is not None:
if not isinstance(data, str):
raise TemplateSyntaxError(f"Fill tag '{SLOT_DATA_KWARG}' kwarg must resolve to a string, got {data}")
if not is_identifier(data):
raise RuntimeError(
f"Fill tag kwarg '{SLOT_DATA_KWARG}' does not resolve to a valid Python identifier, got '{data}'"
)
if default_var is not None and not isinstance(default_var, str):
if default is not None:
if not isinstance(default, str):
raise TemplateSyntaxError(
f"Fill tag '{SLOT_DEFAULT_KWARG}' kwarg must resolve to a string, got {default_var}"
f"Fill tag '{SLOT_DEFAULT_KWARG}' kwarg must resolve to a string, got {default}"
)
if not is_identifier(default):
raise RuntimeError(
f"Fill tag kwarg '{SLOT_DEFAULT_KWARG}' does not resolve to a valid Python identifier,"
f" got '{default}'"
)
# data and default cannot be bound to the same variable
if data_var and default_var and data_var == default_var:
if data and default and data == default:
raise RuntimeError(
f"Fill '{name}' received the same string for slot default ({SLOT_DEFAULT_KWARG}=...)"
f" and slot data ({SLOT_DATA_KWARG}=...)"
)
return FillWithData(
fill_data = FillWithData(
fill=self,
name=name,
default_var=default_var,
data_var=data_var,
default_var=default,
data_var=data,
extra_context={},
)
def _process_kwarg(
self,
kwargs: Dict[str, Any],
key: str,
identifier: bool = True,
) -> Optional[Any]:
if key not in kwargs:
return None
self._extract_fill(context, fill_data)
value = kwargs[key]
if value is None:
return None
return ""
if identifier and not is_identifier(value):
raise RuntimeError(f"Fill tag kwarg '{key}' does not resolve to a valid Python identifier, got '{value}'")
return value
def _extract_fill(self, context: Context) -> None:
def _extract_fill(self, context: Context, data: "FillWithData") -> None:
# `FILL_GEN_CONTEXT_KEY` is only ever set when we are rendering content between the
# `{% component %}...{% endcomponent %}` tags. This is done in order to collect all fill tags.
# E.g.
@ -474,10 +619,6 @@ class FillNode(BaseNode):
if collected_fills is None:
return
# NOTE: It's important that we use the context given to the fill tag, so it accounts
# for any variables set via e.g. for-loops.
data = self.resolve_kwargs(context)
# To allow using variables which were defined within the template and to which
# the `{% fill %}` tag has access, we need to capture those variables too.
#

View file

@ -1,707 +1,43 @@
# Notes on documentation:
# - For intuitive use via Python imports, keep the tag names same as the function name.
# E.g. so if the tag name is `slot`, one can also do
# `from django_components.templatetags.component_tags import slot`
#
# - All tags are defined using `@register.tag`. Do NOT use `@register.simple_tag`.
# The reason for this is so that we use `TagSpec` and `parse_template_tag`. When generating
# documentation, we extract the `TagSpecs` to be able to describe each tag's function signature.
#
# - Use `with_tag_spec` for defining `TagSpecs`. This will make it available to the function
# as the last argument, and will also set the `TagSpec` instance to `fn._tag_spec`.
# During documentation generation, we access the `fn._tag_spec`.
import inspect
from typing import Any, Dict, Literal, Optional
import django.template
from django.template.base import Parser, TextNode, Token
from django.template.exceptions import TemplateSyntaxError
from django.utils.safestring import SafeString, mark_safe
from django_components.attributes import HtmlAttrsNode
from django_components.component import COMP_ONLY_FLAG, ComponentNode
from django_components.component_registry import ComponentRegistry
from django_components.dependencies import CSS_DEPENDENCY_PLACEHOLDER, JS_DEPENDENCY_PLACEHOLDER
from django_components.component import ComponentNode
from django_components.dependencies import ComponentCssDependenciesNode, ComponentJsDependenciesNode
from django_components.provide import ProvideNode
from django_components.slots import SLOT_DEFAULT_KEYWORD, SLOT_REQUIRED_KEYWORD, FillNode, SlotNode
from django_components.tag_formatter import get_tag_formatter
from django_components.util.logger import trace_msg
from django_components.util.misc import gen_id
from django_components.util.template_tag import TagSpec, parse_template_tag, with_tag_spec
from django_components.slots import FillNode, SlotNode
# 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()
def _component_dependencies(type: Literal["js", "css"]) -> SafeString:
"""Marks location where CSS link and JS script tags should be rendered."""
if type == "css":
placeholder = CSS_DEPENDENCY_PLACEHOLDER
elif type == "js":
placeholder = JS_DEPENDENCY_PLACEHOLDER
else:
raise TemplateSyntaxError(
f"Unknown dependency type in {{% component_dependencies %}}. Must be one of 'css' or 'js', got {type}"
)
return TextNode(mark_safe(placeholder))
def component_dependencies_signature() -> None: ... # noqa: E704
@register.tag("component_css_dependencies")
@with_tag_spec(
TagSpec(
tag="component_css_dependencies",
end_tag=None, # inline-only
signature=inspect.Signature.from_callable(component_dependencies_signature),
)
)
def component_css_dependencies(parser: Parser, token: Token, tag_spec: TagSpec) -> TextNode:
"""
Marks location where CSS link tags should be rendered after the whole HTML has been generated.
Generally, this should be inserted into the `<head>` tag of the HTML.
If the generated HTML does NOT contain any `{% component_css_dependencies %}` tags, CSS links
are by default inserted into the `<head>` tag of the HTML. (See
[JS and CSS output locations](../../concepts/advanced/rendering_js_css/#js-and-css-output-locations))
Note that there should be only one `{% component_css_dependencies %}` for the whole HTML document.
If you insert this tag multiple times, ALL CSS links will be duplicately inserted into ALL these places.
"""
# Parse to check that the syntax is valid
parse_template_tag(parser, token, tag_spec)
return _component_dependencies("css")
@register.tag("component_js_dependencies")
@with_tag_spec(
TagSpec(
tag="component_js_dependencies",
end_tag=None, # inline-only
signature=inspect.Signature.from_callable(component_dependencies_signature),
)
)
def component_js_dependencies(parser: Parser, token: Token, tag_spec: TagSpec) -> TextNode:
"""
Marks location where JS link tags should be rendered after the whole HTML has been generated.
Generally, this should be inserted at the end of the `<body>` tag of the HTML.
If the generated HTML does NOT contain any `{% component_js_dependencies %}` tags, JS scripts
are by default inserted at the end of the `<body>` tag of the HTML. (See
[JS and CSS output locations](../../concepts/advanced/rendering_js_css/#js-and-css-output-locations))
Note that there should be only one `{% component_js_dependencies %}` for the whole HTML document.
If you insert this tag multiple times, ALL JS scripts will be duplicately inserted into ALL these places.
"""
# Parse to check that the syntax is valid
parse_template_tag(parser, token, tag_spec)
return _component_dependencies("js")
def slot_signature(name: str, **kwargs: Any) -> None: ... # noqa: E704
@register.tag("slot")
@with_tag_spec(
TagSpec(
tag="slot",
end_tag="endslot",
signature=inspect.Signature.from_callable(slot_signature),
flags=[SLOT_DEFAULT_KEYWORD, SLOT_REQUIRED_KEYWORD],
)
)
def slot(parser: Parser, token: Token, tag_spec: TagSpec) -> SlotNode:
"""
Slot tag marks a place inside a component where content can be inserted
from outside.
[Learn more](../../concepts/fundamentals/slots) about using slots.
This is similar to slots as seen in
[Web components](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/slot),
[Vue](https://vuejs.org/guide/components/slots.html)
or [React's `children`](https://react.dev/learn/passing-props-to-a-component#passing-jsx-as-children).
**Args:**
- `name` (str, required): Registered name of the component to render
- `default`: Optional flag. If there is a default slot, you can pass the component slot content
without using the [`{% fill %}`](#fill) tag. See
[Default slot](../../concepts/fundamentals/slots#default-slot)
- `required`: Optional flag. Will raise an error if a slot is required but not given.
- `**kwargs`: Any extra kwargs will be passed as the slot data.
**Example:**
```python
@register("child")
class Child(Component):
template = \"\"\"
<div>
{% slot "content" default %}
This is shown if not overriden!
{% endslot %}
</div>
<aside>
{% slot "sidebar" required / %}
</aside>
\"\"\"
```
```python
@register("parent")
class Parent(Component):
template = \"\"\"
<div>
{% component "child" %}
{% fill "content" %}
🗞📰
{% endfill %}
{% fill "sidebar" %}
🍷🧉🍾
{% endfill %}
{% endcomponent %}
</div>
\"\"\"
```
### Passing data to slots
Any extra kwargs will be considered as slot data, and will be accessible in the [`{% fill %}`](#fill)
tag via fill's `data` kwarg:
```python
@register("child")
class Child(Component):
template = \"\"\"
<div>
{# Passing data to the slot #}
{% slot "content" user=user %}
This is shown if not overriden!
{% endslot %}
</div>
\"\"\"
```
```python
@register("parent")
class Parent(Component):
template = \"\"\"
{# Parent can access the slot data #}
{% component "child" %}
{% fill "content" data="data" %}
<div class="wrapper-class">
{{ data.user }}
</div>
{% endfill %}
{% endcomponent %}
\"\"\"
```
### Accessing default slot content
The content between the `{% slot %}..{% endslot %}` tags is the default content that
will be rendered if no fill is given for the slot.
This default content can then be accessed from within the [`{% fill %}`](#fill) tag using
the fill's `default` kwarg.
This is useful if you need to wrap / prepend / append the original slot's content.
```python
@register("child")
class Child(Component):
template = \"\"\"
<div>
{% slot "content" %}
This is default content!
{% endslot %}
</div>
\"\"\"
```
```python
@register("parent")
class Parent(Component):
template = \"\"\"
{# Parent can access the slot's default content #}
{% component "child" %}
{% fill "content" default="default" %}
{{ default }}
{% endfill %}
{% endcomponent %}
\"\"\"
```
"""
tag_id = gen_id()
tag = parse_template_tag(parser, token, tag_spec)
trace_id = f"slot-id-{tag_id}"
trace_msg("PARSE", "SLOT", trace_id, tag_id)
body = tag.parse_body()
slot_node = SlotNode(
nodelist=body,
node_id=tag_id,
params=tag.params,
is_required=tag.flags[SLOT_REQUIRED_KEYWORD],
is_default=tag.flags[SLOT_DEFAULT_KEYWORD],
trace_id=trace_id,
)
trace_msg("PARSE", "SLOT", trace_id, tag_id, "...Done!")
return slot_node
def fill_signature(name: str, *, data: Optional[str] = None, default: Optional[str] = None) -> None: ... # noqa: E704
@register.tag("fill")
@with_tag_spec(
TagSpec(
tag="fill",
end_tag="endfill",
signature=inspect.Signature.from_callable(fill_signature),
)
)
def fill(parser: Parser, token: Token, tag_spec: TagSpec) -> FillNode:
"""
Use this tag to insert content into component's slots.
`{% fill %}` tag may be used only within a `{% component %}..{% endcomponent %}` block.
Runtime checks should prohibit other usages.
**Args:**
- `name` (str, required): Name of the slot to insert this content into. Use `"default"` for
the default slot.
- `default` (str, optional): This argument allows you to access the original content of the slot
under the specified variable name. See
[Accessing original content of slots](../../concepts/fundamentals/slots#accessing-original-content-of-slots)
- `data` (str, optional): This argument allows you to access the data passed to the slot
under the specified variable name. See [Scoped slots](../../concepts/fundamentals/slots#scoped-slots)
**Examples:**
Basic usage:
```django
{% component "my_table" %}
{% fill "pagination" %}
< 1 | 2 | 3 >
{% endfill %}
{% endcomponent %}
```
### Accessing slot's default content with the `default` kwarg
```django
{# my_table.html #}
<table>
...
{% slot "pagination" %}
< 1 | 2 | 3 >
{% endslot %}
</table>
```
```django
{% component "my_table" %}
{% fill "pagination" default="default_pag" %}
<div class="my-class">
{{ default_pag }}
</div>
{% endfill %}
{% endcomponent %}
```
### Accessing slot's data with the `data` kwarg
```django
{# my_table.html #}
<table>
...
{% slot "pagination" pages=pages %}
< 1 | 2 | 3 >
{% endslot %}
</table>
```
```django
{% component "my_table" %}
{% fill "pagination" data="slot_data" %}
{% for page in slot_data.pages %}
<a href="{{ page.link }}">
{{ page.index }}
</a>
{% endfor %}
{% endfill %}
{% endcomponent %}
```
### Accessing slot data and default content on the default slot
To access slot data and the default slot content on the default slot,
use `{% fill %}` with `name` set to `"default"`:
```django
{% component "button" %}
{% fill name="default" data="slot_data" default="default_slot" %}
You clicked me {{ slot_data.count }} times!
{{ default_slot }}
{% endfill %}
{% endcomponent %}
```
"""
tag_id = gen_id()
tag = parse_template_tag(parser, token, tag_spec)
trace_id = f"fill-id-{tag_id}"
trace_msg("PARSE", "FILL", trace_id, tag_id)
body = tag.parse_body()
fill_node = FillNode(
nodelist=body,
node_id=tag_id,
params=tag.params,
trace_id=trace_id,
)
trace_msg("PARSE", "FILL", trace_id, tag_id, "...Done!")
return fill_node
def component_signature(*args: Any, **kwargs: Any) -> None: ... # noqa: E704
@with_tag_spec(
TagSpec(
tag="component",
end_tag="endcomponent",
signature=inspect.Signature.from_callable(component_signature),
flags=[COMP_ONLY_FLAG],
)
)
def component(
parser: Parser,
token: Token,
registry: ComponentRegistry,
tag_name: str,
tag_spec: TagSpec,
) -> ComponentNode:
"""
Renders one of the components that was previously registered with
[`@register()`](./api.md#django_components.register)
decorator.
**Args:**
- `name` (str, required): Registered name of the component to render
- All other args and kwargs are defined based on the component itself.
If you defined a component `"my_table"`
```python
from django_component import Component, register
@register("my_table")
class MyTable(Component):
template = \"\"\"
<table>
<thead>
{% for header in headers %}
<th>{{ header }}</th>
{% endfor %}
</thead>
<tbody>
{% for row in rows %}
<tr>
{% for cell in row %}
<td>{{ cell }}</td>
{% endfor %}
</tr>
{% endfor %}
<tbody>
</table>
\"\"\"
def get_context_data(self, rows: List, headers: List):
return {
"rows": rows,
"headers": headers,
}
```
Then you can render this component by referring to `MyTable` via its
registered name `"my_table"`:
```django
{% component "my_table" rows=rows headers=headers ... / %}
```
### Component input
Positional and keyword arguments can be literals or template variables.
The component name must be a single- or double-quotes string and must
be either:
- The first positional argument after `component`:
```django
{% component "my_table" rows=rows headers=headers ... / %}
```
- Passed as kwarg `name`:
```django
{% component rows=rows headers=headers name="my_table" ... / %}
```
### Inserting into slots
If the component defined any [slots](../concepts/fundamentals/slots.md), you can
pass in the content to be placed inside those slots by inserting [`{% fill %}`](#fill) tags,
directly within the `{% component %}` tag:
```django
{% component "my_table" rows=rows headers=headers ... / %}
{% fill "pagination" %}
< 1 | 2 | 3 >
{% endfill %}
{% endcomponent %}
```
### Isolating components
By default, components behave similarly to Django's
[`{% include %}`](https://docs.djangoproject.com/en/5.1/ref/templates/builtins/#include),
and the template inside the component has access to the variables defined in the outer template.
You can selectively isolate a component, using the `only` flag, so that the inner template
can access only the data that was explicitly passed to it:
```django
{% component "name" positional_arg keyword_arg=value ... only %}
```
"""
tag_id = gen_id()
bits = token.split_contents()
# Let the TagFormatter pre-process the tokens
formatter = get_tag_formatter(registry)
result = formatter.parse([*bits])
end_tag = formatter.end_tag(result.component_name)
# NOTE: The tokens returned from TagFormatter.parse do NOT include the tag itself,
# so we add it back in.
bits = [bits[0], *result.tokens]
token.contents = " ".join(bits)
# Set the component-specific start and end tags
component_tag_spec = tag_spec.copy()
component_tag_spec.tag = tag_name
component_tag_spec.end_tag = end_tag
tag = parse_template_tag(parser, token, component_tag_spec)
trace_msg("PARSE", "COMP", result.component_name, tag_id)
body = tag.parse_body()
component_node = ComponentNode(
name=result.component_name,
params=tag.params,
isolated_context=tag.flags[COMP_ONLY_FLAG],
nodelist=body,
node_id=tag_id,
registry=registry,
)
trace_msg("PARSE", "COMP", result.component_name, tag_id, "...Done!")
return component_node
def provide_signature(name: str, **kwargs: Any) -> None: ... # noqa: E704
@register.tag("provide")
@with_tag_spec(
TagSpec(
tag="provide",
end_tag="endprovide",
signature=inspect.Signature.from_callable(provide_signature),
flags=[],
)
)
def provide(parser: Parser, token: Token, tag_spec: TagSpec) -> ProvideNode:
"""
The "provider" part of the [provide / inject feature](../../concepts/advanced/provide_inject).
Pass kwargs to this tag to define the provider's data.
Any components defined within the `{% provide %}..{% endprovide %}` tags will be able to access this data
with [`Component.inject()`](../api#django_components.Component.inject).
This is similar to React's [`ContextProvider`](https://react.dev/learn/passing-data-deeply-with-context),
or Vue's [`provide()`](https://vuejs.org/guide/components/provide-inject).
**Args:**
- `name` (str, required): Provider name. This is the name you will then use in
[`Component.inject()`](../api#django_components.Component.inject).
- `**kwargs`: Any extra kwargs will be passed as the provided data.
**Example:**
Provide the "user_data" in parent component:
```python
@register("parent")
class Parent(Component):
template = \"\"\"
<div>
{% provide "user_data" user=user %}
{% component "child" / %}
{% endprovide %}
</div>
\"\"\"
def get_context_data(self, user: User):
return {
"user": user,
}
```
Since the "child" component is used within the `{% provide %} / {% endprovide %}` tags,
we can request the "user_data" using `Component.inject("user_data")`:
```python
@register("child")
class Child(Component):
template = \"\"\"
<div>
User is: {{ user }}
</div>
\"\"\"
def get_context_data(self):
user = self.inject("user_data").user
return {
"user": user,
}
```
Notice that the keys defined on the `{% provide %}` tag are then accessed as attributes
when accessing them with [`Component.inject()`](../api#django_components.Component.inject).
Do this
```python
user = self.inject("user_data").user
```
Don't do this
```python
user = self.inject("user_data")["user"]
```
"""
tag_id = gen_id()
# e.g. {% provide <name> key=val key2=val2 %}
tag = parse_template_tag(parser, token, tag_spec)
trace_id = f"fill-id-{tag_id}"
trace_msg("PARSE", "PROVIDE", trace_id, tag_id)
body = tag.parse_body()
provide_node = ProvideNode(
nodelist=body,
node_id=tag_id,
params=tag.params,
trace_id=trace_id,
)
trace_msg("PARSE", "PROVIDE", trace_id, tag_id, "...Done!")
return provide_node
def html_attrs_signature( # noqa: E704
attrs: Optional[Dict] = None, defaults: Optional[Dict] = None, **kwargs: Any
) -> None: ...
@register.tag("html_attrs")
@with_tag_spec(
TagSpec(
tag="html_attrs",
end_tag=None, # inline-only
signature=inspect.Signature.from_callable(html_attrs_signature),
flags=[],
)
)
def html_attrs(parser: Parser, token: Token, tag_spec: TagSpec) -> HtmlAttrsNode:
"""
Generate HTML attributes (`key="value"`), combining data from multiple sources,
whether its template variables or static text.
It is designed to easily merge HTML attributes passed from outside with the internal.
See how to in [Passing HTML attributes to components](../../guides/howto/passing_html_attrs/).
**Args:**
- `attrs` (dict, optional): Optional dictionary that holds HTML attributes. On conflict, overrides
values in the `default` dictionary.
- `default` (str, optional): Optional dictionary that holds HTML attributes. On conflict, is overriden
with values in the `attrs` dictionary.
- Any extra kwargs will be appended to the corresponding keys
The attributes in `attrs` and `defaults` are merged and resulting dict is rendered as HTML attributes
(`key="value"`).
Extra kwargs (`key=value`) are concatenated to existing keys. So if we have
```python
attrs = {"class": "my-class"}
```
Then
```django
{% html_attrs attrs class="extra-class" %}
```
will result in `class="my-class extra-class"`.
**Example:**
```django
<div {% html_attrs
attrs
defaults:class="default-class"
class="extra-class"
data-id="123"
%}>
```
renders
```html
<div class="my-class extra-class" data-id="123">
```
**See more usage examples in
[HTML attributes](../../concepts/fundamentals/html_attributes#examples-for-html_attrs).**
"""
tag_id = gen_id()
tag = parse_template_tag(parser, token, tag_spec)
return HtmlAttrsNode(
node_id=tag_id,
params=tag.params,
)
# All tags are defined with our BaseNode class. Reasons for that are:
# - This ensures they all have the same set of features, like supporting flags,
# or literal lists and dicts as parameters.
# - The individual Node classes double as a source of truth for the tag's documentation.
#
# NOTE: The documentation generation script in `docs/scripts/reference.py` actually
# searches this file for all `Node` classes and uses them to generate the documentation.
# The docstring on the Node classes is used as the tag's documentation.
ComponentNode.register(register)
ComponentCssDependenciesNode.register(register)
ComponentJsDependenciesNode.register(register)
FillNode.register(register)
HtmlAttrsNode.register(register)
ProvideNode.register(register)
SlotNode.register(register)
# For an intuitive use via Python imports, the tags are aliased to the function name.
# E.g. so if the tag name is `slot`, one can also do:
# `from django_components.templatetags.component_tags import slot`
component = ComponentNode.parse
component_css_dependencies = ComponentCssDependenciesNode.parse
component_js_dependencies = ComponentJsDependenciesNode.parse
fill = FillNode.parse
html_attrs = HtmlAttrsNode.parse
provide = ProvideNode.parse
slot = SlotNode.parse
__all__ = [

View file

@ -1,6 +1,6 @@
import logging
import sys
from typing import Any, Dict, Literal, Optional
from typing import Any, Dict, Literal
DEFAULT_TRACE_LEVEL_NUM = 5 # NOTE: MUST be lower than DEBUG which is 10
@ -62,27 +62,18 @@ def trace(logger: logging.Logger, message: str, *args: Any, **kwargs: Any) -> No
def trace_msg(
action: Literal["PARSE", "RENDR", "GET", "SET"],
node_type: Literal["COMP", "FILL", "SLOT", "PROVIDE", "N/A"],
node_name: str,
action: Literal["PARSE", "RENDR"],
node_type: str,
node_id: str,
msg: str = "",
component_id: Optional[str] = None,
) -> None:
"""
TRACE level logger with opinionated format for tracing interaction of components,
nodes, and slots. Formats messages like so:
`"ASSOC SLOT test_slot ID 0088 TO COMP 0087"`
`"PARSE slot ID 0088 ...Done!"`
"""
msg_prefix = ""
if action == "RENDR" and node_type == "FILL":
if not component_id:
raise ValueError("component_id must be set for the RENDER action")
msg_prefix = f"FOR COMP {component_id}"
msg_parts = [f"{action} {node_type} {node_name} ID {node_id}", *([msg_prefix] if msg_prefix else []), msg]
full_msg = " ".join(msg_parts)
full_msg = f"{action} {node_type} ID {node_id} {msg}"
# NOTE: When debugging tests during development, it may be easier to change
# this to `print()`

View file

@ -1,7 +1,12 @@
import functools
"""
This file is for logic that focuses on transforming the AST of template tags
(as parsed from tag_parser) into a form that can be used by the Nodes.
"""
import inspect
import sys
from dataclasses import dataclass
from typing import Any, Callable, Dict, Iterable, List, Mapping, NamedTuple, Optional, Set, Tuple
from typing import Any, Callable, Dict, Iterable, List, Mapping, NamedTuple, Optional, Set, Tuple, Union
from django.template import Context, NodeList
from django.template.base import Parser, Token
@ -11,152 +16,36 @@ from django_components.expression import process_aggregate_kwargs
from django_components.util.tag_parser import TagAttr, parse_tag
@dataclass
class TagSpec:
"""Definition of args, kwargs, flags, etc, for a template tag."""
signature: inspect.Signature
"""Input to the tag as a Python function signature."""
tag: str
"""Tag name. E.g. `"slot"` means the tag is written like so `{% slot ... %}`"""
end_tag: Optional[str] = None
# For details see https://github.com/EmilStenstrom/django-components/pull/902#discussion_r1913611633
# and following comments
def validate_params(
tag: str,
signature: inspect.Signature,
params: List["TagParam"],
extra_kwargs: Optional[Dict[str, Any]] = None,
) -> None:
"""
End tag.
E.g. `"endslot"` means anything between the start tag and `{% endslot %}`
is considered the slot's body.
"""
flags: Optional[List[str]] = None
"""
List of allowed flags.
Flags are like kwargs, but without the value part. E.g. in `{% mytag only required %}`:
- `only` and `required` are treated as `only=True` and `required=True` if present
- and treated as `only=False` and `required=False` if omitted
"""
def copy(self) -> "TagSpec":
sig_parameters_copy = [param.replace() for param in self.signature.parameters.values()]
signature = inspect.Signature(sig_parameters_copy)
flags = self.flags.copy() if self.flags else None
return self.__class__(
signature=signature,
tag=self.tag,
end_tag=self.end_tag,
flags=flags,
)
# For details see https://github.com/EmilStenstrom/django-components/pull/902
def validate_params(self, params: List["TagParam"]) -> Tuple[List[Any], Dict[str, Any]]:
"""
Validates a list of TagParam objects against this tag spec's function signature.
Validates a list of TagParam objects against this tag's function signature.
The validation preserves the order of parameters as they appeared in the template.
Args:
params: List of TagParam objects representing the parameters as they appeared
in the template tag.
Returns:
A tuple of (args, kwargs) containing the validated parameters.
Raises:
TypeError: If the parameters don't match the tag spec's rules.
Raises `TypeError` if the parameters don't match the tag's signature.
"""
# Create a function with this signature that captures the input and sorts
# it into args and kwargs
def validator(*args: Any, **kwargs: Any) -> Tuple[List[Any], Dict[str, Any]]:
# Create a function that uses the given signature
def validator(*args: Any, **kwargs: Any) -> None:
# Let Python do the signature validation
bound = self.signature.bind(*args, **kwargs)
bound = signature.bind(*args, **kwargs)
bound.apply_defaults()
# Extract positional args
pos_args: List[Any] = []
for name, param in self.signature.parameters.items():
# Case: `name` (positional)
if param.kind == inspect.Parameter.POSITIONAL_ONLY:
pos_args.append(bound.arguments[name])
# Case: `*args`
elif param.kind == inspect.Parameter.VAR_POSITIONAL:
pos_args.extend(bound.arguments[name])
# Extract kwargs
kw_args: Dict[str, Any] = {}
for name, param in self.signature.parameters.items():
# Case: `name=...`
if param.kind in (inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY):
if name in bound.arguments:
kw_args[name] = bound.arguments[name]
# Case: `**kwargs`
elif param.kind == inspect.Parameter.VAR_KEYWORD:
kw_args.update(bound.arguments[name])
return pos_args, kw_args
# Set the signature on the function
validator.__signature__ = self.signature # type: ignore[attr-defined]
# Call the validator with our args and kwargs, in such a way to
# let the Python interpreter validate on repeated kwargs. E.g.
#
# ```
# args, kwargs = validator(
# *call_args,
# **call_kwargs[0],
# **call_kwargs[1],
# ...
# )
# ```
call_args = []
call_kwargs = []
for param in params:
if param.key is None:
call_args.append(param.value)
else:
call_kwargs.append({param.key: param.value})
# NOTE: Although we use `exec()` here, it's safe, because we control the input -
# we make dynamic only the list index.
#
# We MUST use the indices, because we can't trust neither the param keys nor values,
# so we MUST NOT reference them directly in the exec script, otherwise we'd be at risk
# of injection attack.
validator_call_script = "args, kwargs = validator(*call_args, "
for kw_index, _ in enumerate(call_kwargs):
validator_call_script += f"**call_kwargs[{kw_index}], "
validator_call_script += ")"
validator.__signature__ = signature # type: ignore[attr-defined]
# Call the validator with our args and kwargs in the same order as they appeared
# in the template, to let the Python interpreter validate on repeated kwargs.
try:
# Create function namespace
namespace: Dict[str, Any] = {"validator": validator, "call_args": call_args, "call_kwargs": call_kwargs}
exec(validator_call_script, namespace)
new_args, new_kwargs = namespace["args"], namespace["kwargs"]
return new_args, new_kwargs
apply_params_in_original_order(validator, params, extra_kwargs)
except TypeError as e:
# Enhance the error message
raise TypeError(f"Invalid parameters for tag '{self.tag}': {str(e)}") from None
def with_tag_spec(tag_spec: TagSpec) -> Callable:
"""
Decorator that binds a `tag_spec` to a template tag function,
there's a single source of truth for the tag spec, while also:
1. Making the tag spec available inside the tag function as `tag_spec`.
2. Making the tag spec accessible from outside as `_tag_spec` for documentation generation.
"""
def decorator(fn: Callable) -> Any:
fn._tag_spec = tag_spec # type: ignore[attr-defined]
@functools.wraps(fn)
def wrapper(*args: Any, **kwargs: Any) -> Any:
return fn(*args, **kwargs, tag_spec=tag_spec)
return wrapper
return decorator
raise TypeError(f"Invalid parameters for tag '{tag}': {str(e)}") from None
@dataclass
@ -180,25 +69,15 @@ class TagParam:
value: Any
class TagParams(NamedTuple):
"""
TagParams holds the parsed tag attributes and the tag spec, so that, at render time,
when we are able to resolve the tag inputs with the given Context, we are also able to validate
the inputs against the tag spec.
This is done so that the tag's public API (as defined in the tag spec) can be defined
next to the tag implementation. Otherwise the input validation would have to be defined by
the internal `Node` classes.
"""
params: List[TagAttr]
tag_spec: TagSpec
def resolve(self, context: Context) -> Tuple[List[Any], Dict[str, Any]]:
def resolve_params(
tag: str,
params: List[TagAttr],
context: Context,
) -> List[TagParam]:
# First, resolve any spread operators. Spreads can introduce both positional
# args (e.g. `*args`) and kwargs (e.g. `**kwargs`).
resolved_params: List[TagParam] = []
for param in self.params:
for param in params:
resolved = param.value.resolve(context)
if param.value.spread:
@ -218,26 +97,26 @@ class TagParams(NamedTuple):
else:
resolved_params.append(TagParam(key=param.key, value=resolved))
if self.tag_spec.tag == "html_attrs":
if tag == "html_attrs":
resolved_params = merge_repeated_kwargs(resolved_params)
resolved_params = process_aggregate_kwargs(resolved_params)
args, kwargs = self.tag_spec.validate_params(resolved_params)
return args, kwargs
return resolved_params
# Data obj to give meaning to the parsed tag fields
class ParsedTag(NamedTuple):
tag_name: str
flags: Dict[str, bool]
params: TagParams
params: List[TagAttr]
parse_body: Callable[[], NodeList]
def parse_template_tag(
tag: str,
end_tag: Optional[str],
allowed_flags: Optional[List[str]],
parser: Parser,
token: Token,
tag_spec: TagSpec,
) -> ParsedTag:
_, attrs = parse_tag(token.contents, parser)
@ -246,13 +125,14 @@ def parse_template_tag(
tag_name = tag_name_attr.serialize(omit_key=True)
# Sanity check
if tag_name != tag_spec.tag:
raise TemplateSyntaxError(f"Start tag parser received tag '{tag_name}', expected '{tag_spec.tag}'")
if tag_name != tag:
raise TemplateSyntaxError(f"Start tag parser received tag '{tag_name}', expected '{tag}'")
# There's 3 ways how we tell when a tag ends:
# 1. If the tag contains `/` at the end, it's a self-closing tag (like `<div />`),
# and it doesn't have an end tag. In this case we strip the trailing slash.
# Otherwise, depending on the tag spec, the tag may be:
#
# Otherwise, depending on the end_tag, the tag may be:
# 2. Block tag - With corresponding end tag, e.g. `{% endslot %}`
# 3. Inlined tag - Without the end tag.
last_token = attrs[-1].value if len(attrs) else None
@ -260,9 +140,9 @@ def parse_template_tag(
attrs.pop()
is_inline = True
else:
is_inline = not tag_spec.end_tag
is_inline = not end_tag
raw_params, flags = _extract_flags(tag_name, attrs, tag_spec.flags or [])
raw_params, flags = _extract_flags(tag_name, attrs, allowed_flags or [])
def _parse_tag_body(parser: Parser, end_tag: str, inline: bool) -> NodeList:
if inline:
@ -273,14 +153,13 @@ def parse_template_tag(
return body
return ParsedTag(
tag_name=tag_name,
params=TagParams(params=raw_params, tag_spec=tag_spec),
params=raw_params,
flags=flags,
# 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, tag_spec.end_tag, is_inline) if tag_spec.end_tag else NodeList(),
parse_body=lambda: _parse_tag_body(parser, end_tag, is_inline) if end_tag else NodeList(),
)
@ -305,7 +184,7 @@ def _extract_flags(
found_flags.add(value)
flags_dict: Dict[str, bool] = {
# Base state, as defined in the tag spec
# Base state - all flags False
**{flag: False for flag in (allowed_flags or [])},
# Flags found on the template tag
**{flag: True for flag in found_flags},
@ -348,3 +227,99 @@ def merge_repeated_kwargs(params: List[TagParam]) -> List[TagParam]:
params_by_key[param.key].value += " " + str(param.value)
return resolved_params
def apply_params_in_original_order(
fn: Callable[..., Any],
params: List[TagParam],
extra_kwargs: Optional[Dict[str, Any]] = None,
) -> Any:
"""
Apply a list of `TagParams` to another function, keeping the order of the params as they
appeared in the template.
If a template tag was called like this:
```django
{% component key1=value1 arg1 arg2 key2=value2 key3=value3 %}
```
Then `apply_params_in_original_order()` will call the `fn` like this:
```
component(
key1=call_params[0], # kwarg 1
call_params[1], # arg 1
call_params[2], # arg 2
key2=call_params[3], # kwarg 2
key3=call_params[4], # kwarg 3
...
**extra_kwargs,
)
```
This way, this will be effectively the same as:
```python
component(key1=value1, arg1, arg2, key2=value2, key3=value3, ..., **extra_kwargs)
```
The problem this works around is that, dynamically, args and kwargs in Python
can be passed only with `*args` and `**kwargs`. But in such case, we're already
grouping all args and kwargs, which may not represent the original order of the params
as they appeared in the template tag.
If you need to pass kwargs that are not valid Python identifiers, e.g. `data-id`, `class`, `:href`,
you can pass them in via `extra_kwargs`. These kwargs will be exempt from the validation, and will be
passed to the function as a dictionary spread.
"""
# Generate a script like so:
# ```py
# component(
# key1=call_params[0],
# call_params[1],
# call_params[2],
# key2=call_params[3],
# key3=call_params[4],
# ...
# **extra_kwargs,
# )
# ```
#
# NOTE: Instead of grouping params into args and kwargs, we preserve the original order
# of the params as they appeared in the template.
#
# NOTE: Because we use `eval()` here, we can't trust neither the param keys nor values.
# So we MUST NOT reference them directly in the exec script, otherwise we'd be at risk
# of injection attack.
#
# Currently, the use of `eval()` is safe, because we control the input:
# - List with indices is used so that we don't have to reference directly or try to print the values.
# and instead refer to them as `call_params[0]`, `call_params[1]`, etc.
# - List indices are safe, because we generate them.
# - Kwarg names come from the user. But Python expects the kwargs to be valid identifiers.
# So if a key is not a valid identifier, we'll raise an error. Before passing it to `eval()`
validator_call_script = "fn("
call_params: List[Union[List, Dict]] = []
for index, param in enumerate(params):
call_params.append(param.value)
if param.key is None:
validator_call_script += f"call_params[{index}], "
else:
validator_call_script += f"{param.key}=call_params[{index}], "
validator_call_script += "**extra_kwargs, "
validator_call_script += ")"
def applier(fn: Callable[..., Any]) -> Any:
locals = {
"fn": fn,
"call_params": call_params,
"extra_kwargs": extra_kwargs or {},
}
# NOTE: `eval()` changed API in Python 3.13
if sys.version_info >= (3, 13):
return eval(validator_call_script, globals={}, locals=locals)
else:
return eval(validator_call_script, {}, locals)
return applier(fn)

View file

@ -22,6 +22,11 @@ def setup_test_config(
"tests/templates/",
"tests/components/", # Required for template relative imports in tests
],
"OPTIONS": {
"builtins": [
"django_components.templatetags.component_tags",
]
},
}
],
"COMPONENTS": {

View file

@ -108,7 +108,7 @@ class MainMediaTest(BaseTestCase):
self.assertInHTML(
"""
<form data-djc-id-a1bc3f method="post">
<form data-djc-id-a1bc41 method="post">
<input name="variable" type="text" value="test"/>
<input type="submit"/>
</form>
@ -184,7 +184,7 @@ class MainMediaTest(BaseTestCase):
rendered = render_dependencies(rendered_raw)
self.assertIn(
"Variable: <strong data-djc-id-a1bc3f>test</strong>",
"Variable: <strong data-djc-id-a1bc41>test</strong>",
rendered,
)
self.assertInHTML(
@ -915,7 +915,7 @@ class MediaRelativePathTests(BaseTestCase):
self.assertInHTML(
"""
<form data-djc-id-a1bc3f method="post">
<form data-djc-id-a1bc41 method="post">
<input type="text" name="variable" value="test">
<input type="submit">
</form>

View file

@ -265,7 +265,7 @@ class RenderDependenciesTests(BaseTestCase):
self.assertInHTML(
"""
<body>
Variable: <strong data-djc-id-a1bc3f>foo</strong>
Variable: <strong data-djc-id-a1bc41>foo</strong>
<style>.xyz { color: red; }</style>
<link href="style.css" media="all" rel="stylesheet">
@ -510,7 +510,7 @@ class MiddlewareTests(BaseTestCase):
assert_dependencies(rendered1)
self.assertEqual(
rendered1.count("Variable: <strong data-djc-id-a1bc3f data-djc-id-a1bc40>value</strong>"),
rendered1.count("Variable: <strong data-djc-id-a1bc41 data-djc-id-a1bc42>value</strong>"),
1,
)
@ -520,7 +520,7 @@ class MiddlewareTests(BaseTestCase):
)
assert_dependencies(rendered2)
self.assertEqual(
rendered2.count("Variable: <strong data-djc-id-a1bc41 data-djc-id-a1bc42>value</strong>"),
rendered2.count("Variable: <strong data-djc-id-a1bc43 data-djc-id-a1bc44>value</strong>"),
1,
)
@ -531,6 +531,6 @@ class MiddlewareTests(BaseTestCase):
assert_dependencies(rendered3)
self.assertEqual(
rendered3.count("Variable: <strong data-djc-id-a1bc43 data-djc-id-a1bc44>value</strong>"),
rendered3.count("Variable: <strong data-djc-id-a1bc45 data-djc-id-a1bc46>value</strong>"),
1,
)

View file

@ -745,10 +745,7 @@ class SpreadOperatorTests(BaseTestCase):
template1 = Template(template_str1)
with self.assertRaisesMessage(
TypeError,
"got multiple values for keyword argument 'x'",
):
with self.assertRaisesMessage(SyntaxError, "keyword argument repeated"):
template1.render(context)
# But, similarly to python, we can merge multiple **kwargs by instead

570
tests/test_node.py Normal file
View file

@ -0,0 +1,570 @@
from django.template import Context, Template
from django.template.exceptions import TemplateSyntaxError
from django_components import types
from django_components.node import BaseNode, template_tag
from django_components.templatetags import component_tags
from .django_test_setup import setup_test_config
from .testutils import BaseTestCase
setup_test_config({"autodiscover": False})
class NodeTests(BaseTestCase):
def test_node_class_requires_tag(self):
with self.assertRaises(ValueError):
class CaptureNode(BaseNode):
pass
# Test that the template tag can be used within the template under the registered tag
def test_node_class_tags(self):
class TestNode(BaseNode):
tag = "mytag"
end_tag = "endmytag"
def render(self, context: Context, name: str, **kwargs) -> str:
return f"Hello, {name}!"
TestNode.register(component_tags.register)
# Works with end tag and self-closing
template_str: types.django_html = """
{% load component_tags %}
{% mytag 'John' %}
{% endmytag %}
Shorthand: {% mytag 'Mary' / %}
"""
template = Template(template_str)
rendered = template.render(Context({}))
self.assertEqual(rendered.strip(), "Hello, John!\n Shorthand: Hello, Mary!")
# But raises if missing end tag
template_str2: types.django_html = """
{% load component_tags %}
{% mytag 'John' %}
"""
with self.assertRaisesMessage(TemplateSyntaxError, "Unclosed tag on line 3: 'mytag'"):
Template(template_str2)
TestNode.unregister(component_tags.register)
def test_node_class_no_end_tag(self):
class TestNode(BaseNode):
tag = "mytag"
def render(self, context: Context, name: str, **kwargs) -> str:
return f"Hello, {name}!"
TestNode.register(component_tags.register)
# Raises with end tag or self-closing
template_str: types.django_html = """
{% load component_tags %}
{% mytag 'John' %}
{% endmytag %}
Shorthand: {% mytag 'Mary' / %}
"""
with self.assertRaisesMessage(TemplateSyntaxError, "Invalid block tag on line 4: 'endmytag'"):
Template(template_str)
# Works when missing end tag
template_str2: types.django_html = """
{% load component_tags %}
{% mytag 'John' %}
"""
template2 = Template(template_str2)
rendered2 = template2.render(Context({}))
self.assertEqual(rendered2.strip(), "Hello, John!")
TestNode.unregister(component_tags.register)
def test_node_class_flags(self):
captured = None
class TestNode(BaseNode):
tag = "mytag"
end_tag = "endmytag"
allowed_flags = ["required", "default"]
def render(self, context: Context, name: str, **kwargs) -> str:
nonlocal captured
captured = self.allowed_flags, self.flags, self.active_flags
return f"Hello, {name}!"
TestNode.register(component_tags.register)
template_str = """
{% load component_tags %}
{% mytag 'John' required / %}
"""
template = Template(template_str)
template.render(Context({}))
allowed_flags, flags, active_flags = captured # type: ignore
self.assertEqual(allowed_flags, ["required", "default"])
self.assertEqual(flags, {"required": True, "default": False})
self.assertEqual(active_flags, ["required"])
TestNode.unregister(component_tags.register)
def test_node_render(self):
# Check that the render function is called with the context
captured = None
class TestNode(BaseNode):
tag = "mytag"
def render(self, context: Context) -> str:
nonlocal captured
captured = context.flatten()
return f"Hello, {context['name']}!"
TestNode.register(component_tags.register)
template_str = """
{% load component_tags %}
{% mytag / %}
"""
template = Template(template_str)
rendered = template.render(Context({"name": "John"}))
self.assertEqual(captured, {"False": False, "None": None, "True": True, "name": "John"})
self.assertEqual(rendered.strip(), "Hello, John!")
TestNode.unregister(component_tags.register)
def test_node_render_raises_if_no_context_arg(self):
with self.assertRaisesMessage(TypeError, "`render()` method of TestNode must have at least two parameters"):
class TestNode(BaseNode):
tag = "mytag"
def render(self) -> str: # type: ignore
return ""
def test_node_render_accepted_params_set_by_render_signature(self):
captured = None
class TestNode1(BaseNode):
tag = "mytag"
allowed_flags = ["required", "default"]
def render(self, context: Context, name: str, count: int = 1, *, msg: str, mode: str = "default") -> str:
nonlocal captured
captured = name, count, msg, mode
return ""
TestNode1.register(component_tags.register)
# Set only required params
template1 = Template(
"""
{% load component_tags %}
{% mytag 'John' msg='Hello' required %}
"""
)
template1.render(Context({}))
self.assertEqual(captured, ("John", 1, "Hello", "default"))
# Set all params
template2 = Template(
"""
{% load component_tags %}
{% mytag 'John2' count=2 msg='Hello' mode='custom' required %}
"""
)
template2.render(Context({}))
self.assertEqual(captured, ("John2", 2, "Hello", "custom"))
# Set no params
template3 = Template(
"""
{% load component_tags %}
{% mytag %}
"""
)
with self.assertRaisesMessage(
TypeError, "Invalid parameters for tag 'mytag': missing a required argument: 'name'"
):
template3.render(Context({}))
# Omit required arg
template4 = Template(
"""
{% load component_tags %}
{% mytag msg='Hello' %}
"""
)
with self.assertRaisesMessage(
TypeError, "Invalid parameters for tag 'mytag': missing a required argument: 'name'"
):
template4.render(Context({}))
# Omit required kwarg
template5 = Template(
"""
{% load component_tags %}
{% mytag name='John' %}
"""
)
with self.assertRaisesMessage(
TypeError, "Invalid parameters for tag 'mytag': missing a required argument: 'msg'"
):
template5.render(Context({}))
# Extra args
template6 = Template(
"""
{% load component_tags %}
{% mytag 123 count=1 name='John' %}
"""
)
with self.assertRaisesMessage(
TypeError, "Invalid parameters for tag 'mytag': multiple values for argument 'name'"
):
template6.render(Context({}))
# Extra args after kwargs
template6 = Template(
"""
{% load component_tags %}
{% mytag count=1 name='John' 123 %}
"""
)
with self.assertRaisesMessage(SyntaxError, "positional argument follows keyword argument"):
template6.render(Context({}))
# Extra kwargs
template7 = Template(
"""
{% load component_tags %}
{% mytag 'John' msg='Hello' mode='custom' var=123 %}
"""
)
with self.assertRaisesMessage(
TypeError, "Invalid parameters for tag 'mytag': got an unexpected keyword argument 'var'"
):
template7.render(Context({}))
# Extra kwargs - non-identifier or kwargs
template8 = Template(
"""
{% load component_tags %}
{% mytag 'John' msg='Hello' mode='custom' data-id=123 class="pa-4" @click.once="myVar" %}
"""
)
with self.assertRaisesMessage(
TypeError, "Invalid parameters for tag 'mytag': got an unexpected keyword argument 'data-id'"
):
template8.render(Context({}))
# Extra arg after special kwargs
template9 = Template(
"""
{% load component_tags %}
{% mytag data-id=123 'John' msg='Hello' %}
"""
)
with self.assertRaisesMessage(SyntaxError, "positional argument follows keyword argument"):
template9.render(Context({}))
TestNode1.unregister(component_tags.register)
def test_node_render_extra_args_and_kwargs(self):
captured = None
class TestNode1(BaseNode):
tag = "mytag"
allowed_flags = ["required", "default"]
def render(self, context: Context, name: str, *args, msg: str, **kwargs) -> str:
nonlocal captured
captured = name, args, msg, kwargs
return ""
TestNode1.register(component_tags.register)
template1 = Template(
"""
{% load component_tags %}
{% mytag 'John'
123 456 789 msg='Hello' a=1 b=2 c=3 required
data-id=123 class="pa-4" @click.once="myVar"
%}
"""
)
template1.render(Context({}))
self.assertEqual(
captured,
(
"John",
(123, 456, 789),
"Hello",
{"a": 1, "b": 2, "c": 3, "data-id": 123, "class": "pa-4", "@click.once": "myVar"},
),
)
TestNode1.unregister(component_tags.register)
class DecoratorTests(BaseTestCase):
def test_decorator_requires_tag(self):
with self.assertRaisesMessage(TypeError, "template_tag() missing 1 required positional argument: 'tag'"):
@template_tag(component_tags.register) # type: ignore
def mytag(node: BaseNode, context: Context) -> str:
return ""
# Test that the template tag can be used within the template under the registered tag
def test_decorator_tags(self):
@template_tag(component_tags.register, tag="mytag", end_tag="endmytag")
def render(node: BaseNode, context: Context, name: str, **kwargs) -> str:
return f"Hello, {name}!"
# Works with end tag and self-closing
template_str: types.django_html = """
{% load component_tags %}
{% mytag 'John' %}
{% endmytag %}
Shorthand: {% mytag 'Mary' / %}
"""
template = Template(template_str)
rendered = template.render(Context({}))
self.assertEqual(rendered.strip(), "Hello, John!\n Shorthand: Hello, Mary!")
# But raises if missing end tag
template_str2: types.django_html = """
{% load component_tags %}
{% mytag 'John' %}
"""
with self.assertRaisesMessage(TemplateSyntaxError, "Unclosed tag on line 3: 'mytag'"):
Template(template_str2)
render._node.unregister(component_tags.register) # type: ignore[attr-defined]
def test_decorator_no_end_tag(self):
@template_tag(component_tags.register, tag="mytag") # type: ignore
def render(node: BaseNode, context: Context, name: str, **kwargs) -> str:
return f"Hello, {name}!"
# Raises with end tag or self-closing
template_str: types.django_html = """
{% load component_tags %}
{% mytag 'John' %}
{% endmytag %}
Shorthand: {% mytag 'Mary' / %}
"""
with self.assertRaisesMessage(TemplateSyntaxError, "Invalid block tag on line 4: 'endmytag'"):
Template(template_str)
# Works when missing end tag
template_str2: types.django_html = """
{% load component_tags %}
{% mytag 'John' %}
"""
template2 = Template(template_str2)
rendered2 = template2.render(Context({}))
self.assertEqual(rendered2.strip(), "Hello, John!")
render._node.unregister(component_tags.register) # type: ignore[attr-defined]
def test_decorator_flags(self):
@template_tag(component_tags.register, tag="mytag", end_tag="endmytag", allowed_flags=["required", "default"])
def render(node: BaseNode, context: Context, name: str, **kwargs) -> str:
return ""
render._node.unregister(component_tags.register) # type: ignore[attr-defined]
def test_decorator_render(self):
# Check that the render function is called with the context
captured = None
@template_tag(component_tags.register, tag="mytag") # type: ignore
def render(node: BaseNode, context: Context) -> str:
nonlocal captured
captured = context.flatten()
return f"Hello, {context['name']}!"
template_str = """
{% load component_tags %}
{% mytag / %}
"""
template = Template(template_str)
rendered = template.render(Context({"name": "John"}))
self.assertEqual(captured, {"False": False, "None": None, "True": True, "name": "John"})
self.assertEqual(rendered.strip(), "Hello, John!")
render._node.unregister(component_tags.register) # type: ignore[attr-defined]
def test_decorator_render_raises_if_no_context_arg(self):
with self.assertRaisesMessage(
TypeError,
"Failed to create node class in 'template_tag()' for 'render'",
):
@template_tag(component_tags.register, tag="mytag") # type: ignore
def render(node: BaseNode) -> str: # type: ignore
return ""
def test_decorator_render_accepted_params_set_by_render_signature(self):
captured = None
@template_tag(component_tags.register, tag="mytag", allowed_flags=["required", "default"]) # type: ignore
def render(
node: BaseNode, context: Context, name: str, count: int = 1, *, msg: str, mode: str = "default"
) -> str:
nonlocal captured
captured = name, count, msg, mode
return ""
# Set only required params
template1 = Template(
"""
{% load component_tags %}
{% mytag 'John' msg='Hello' required %}
"""
)
template1.render(Context({}))
self.assertEqual(captured, ("John", 1, "Hello", "default"))
# Set all params
template2 = Template(
"""
{% load component_tags %}
{% mytag 'John2' count=2 msg='Hello' mode='custom' required %}
"""
)
template2.render(Context({}))
self.assertEqual(captured, ("John2", 2, "Hello", "custom"))
# Set no params
template3 = Template(
"""
{% load component_tags %}
{% mytag %}
"""
)
with self.assertRaisesMessage(
TypeError, "Invalid parameters for tag 'mytag': missing a required argument: 'name'"
):
template3.render(Context({}))
# Omit required arg
template4 = Template(
"""
{% load component_tags %}
{% mytag msg='Hello' %}
"""
)
with self.assertRaisesMessage(
TypeError, "Invalid parameters for tag 'mytag': missing a required argument: 'name'"
):
template4.render(Context({}))
# Omit required kwarg
template5 = Template(
"""
{% load component_tags %}
{% mytag name='John' %}
"""
)
with self.assertRaisesMessage(
TypeError, "Invalid parameters for tag 'mytag': missing a required argument: 'msg'"
):
template5.render(Context({}))
# Extra args
template6 = Template(
"""
{% load component_tags %}
{% mytag 123 count=1 name='John' %}
"""
)
with self.assertRaisesMessage(
TypeError, "Invalid parameters for tag 'mytag': multiple values for argument 'name'"
):
template6.render(Context({}))
# Extra args after kwargs
template6 = Template(
"""
{% load component_tags %}
{% mytag count=1 name='John' 123 %}
"""
)
with self.assertRaisesMessage(SyntaxError, "positional argument follows keyword argument"):
template6.render(Context({}))
# Extra kwargs
template7 = Template(
"""
{% load component_tags %}
{% mytag 'John' msg='Hello' mode='custom' var=123 %}
"""
)
with self.assertRaisesMessage(
TypeError, "Invalid parameters for tag 'mytag': got an unexpected keyword argument 'var'"
):
template7.render(Context({}))
# Extra kwargs - non-identifier or kwargs
template8 = Template(
"""
{% load component_tags %}
{% mytag 'John' msg='Hello' mode='custom' data-id=123 class="pa-4" @click.once="myVar" %}
"""
)
with self.assertRaisesMessage(
TypeError, "Invalid parameters for tag 'mytag': got an unexpected keyword argument 'data-id'"
):
template8.render(Context({}))
# Extra arg after special kwargs
template9 = Template(
"""
{% load component_tags %}
{% mytag data-id=123 'John' msg='Hello' %}
"""
)
with self.assertRaisesMessage(SyntaxError, "positional argument follows keyword argument"):
template9.render(Context({}))
render._node.unregister(component_tags.register) # type: ignore[attr-defined]
def test_decorator_render_extra_args_and_kwargs(self):
captured = None
@template_tag(component_tags.register, tag="mytag", allowed_flags=["required", "default"]) # type: ignore
def render(node: BaseNode, context: Context, name: str, *args, msg: str, **kwargs) -> str:
nonlocal captured
captured = name, args, msg, kwargs
return ""
template1 = Template(
"""
{% load component_tags %}
{% mytag 'John'
123 456 789 msg='Hello' a=1 b=2 c=3 required
data-id=123 class="pa-4" @click.once="myVar"
%}
"""
)
template1.render(Context({}))
self.assertEqual(
captured,
(
"John",
(123, 456, 789),
"Hello",
{"a": 1, "b": 2, "c": 3, "data-id": 123, "class": "pa-4", "@click.once": "myVar"},
),
)
render._node.unregister(component_tags.register) # type: ignore[attr-defined]

View file

@ -381,7 +381,10 @@ class ProvideTemplateTagTest(BaseTestCase):
{% component "injectee" %}
{% endcomponent %}
"""
with self.assertRaisesMessage(RuntimeError, "Provide tag kwarg 'name' is missing"):
with self.assertRaisesMessage(
TemplateSyntaxError,
"Provide tag received an empty string. Key must be non-empty and a valid identifier",
):
Template(template_str).render(Context({}))
@parametrize_context_behavior(["django", "isolated"])