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

@ -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)
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."
)
**Examples:**
def __repr__(self) -> str:
return f"<{self.__class__.__name__} ID: {self.node_id}. Contents: {repr(self.nodelist)}.>"
Basic usage:
```django
{% component "my_table" %}
{% fill "pagination" %}
< 1 | 2 | 3 >
{% endfill %}
{% endcomponent %}
```
def resolve_kwargs(self, context: Context) -> "FillWithData":
_, kwargs = self.params.resolve(context)
### Accessing slot's default content with the `default` kwarg
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)
```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."
)
# 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):
raise TemplateSyntaxError(
f"Fill tag '{SLOT_DEFAULT_KWARG}' kwarg must resolve to a string, got {default_var}"
)
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}"
)
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.
Validates a list of TagParam objects against this tag's function signature.
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.
The validation preserves the order of parameters as they appeared in the template.
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
Raises `TypeError` if the parameters don't match the tag's signature.
"""
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,
)
# Create a function that uses the given signature
def validator(*args: Any, **kwargs: Any) -> None:
# Let Python do the signature validation
bound = signature.bind(*args, **kwargs)
bound.apply_defaults()
# 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.
validator.__signature__ = signature # type: ignore[attr-defined]
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.
"""
# 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]]:
# Let Python do the signature validation
bound = self.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 += ")"
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
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
# 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:
apply_params_in_original_order(validator, params, extra_kwargs)
except TypeError as e:
raise TypeError(f"Invalid parameters for tag '{tag}': {str(e)}") from None
@dataclass
@ -180,64 +69,54 @@ 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.
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 params:
resolved = param.value.resolve(context)
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.
"""
if param.value.spread:
if param.key:
raise ValueError(f"Cannot spread a value onto a key: {param.key}")
params: List[TagAttr]
tag_spec: TagSpec
def resolve(self, context: Context) -> Tuple[List[Any], Dict[str, Any]]:
# 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:
resolved = param.value.resolve(context)
if param.value.spread:
if param.key:
raise ValueError(f"Cannot spread a value onto a key: {param.key}")
if isinstance(resolved, Mapping):
for key, value in resolved.items():
resolved_params.append(TagParam(key=key, value=value))
elif isinstance(resolved, Iterable):
for value in resolved:
resolved_params.append(TagParam(key=None, value=value))
else:
raise ValueError(
f"Cannot spread non-iterable value: '{param.value.serialize()}' resolved to {resolved}"
)
if isinstance(resolved, Mapping):
for key, value in resolved.items():
resolved_params.append(TagParam(key=key, value=value))
elif isinstance(resolved, Iterable):
for value in resolved:
resolved_params.append(TagParam(key=None, value=value))
else:
resolved_params.append(TagParam(key=param.key, value=resolved))
raise ValueError(
f"Cannot spread non-iterable value: '{param.value.serialize()}' resolved to {resolved}"
)
else:
resolved_params.append(TagParam(key=param.key, value=resolved))
if self.tag_spec.tag == "html_attrs":
resolved_params = merge_repeated_kwargs(resolved_params)
resolved_params = process_aggregate_kwargs(resolved_params)
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)