Merge pull request #437 from JuroOravec/350-feat-slot-context-resolution

This commit is contained in:
Juro Oravec 2024-04-17 23:22:01 +02:00 committed by GitHub
commit c422f20ee4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 940 additions and 221 deletions

View file

@ -20,6 +20,8 @@ Read on to learn about the details!
## Release notes ## Release notes
**Version 0.67** CHANGED the default way how context variables are resolved in slots. See the [documentation](#isolate-components-slots) for more details.
🚨📢 **Version 0.5** CHANGES THE SYNTAX for components. `component_block` is now `component`, and `component` blocks need an ending `endcomponent` tag. The new `python manage.py upgradecomponent` command can be used to upgrade a directory (use --path argument to point to each dir) of components to the new syntax automatically. 🚨📢 **Version 0.5** CHANGES THE SYNTAX for components. `component_block` is now `component`, and `component` blocks need an ending `endcomponent` tag. The new `python manage.py upgradecomponent` command can be used to upgrade a directory (use --path argument to point to each dir) of components to the new syntax automatically.
This change is done to simplify the API in anticipation of a 1.0 release of django_components. After 1.0 we intend to be stricter with big changes like this in point releases. This change is done to simplify the API in anticipation of a 1.0 release of django_components. After 1.0 we intend to be stricter with big changes like this in point releases.
@ -704,6 +706,27 @@ COMPONENTS = {
} }
``` ```
### Isolate components' slots
What variables should be available from inside a component slot?
By default, variables inside component slots are preferentially taken from the root context.
This is similar to [how Vue renders slots](https://vuejs.org/guide/components/slots.html#render-scope),
except that, if variable is not found in the root, then the surrounding context is searched too.
You can change this with the `slot_contet_behavior` setting. Options are:
- `"prefer_root"` - Default - as described above
- `"isolated"` - Same behavior as Vue - variables are taken ONLY from the root context
- `"allow_override"` - slot context variables are taken from its surroundings (default before v0.67)
```python
COMPONENTS = {
"slot_context_behavior": "isolated",
}
```
For further details and examples, see [SlotContextBehavior](https://github.com/EmilStenstrom/django-components/blob/master/src/django_components/app_settings.py#L12).
## Logging and debugging ## Logging and debugging
Django components supports [logging with Django](https://docs.djangoproject.com/en/5.0/howto/logging/#logging-how-to). This can help with troubleshooting. Django components supports [logging with Django](https://docs.djangoproject.com/en/5.0/howto/logging/#logging-how-to). This can help with troubleshooting.

View file

@ -80,6 +80,14 @@ TEMPLATES = [
WSGI_APPLICATION = "sampleproject.wsgi.application" WSGI_APPLICATION = "sampleproject.wsgi.application"
# COMPONENTS = {
# "autodiscover": True,
# "libraries": [],
# "template_cache_size": 128,
# "context_behavior": "isolated", # "global" | "isolated"
# "slot_context_behavior": "prefer_root", # "allow_override" | "prefer_root" | "isolated"
# }
# Database # Database
# https://docs.djangoproject.com/en/4.0/ref/settings/#databases # https://docs.djangoproject.com/en/4.0/ref/settings/#databases

View file

@ -1,5 +1,5 @@
from enum import Enum from enum import Enum
from typing import List from typing import Dict, List
from django.conf import settings from django.conf import settings
@ -9,25 +9,118 @@ class ContextBehavior(str, Enum):
ISOLATED = "isolated" ISOLATED = "isolated"
class SlotContextBehavior(str, Enum):
ALLOW_OVERRIDE = "allow_override"
"""
Components CAN override the slot context variables passed from the outer scopes.
Contexts of deeper components take precedence over shallower ones.
Example:
Given this template
```txt
{% component 'my_comp' %}
{{ my_var }}
{% endcomponent %}
```
and this context passed to the render function (AKA root context)
```py
{ "my_var": 123 }
```
Then if component "my_comp" defines context
```py
{ "my_var": 456 }
```
Then since "my_comp" overrides the varialbe "my_var", so `{{ my_var }}` will equal `456`.
"""
PREFER_ROOT = "prefer_root"
"""
This is the same as "allow_override", except any variables defined in the root context
take precedence over anything else.
So if a variable is found in the root context, then root context is used.
Otherwise, the context of the component where the slot fill is located is used.
Example:
Given this template
```txt
{% component 'my_comp' %}
{{ my_var_one }}
{{ my_var_two }}
{% endcomponent %}
```
and this context passed to the render function (AKA root context)
```py
{ "my_var_one": 123 }
```
Then if component "my_comp" defines context
```py
{ "my_var": 456, "my_var_two": "abc" }
```
Then the rendered `{{ my_var_one }}` will equal to `123`, and `{{ my_var_two }}`
will equal to "abc".
"""
ISOLATED = "isolated"
"""
This setting makes the slots behave similar to Vue or React, where
the slot uses EXCLUSIVELY the root context, and nested components CANNOT
override context variables inside the slots.
Example:
Given this template
```txt
{% component 'my_comp' %}
{{ my_var }}
{% endcomponent %}
```
and this context passed to the render function (AKA root context)
```py
{ "my_var": 123 }
```
Then if component "my_comp" defines context
```py
{ "my_var": 456 }
```
Then the rendered `{{ my_var }}` will equal `123`.
"""
class AppSettings: class AppSettings:
def __init__(self) -> None: @property
self.settings = getattr(settings, "COMPONENTS", {}) def settings(self) -> Dict:
return getattr(settings, "COMPONENTS", {})
@property @property
def AUTODISCOVER(self) -> bool: def AUTODISCOVER(self) -> bool:
return self.settings.setdefault("autodiscover", True) return self.settings.get("autodiscover", True)
@property @property
def LIBRARIES(self) -> List: def LIBRARIES(self) -> List:
return self.settings.setdefault("libraries", []) return self.settings.get("libraries", [])
@property @property
def TEMPLATE_CACHE_SIZE(self) -> int: def TEMPLATE_CACHE_SIZE(self) -> int:
return self.settings.setdefault("template_cache_size", 128) return self.settings.get("template_cache_size", 128)
@property @property
def CONTEXT_BEHAVIOR(self) -> ContextBehavior: def CONTEXT_BEHAVIOR(self) -> ContextBehavior:
raw_value = self.settings.setdefault("context_behavior", ContextBehavior.GLOBAL.value) raw_value = self.settings.get("context_behavior", ContextBehavior.GLOBAL.value)
return self._validate_context_behavior(raw_value) return self._validate_context_behavior(raw_value)
def _validate_context_behavior(self, raw_value: ContextBehavior) -> ContextBehavior: def _validate_context_behavior(self, raw_value: ContextBehavior) -> ContextBehavior:
@ -37,5 +130,17 @@ class AppSettings:
valid_values = [behavior.value for behavior in ContextBehavior] valid_values = [behavior.value for behavior in ContextBehavior]
raise ValueError(f"Invalid context behavior: {raw_value}. Valid options are {valid_values}") raise ValueError(f"Invalid context behavior: {raw_value}. Valid options are {valid_values}")
@property
def SLOT_CONTEXT_BEHAVIOR(self) -> SlotContextBehavior:
raw_value = self.settings.get("slot_context_behavior", SlotContextBehavior.PREFER_ROOT.value)
return self._validate_slot_context_behavior(raw_value)
def _validate_slot_context_behavior(self, raw_value: SlotContextBehavior) -> SlotContextBehavior:
try:
return SlotContextBehavior(raw_value)
except ValueError:
valid_values = [behavior.value for behavior in SlotContextBehavior]
raise ValueError(f"Invalid slot context behavior: {raw_value}. Valid options are {valid_values}")
app_settings = AppSettings() app_settings = AppSettings()

View file

@ -2,7 +2,7 @@ import inspect
import os import os
import sys import sys
from pathlib import Path from pathlib import Path
from typing import Any, ClassVar, Dict, Iterable, List, Mapping, MutableMapping, Optional, Tuple, Type, Union from typing import Any, ClassVar, Dict, List, Mapping, MutableMapping, Optional, Tuple, Type, Union
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
from django.forms.widgets import Media, MediaDefiningClass from django.forms.widgets import Media, MediaDefiningClass
@ -20,17 +20,24 @@ from django.views import View
# way the two modules depend on one another. # way the two modules depend on one another.
from django_components.component_registry import registry # NOQA from django_components.component_registry import registry # NOQA
from django_components.component_registry import AlreadyRegistered, ComponentRegistry, NotRegistered, register # NOQA from django_components.component_registry import AlreadyRegistered, ComponentRegistry, NotRegistered, register # NOQA
from django_components.logger import logger from django_components.context import (
capture_root_context,
get_root_context,
set_root_context,
set_slot_component_association,
)
from django_components.logger import logger, trace_msg
from django_components.middleware import is_dependency_middleware_active from django_components.middleware import is_dependency_middleware_active
from django_components.node import walk_nodelist
from django_components.slots import ( from django_components.slots import (
DefaultFillContent, DEFAULT_SLOT_KEY,
ImplicitFillNode, FillContent,
NamedFillContent, FillNode,
NamedFillNode,
SlotName, SlotName,
SlotNode,
render_component_template_with_slots, render_component_template_with_slots,
) )
from django_components.utils import search from django_components.utils import gen_id, search
RENDERED_COMMENT_TEMPLATE = "<!-- _RENDERED {name} -->" RENDERED_COMMENT_TEMPLATE = "<!-- _RENDERED {name} -->"
@ -185,12 +192,14 @@ class Component(View, metaclass=SimplifiedInterfaceMediaDefiningClass):
def __init__( def __init__(
self, self,
registered_name: Optional[str] = None, registered_name: Optional[str] = None,
component_id: Optional[str] = None,
outer_context: Optional[Context] = None, outer_context: Optional[Context] = None,
fill_content: Union[DefaultFillContent, Iterable[NamedFillContent]] = (), # type: ignore fill_content: Dict[str, FillContent] = {},
): ):
self.registered_name: Optional[str] = registered_name self.registered_name: Optional[str] = registered_name
self.outer_context: Context = outer_context or Context() self.outer_context: Context = outer_context or Context()
self.fill_content = fill_content self.fill_content = fill_content
self.component_id = component_id or gen_id()
def __init_subclass__(cls, **kwargs: Any) -> None: def __init_subclass__(cls, **kwargs: Any) -> None:
cls.class_hash = hash(inspect.getfile(cls) + cls.__name__) cls.class_hash = hash(inspect.getfile(cls) + cls.__name__)
@ -230,6 +239,12 @@ class Component(View, metaclass=SimplifiedInterfaceMediaDefiningClass):
return mark_safe(f"<script>{self.js}</script>") return mark_safe(f"<script>{self.js}</script>")
return mark_safe("\n".join(self.media.render_js())) return mark_safe("\n".join(self.media.render_js()))
# NOTE: When the template is taken from a file (AKA
# specified via `template_name`), then we leverage
# Django's template caching. This means that the same
# instance of Template is reused. This is important to keep
# in mind, because the implication is that we should
# treat Templates AND their nodelists as IMMUTABLE.
def get_template(self, context: Mapping) -> Template: def get_template(self, context: Mapping) -> Template:
template_string = self.get_template_string(context) template_string = self.get_template_string(context)
if template_string is not None: if template_string is not None:
@ -246,7 +261,7 @@ class Component(View, metaclass=SimplifiedInterfaceMediaDefiningClass):
def render( def render(
self, self,
context_data: Dict[str, Any], context_data: Union[Dict[str, Any], Context],
slots_data: Optional[Dict[SlotName, str]] = None, slots_data: Optional[Dict[SlotName, str]] = None,
escape_slots_content: bool = True, escape_slots_content: bool = True,
) -> str: ) -> str:
@ -255,14 +270,25 @@ class Component(View, metaclass=SimplifiedInterfaceMediaDefiningClass):
context = context_data if isinstance(context_data, Context) else Context(context_data) context = context_data if isinstance(context_data, Context) else Context(context_data)
template = self.get_template(context) template = self.get_template(context)
# Associate the slots with this component for this context
# This allows us to look up component-specific slot fills.
def on_node(node: Node) -> None:
if isinstance(node, SlotNode):
trace_msg("ASSOC", "SLOT", node.name, node.node_id, component_id=self.component_id)
set_slot_component_association(context, node.node_id, self.component_id)
walk_nodelist(template.nodelist, on_node)
if slots_data: if slots_data:
self._fill_slots(slots_data, escape_slots_content) self._fill_slots(slots_data, escape_slots_content)
return render_component_template_with_slots(template, context, self.fill_content, self.registered_name) return render_component_template_with_slots(
self.component_id, template, context, self.fill_content, self.registered_name
)
def render_to_response( def render_to_response(
self, self,
context_data: Dict[str, Any], context_data: Union[Dict[str, Any], Context],
slots_data: Optional[Dict[SlotName, str]] = None, slots_data: Optional[Dict[SlotName, str]] = None,
escape_slots_content: bool = True, escape_slots_content: bool = True,
*args: Any, *args: Any,
@ -280,14 +306,13 @@ class Component(View, metaclass=SimplifiedInterfaceMediaDefiningClass):
escape_content: bool = True, escape_content: bool = True,
) -> None: ) -> None:
"""Fill component slots outside of template rendering.""" """Fill component slots outside of template rendering."""
self.fill_content = [ self.fill_content = {
( slot_name: FillContent(
slot_name,
TextNode(escape(content) if escape_content else content), TextNode(escape(content) if escape_content else content),
None, None,
) )
for (slot_name, content) in slots_data.items() for (slot_name, content) in slots_data.items()
] }
class ComponentNode(Node): class ComponentNode(Node):
@ -299,20 +324,16 @@ class ComponentNode(Node):
context_args: List[FilterExpression], context_args: List[FilterExpression],
context_kwargs: Mapping[str, FilterExpression], context_kwargs: Mapping[str, FilterExpression],
isolated_context: bool = False, isolated_context: bool = False,
fill_nodes: Union[ImplicitFillNode, Iterable[NamedFillNode]] = (), fill_nodes: Optional[List[FillNode]] = None,
component_id: Optional[str] = None,
) -> None: ) -> None:
self.component_id = component_id or gen_id()
self.name_fexp = name_fexp self.name_fexp = name_fexp
self.context_args = context_args or [] self.context_args = context_args or []
self.context_kwargs = context_kwargs or {} self.context_kwargs = context_kwargs or {}
self.isolated_context = isolated_context self.isolated_context = isolated_context
self.fill_nodes = fill_nodes self.fill_nodes = fill_nodes or []
self.nodelist = self._create_nodelist(fill_nodes) self.nodelist = NodeList(fill_nodes)
def _create_nodelist(self, fill_nodes: Union[Iterable[Node], ImplicitFillNode]) -> NodeList:
if isinstance(fill_nodes, ImplicitFillNode):
return NodeList([fill_nodes])
else:
return NodeList(fill_nodes)
def __repr__(self) -> str: def __repr__(self) -> str:
return "<ComponentNode: {}. Contents: {!r}>".format( return "<ComponentNode: {}. Contents: {!r}>".format(
@ -321,43 +342,61 @@ class ComponentNode(Node):
) )
def render(self, context: Context) -> str: def render(self, context: Context) -> str:
trace_msg("RENDR", "COMP", self.name_fexp, self.component_id)
resolved_component_name = self.name_fexp.resolve(context) resolved_component_name = self.name_fexp.resolve(context)
component_cls: Type[Component] = registry.get(resolved_component_name) component_cls: Type[Component] = registry.get(resolved_component_name)
# If this is the outer-/top-most component node, then save the outer context,
# so it can be used by nested Slots.
capture_root_context(context)
# Resolve FilterExpressions and Variables that were passed as args to the # Resolve FilterExpressions and Variables that were passed as args to the
# component, then call component's context method # component, then call component's context method
# to get values to insert into the context # to get values to insert into the context
resolved_context_args = [safe_resolve(arg, context) for arg in self.context_args] resolved_context_args = [safe_resolve(arg, context) for arg in self.context_args]
resolved_context_kwargs = {key: safe_resolve(kwarg, context) for key, kwarg in self.context_kwargs.items()} resolved_context_kwargs = {key: safe_resolve(kwarg, context) for key, kwarg in self.context_kwargs.items()}
if isinstance(self.fill_nodes, ImplicitFillNode): is_default_slot = len(self.fill_nodes) == 1 and self.fill_nodes[0].is_implicit
fill_content = self.fill_nodes.nodelist if is_default_slot:
fill_content: Dict[str, FillContent] = {DEFAULT_SLOT_KEY: FillContent(self.fill_nodes[0].nodelist, None)}
else: else:
fill_content = [] fill_content = {}
for fill_node in self.fill_nodes: for fill_node in self.fill_nodes:
# Note that outer component context is used to resolve variables in # Note that outer component context is used to resolve variables in
# fill tag. # fill tag.
resolved_name = fill_node.name_fexp.resolve(context) resolved_name = fill_node.name_fexp.resolve(context)
resolved_fill_alias = fill_node.resolve_alias(context, resolved_component_name) resolved_fill_alias = fill_node.resolve_alias(context, resolved_component_name)
fill_content.append((resolved_name, fill_node.nodelist, resolved_fill_alias)) fill_content[resolved_name] = FillContent(fill_node.nodelist, resolved_fill_alias)
component: Component = component_cls( component: Component = component_cls(
registered_name=resolved_component_name, registered_name=resolved_component_name,
outer_context=context, outer_context=context,
fill_content=fill_content, fill_content=fill_content,
component_id=self.component_id,
) )
component_context: dict = component.get_context_data(*resolved_context_args, **resolved_context_kwargs) component_context: dict = component.get_context_data(*resolved_context_args, **resolved_context_kwargs)
# Prevent outer context from leaking into the template of the component
if self.isolated_context: if self.isolated_context:
# Even if contexts are isolated, we still need to pass down the
# original context so variables in slots can be rendered using
# the original context.
root_ctx = get_root_context(context)
context = context.new() context = context.new()
set_root_context(context, root_ctx)
with context.update(component_context): with context.update(component_context):
rendered_component = component.render(context) rendered_component = component.render(context)
if is_dependency_middleware_active(): if is_dependency_middleware_active():
return RENDERED_COMMENT_TEMPLATE.format(name=resolved_component_name) + rendered_component output = RENDERED_COMMENT_TEMPLATE.format(name=resolved_component_name) + rendered_component
else: else:
return rendered_component output = rendered_component
trace_msg("RENDR", "COMP", self.name_fexp, self.component_id, "...Done!")
return output
def safe_resolve(context_item: FilterExpression, context: Context) -> Any: def safe_resolve(context_item: FilterExpression, context: Context) -> Any:

View file

@ -0,0 +1,122 @@
"""
This file centralizes various ways we use Django's Context class
pass data across components, nodes, slots, and contexts.
You can think of the Context as our storage system.
"""
from copy import copy
from typing import TYPE_CHECKING, Optional
from django.template import Context
from django_components.logger import trace_msg
if TYPE_CHECKING:
from django_components.slots import FillContent
_FILLED_SLOTS_CONTENT_CONTEXT_KEY = "_DJANGO_COMPONENTS_FILLED_SLOTS"
_OUTER_CONTEXT_CONTEXT_KEY = "_DJANGO_COMPONENTS_OUTER_CONTEXT"
_SLOT_COMPONENT_ASSOC_KEY = "_DJANGO_COMPONENTS_SLOT_COMP_ASSOC"
def get_slot_fill(context: Context, component_id: str, slot_name: str) -> Optional["FillContent"]:
"""
Use this function to obtain a slot fill from the current context.
See `set_slot_fill` for more details.
"""
trace_msg("GET", "FILL", slot_name, component_id)
slot_key = (_FILLED_SLOTS_CONTENT_CONTEXT_KEY, component_id, slot_name)
return context.get(slot_key, None)
def set_slot_fill(context: Context, component_id: str, slot_name: str, value: "FillContent") -> None:
"""
Use this function to set a slot fill for the current context.
Note that we make use of the fact that Django's Context is a stack - we can push and pop
extra contexts on top others.
For the slot fills to be pushed/popped wth stack layer, they need to have keys defined
directly on the Context object.
"""
trace_msg("SET", "FILL", slot_name, component_id)
slot_key = (_FILLED_SLOTS_CONTENT_CONTEXT_KEY, component_id, slot_name)
context[slot_key] = value
def get_root_context(context: Context) -> Optional[Context]:
"""
Use this function to get the root context.
Root context is the top-most context, AKA the context that was passed to
the initial `Template.render()`.
We pass through the root context to allow configure how slot fills should be rendered.
See the `SLOT_CONTEXT_BEHAVIOR` setting.
"""
return context.get(_OUTER_CONTEXT_CONTEXT_KEY)
def set_root_context(context: Context, root_ctx: Context) -> None:
"""
Use this function to set the root context.
Root context is the top-most context, AKA the context that was passed to
the initial `Template.render()`.
We pass through the root context to allow configure how slot fills should be rendered.
See the `SLOT_CONTEXT_BEHAVIOR` setting.
"""
context.push({_OUTER_CONTEXT_CONTEXT_KEY: root_ctx})
def capture_root_context(context: Context) -> None:
"""
Set the root context if it was not set before.
Root context is the top-most context, AKA the context that was passed to
the initial `Template.render()`.
We pass through the root context to allow configure how slot fills should be rendered.
See the `SLOT_CONTEXT_BEHAVIOR` setting.
"""
root_ctx_already_defined = _OUTER_CONTEXT_CONTEXT_KEY in context
if not root_ctx_already_defined:
set_root_context(context, copy(context))
def set_slot_component_association(context: Context, slot_id: str, component_id: str) -> None:
"""
Set association between a Slot and a Component in the current context.
We use SlotNodes to render slot fills. SlotNodes are created only at Template parse time.
However, when we are using components with slots in (another) template, we can render
the same component multiple time. So we can have multiple FillNodes intended to be used
with the same SlotNode.
So how do we tell the SlotNode which FillNode to render? We do so by tagging the ComponentNode
and FillNodes with a unique component_id, which ties them together. And then we tell SlotNode
which component_id to use to be able to find the correct Component/Fill.
We don't want to store this info on the Nodes themselves, as we need to treat them as
immutable due to caching of Templates by Django.
Hence, we use the Context to store the associations of SlotNode <-> Component for
the current context stack.
"""
key = (_SLOT_COMPONENT_ASSOC_KEY, slot_id)
context[key] = component_id
def get_slot_component_association(context: Context, slot_id: str) -> str:
"""
Given a slot ID, get the component ID that this slot is associated with
in this context.
See `set_slot_component_association` for more details.
"""
key = (_SLOT_COMPONENT_ASSOC_KEY, slot_id)
return context[key]

View file

@ -1,3 +1,93 @@
import logging import logging
import sys
from typing import Any, Dict, Literal, Optional
DEFAULT_TRACE_LEVEL_NUM = 5 # NOTE: MUST be lower than DEBUG which is 10
logger = logging.getLogger("django_components") logger = logging.getLogger("django_components")
actual_trace_level_num = -1
def setup_logging() -> None:
# Check if "TRACE" level was already defined. And if so, use its log level.
# See https://docs.python.org/3/howto/logging.html#custom-levels
global actual_trace_level_num
log_levels = _get_log_levels()
if "TRACE" in log_levels:
actual_trace_level_num = log_levels["TRACE"]
else:
actual_trace_level_num = DEFAULT_TRACE_LEVEL_NUM
logging.addLevelName(actual_trace_level_num, "TRACE")
def _get_log_levels() -> Dict[str, int]:
# Use official API if possible
if sys.version_info >= (3, 11):
return logging.getLevelNamesMapping()
else:
return logging._nameToLevel.copy()
def trace(logger: logging.Logger, message: str, *args: Any, **kwargs: Any) -> None:
"""
TRACE level logger.
To display TRACE logs, set the logging level to 5.
Example:
```py
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"handlers": {
"console": {
"class": "logging.StreamHandler",
"stream": sys.stdout,
},
},
"loggers": {
"django_components": {
"level": 5,
"handlers": ["console"],
},
},
}
```
"""
if actual_trace_level_num == -1:
setup_logging()
if logger.isEnabledFor(actual_trace_level_num):
logger.log(actual_trace_level_num, message, *args, **kwargs)
def trace_msg(
action: Literal["PARSE", "ASSOC", "RENDR", "GET", "SET"],
node_type: Literal["COMP", "FILL", "SLOT", "IFSB", "N/A"],
node_name: 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"`
"""
msg_prefix = ""
if action == "ASSOC":
if not component_id:
raise ValueError("component_id must be set for the ASSOC action")
msg_prefix = f"TO COMP {component_id}"
elif action == "RENDR" and node_type != "COMP":
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)
# NOTE: When debugging tests during development, it may be easier to change
# this to `print()`
trace(logger, full_msg)

View file

@ -0,0 +1,38 @@
from typing import Callable
from django.template.base import Node, NodeList, TextNode
from django.template.defaulttags import CommentNode
def nodelist_has_content(nodelist: NodeList) -> bool:
for node in nodelist:
if isinstance(node, TextNode) and node.s.isspace():
pass
elif isinstance(node, CommentNode):
pass
else:
return True
return False
def walk_nodelist(nodes: NodeList, callback: Callable[[Node], None]) -> None:
"""Recursively walk a NodeList, calling `callback` for each Node."""
node_queue = [*nodes]
while len(node_queue):
node: Node = node_queue.pop()
callback(node)
node_queue.extend(get_node_children(node))
def get_node_children(node: Node) -> NodeList:
"""
Get child Nodes from Node's nodelist atribute.
This function is taken from `get_nodes_by_type` method of `django.template.base.Node`.
"""
nodes = NodeList()
for attr in node.child_nodelists:
nodelist = getattr(node, attr, [])
if nodelist:
nodes.extend(nodelist)
return nodes

View file

@ -1,35 +1,33 @@
import difflib import difflib
import sys import json
from typing import Dict, Iterable, List, Optional, Set, Tuple, Type, Union from copy import copy
from typing import Dict, List, NamedTuple, Optional, Set, Type, Union
if sys.version_info[:2] < (3, 9):
from typing import ChainMap
else:
from collections import ChainMap
if sys.version_info[:2] < (3, 10):
from typing_extensions import TypeAlias
else:
from typing import TypeAlias
from django.template import Context, Template from django.template import Context, Template
from django.template.base import FilterExpression, Node, NodeList, TextNode from django.template.base import FilterExpression, Node, NodeList, Parser, TextNode
from django.template.defaulttags import CommentNode from django.template.defaulttags import CommentNode
from django.template.exceptions import TemplateSyntaxError from django.template.exceptions import TemplateSyntaxError
from django.utils.safestring import SafeString, mark_safe from django.utils.safestring import SafeString, mark_safe
FILLED_SLOTS_CONTENT_CONTEXT_KEY = "_DJANGO_COMPONENTS_FILLED_SLOTS" from django_components.app_settings import SlotContextBehavior, app_settings
from django_components.context import get_root_context, get_slot_component_association, get_slot_fill, set_slot_fill
from django_components.logger import trace_msg
from django_components.node import nodelist_has_content
from django_components.utils import gen_id
DEFAULT_SLOT_KEY = "_DJANGO_COMPONENTS_DEFAULT_SLOT"
# Type aliases # Type aliases
SlotName = str SlotName = str
AliasName = str AliasName = str
DefaultFillContent: TypeAlias = NodeList
NamedFillContent = Tuple[SlotName, NodeList, Optional[AliasName]]
FillContent = Tuple[NodeList, Optional[AliasName]] class FillContent(NamedTuple):
FilledSlotsContext = ChainMap[Tuple[SlotName, Template], FillContent] """Data passed from component to slot to render that slot"""
nodes: NodeList
alias: Optional[AliasName]
class UserSlotVar: class UserSlotVar:
@ -52,36 +50,46 @@ class UserSlotVar:
return mark_safe(self._slot.nodelist.render(self._context)) return mark_safe(self._slot.nodelist.render(self._context))
class TemplateAwareNodeMixin: class ComponentIdMixin:
_template: Template """
Mixin for classes use or pass through component ID.
We use component IDs to identify which slots should be
rendered with which fills for which components.
"""
_component_id: str
@property @property
def template(self) -> Template: def component_id(self) -> str:
try: try:
return self._template return self._component_id
except AttributeError: except AttributeError:
raise RuntimeError( raise RuntimeError(
f"Internal error: Instance of {type(self).__name__} was not " f"Internal error: Instance of {type(self).__name__} was not "
"linked to Template before use in render() context." "linked to Component before use in render() context. "
"Make sure that the 'component_id' field is set."
) )
@template.setter @component_id.setter
def template(self, value: Template) -> None: def component_id(self, value: Template) -> None:
self._template = value self._component_id = value
class SlotNode(Node, TemplateAwareNodeMixin): class SlotNode(Node):
def __init__( def __init__(
self, self,
name: str, name: str,
nodelist: NodeList, nodelist: NodeList,
is_required: bool = False, is_required: bool = False,
is_default: bool = False, is_default: bool = False,
node_id: Optional[str] = None,
): ):
self.name = name self.name = name
self.nodelist = nodelist self.nodelist = nodelist
self.is_required = is_required self.is_required = is_required
self.is_default = is_default self.is_default = is_default
self.node_id = node_id or gen_id()
@property @property
def active_flags(self) -> List[str]: def active_flags(self) -> List[str]:
@ -96,20 +104,21 @@ class SlotNode(Node, TemplateAwareNodeMixin):
return f"<Slot Node: {self.name}. Contents: {repr(self.nodelist)}. Options: {self.active_flags}>" return f"<Slot Node: {self.name}. Contents: {repr(self.nodelist)}. Options: {self.active_flags}>"
def render(self, context: Context) -> SafeString: def render(self, context: Context) -> SafeString:
try: component_id = get_slot_component_association(context, self.node_id)
filled_slots_map: FilledSlotsContext = context[FILLED_SLOTS_CONTENT_CONTEXT_KEY] trace_msg("RENDR", "SLOT", self.name, self.node_id, component_id=component_id)
except KeyError:
raise TemplateSyntaxError(f"Attempted to render SlotNode '{self.name}' outside a parent component.")
slot_fill_content = get_slot_fill(context, component_id, self.name)
extra_context = {} extra_context = {}
try:
slot_fill_content: FillContent = filled_slots_map[(self.name, self.template)] # Slot fill was NOT found. Will render the default fill
except KeyError: if slot_fill_content is None:
if self.is_required: if self.is_required:
raise TemplateSyntaxError( raise TemplateSyntaxError(
f"Slot '{self.name}' is marked as 'required' (i.e. non-optional), " f"yet no fill is provided. " f"Slot '{self.name}' is marked as 'required' (i.e. non-optional), " f"yet no fill is provided. "
) )
nodelist = self.nodelist nodelist = self.nodelist
# Slot fill WAS found
else: else:
nodelist, alias = slot_fill_content nodelist, alias = slot_fill_content
if alias: if alias:
@ -117,16 +126,54 @@ class SlotNode(Node, TemplateAwareNodeMixin):
raise TemplateSyntaxError() raise TemplateSyntaxError()
extra_context[alias] = UserSlotVar(self, context) extra_context[alias] = UserSlotVar(self, context)
with context.update(extra_context): used_ctx = self.resolve_slot_context(context)
return nodelist.render(context) with used_ctx.update(extra_context):
output = nodelist.render(used_ctx)
trace_msg("RENDR", "SLOT", self.name, self.node_id, component_id=component_id, msg="...Done!")
return output
def resolve_slot_context(self, context: Context) -> Context:
"""
Prepare the context used in a slot fill based on the settings.
See SlotContextBehavior for the description of each option.
"""
root_ctx = get_root_context(context) or Context()
if app_settings.SLOT_CONTEXT_BEHAVIOR == SlotContextBehavior.ALLOW_OVERRIDE:
return context
elif app_settings.SLOT_CONTEXT_BEHAVIOR == SlotContextBehavior.ISOLATED:
return root_ctx
elif app_settings.SLOT_CONTEXT_BEHAVIOR == SlotContextBehavior.PREFER_ROOT:
new_context: Context = copy(context)
new_context.update(root_ctx.flatten())
return new_context
else:
raise ValueError(f"Unknown value for SLOT_CONTEXT_BEHAVIOR: '{app_settings.SLOT_CONTEXT_BEHAVIOR}'")
class BaseFillNode(Node): class FillNode(Node, ComponentIdMixin):
def __init__(self, nodelist: NodeList): is_implicit: bool
self.nodelist: NodeList = nodelist """
Set when a `component` tag pair is passed template content that
excludes `fill` tags. Nodes of this type contribute their nodelists to slots marked
as 'default'.
"""
def __repr__(self) -> str: def __init__(
raise NotImplementedError self,
nodelist: NodeList,
name_fexp: FilterExpression,
alias_fexp: Optional[FilterExpression] = None,
is_implicit: bool = False,
node_id: Optional[str] = None,
):
self.node_id = node_id or gen_id()
self.nodelist = nodelist
self.name_fexp = name_fexp
self.alias_fexp = alias_fexp
self.is_implicit = is_implicit
def render(self, context: Context) -> str: def render(self, context: Context) -> str:
raise TemplateSyntaxError( raise TemplateSyntaxError(
@ -135,18 +182,6 @@ class BaseFillNode(Node):
"a {% component %} context." "a {% component %} context."
) )
class NamedFillNode(BaseFillNode):
def __init__(
self,
nodelist: NodeList,
name_fexp: FilterExpression,
alias_fexp: Optional[FilterExpression] = None,
):
super().__init__(nodelist)
self.name_fexp = name_fexp
self.alias_fexp = alias_fexp
def __repr__(self) -> str: def __repr__(self) -> str:
return f"<{type(self)} Name: {self.name_fexp}. Contents: {repr(self.nodelist)}.>" return f"<{type(self)} Name: {self.name_fexp}. Contents: {repr(self.nodelist)}.>"
@ -164,17 +199,6 @@ class NamedFillNode(BaseFillNode):
return resolved_alias return resolved_alias
class ImplicitFillNode(BaseFillNode):
"""
Instantiated when a `component` tag pair is passed template content that
excludes `fill` tags. Nodes of this type contribute their nodelists to slots marked
as 'default'.
"""
def __repr__(self) -> str:
return f"<{type(self)} Contents: {repr(self.nodelist)}.>"
class _IfSlotFilledBranchNode(Node): class _IfSlotFilledBranchNode(Node):
def __init__(self, nodelist: NodeList) -> None: def __init__(self, nodelist: NodeList) -> None:
self.nodelist = nodelist self.nodelist = nodelist
@ -186,26 +210,22 @@ class _IfSlotFilledBranchNode(Node):
raise NotImplementedError raise NotImplementedError
class IfSlotFilledConditionBranchNode(_IfSlotFilledBranchNode, TemplateAwareNodeMixin): class IfSlotFilledConditionBranchNode(_IfSlotFilledBranchNode, ComponentIdMixin):
def __init__( def __init__(
self, self,
slot_name: str, slot_name: str,
nodelist: NodeList, nodelist: NodeList,
is_positive: Union[bool, None] = True, is_positive: Union[bool, None] = True,
node_id: Optional[str] = None,
) -> None: ) -> None:
self.slot_name = slot_name self.slot_name = slot_name
self.is_positive: Optional[bool] = is_positive self.is_positive: Optional[bool] = is_positive
self.node_id = node_id or gen_id()
super().__init__(nodelist) super().__init__(nodelist)
def evaluate(self, context: Context) -> bool: def evaluate(self, context: Context) -> bool:
try: slot_fill = get_slot_fill(context, self.component_id, self.slot_name)
filled_slots: FilledSlotsContext = context[FILLED_SLOTS_CONTENT_CONTEXT_KEY] is_filled = slot_fill is not None
except KeyError:
raise TemplateSyntaxError(
f"Attempted to render {type(self).__name__} outside a Component rendering context."
)
slot_key = (self.slot_name, self.template)
is_filled = filled_slots.get(slot_key, None) is not None
# Make polarity switchable. # Make polarity switchable.
# i.e. if slot name is NOT filled and is_positive=False, # i.e. if slot name is NOT filled and is_positive=False,
# then False == False -> True # then False == False -> True
@ -244,7 +264,7 @@ class IfSlotFilledNode(Node):
def parse_slot_fill_nodes_from_component_nodelist( def parse_slot_fill_nodes_from_component_nodelist(
component_nodelist: NodeList, component_nodelist: NodeList,
ComponentNodeCls: Type[Node], ComponentNodeCls: Type[Node],
) -> Union[Iterable[NamedFillNode], ImplicitFillNode]: ) -> List[FillNode]:
""" """
Given a component body (`django.template.NodeList`), find all slot fills, Given a component body (`django.template.NodeList`), find all slot fills,
whether defined explicitly with `{% fill %}` or implicitly. whether defined explicitly with `{% fill %}` or implicitly.
@ -263,8 +283,8 @@ def parse_slot_fill_nodes_from_component_nodelist(
Then this function returns the nodes (`django.template.Node`) for `fill "first_fill"` Then this function returns the nodes (`django.template.Node`) for `fill "first_fill"`
and `fill "second_fill"`. and `fill "second_fill"`.
""" """
fill_nodes: Union[Iterable[NamedFillNode], ImplicitFillNode] = [] fill_nodes: List[FillNode] = []
if _block_has_content(component_nodelist): if nodelist_has_content(component_nodelist):
for parse_fn in ( for parse_fn in (
_try_parse_as_default_fill, _try_parse_as_default_fill,
_try_parse_as_named_fill_tag_set, _try_parse_as_named_fill_tag_set,
@ -286,11 +306,11 @@ def parse_slot_fill_nodes_from_component_nodelist(
def _try_parse_as_named_fill_tag_set( def _try_parse_as_named_fill_tag_set(
nodelist: NodeList, nodelist: NodeList,
ComponentNodeCls: Type[Node], ComponentNodeCls: Type[Node],
) -> Optional[Iterable[NamedFillNode]]: ) -> List[FillNode]:
result = [] result = []
seen_name_fexps: Set[FilterExpression] = set() seen_name_fexps: Set[FilterExpression] = set()
for node in nodelist: for node in nodelist:
if isinstance(node, NamedFillNode): if isinstance(node, FillNode):
if node.name_fexp in seen_name_fexps: if node.name_fexp in seen_name_fexps:
raise TemplateSyntaxError( raise TemplateSyntaxError(
f"Multiple fill tags cannot target the same slot name: " f"Multiple fill tags cannot target the same slot name: "
@ -303,19 +323,19 @@ def _try_parse_as_named_fill_tag_set(
elif isinstance(node, TextNode) and node.s.isspace(): elif isinstance(node, TextNode) and node.s.isspace():
pass pass
else: else:
return None return []
return result return result
def _try_parse_as_default_fill( def _try_parse_as_default_fill(
nodelist: NodeList, nodelist: NodeList,
ComponentNodeCls: Type[Node], ComponentNodeCls: Type[Node],
) -> Optional[ImplicitFillNode]: ) -> List[FillNode]:
nodes_stack: List[Node] = list(nodelist) nodes_stack: List[Node] = list(nodelist)
while nodes_stack: while nodes_stack:
node = nodes_stack.pop() node = nodes_stack.pop()
if isinstance(node, NamedFillNode): if isinstance(node, FillNode):
return None return []
elif isinstance(node, ComponentNodeCls): elif isinstance(node, ComponentNodeCls):
# Stop searching here, as fill tags are permitted inside component blocks # Stop searching here, as fill tags are permitted inside component blocks
# embedded within a default fill node. # embedded within a default fill node.
@ -323,56 +343,59 @@ def _try_parse_as_default_fill(
for nodelist_attr_name in node.child_nodelists: for nodelist_attr_name in node.child_nodelists:
nodes_stack.extend(getattr(node, nodelist_attr_name, [])) nodes_stack.extend(getattr(node, nodelist_attr_name, []))
else: else:
return ImplicitFillNode(nodelist=nodelist) return [
FillNode(
nodelist=nodelist,
def _block_has_content(nodelist: NodeList) -> bool: name_fexp=FilterExpression(json.dumps(DEFAULT_SLOT_KEY), Parser("")),
for node in nodelist: is_implicit=True,
if isinstance(node, TextNode) and node.s.isspace(): )
pass ]
elif isinstance(node, CommentNode):
pass
else:
return True
return False
def render_component_template_with_slots( def render_component_template_with_slots(
component_id: str,
template: Template, template: Template,
context: Context, context: Context,
fill_content: Union[DefaultFillContent, Iterable[NamedFillContent]], fill_content: Dict[str, FillContent],
registered_name: Optional[str], registered_name: Optional[str],
) -> str: ) -> str:
""" """
Given a template, context, and slot fills, this function first prepares This function first prepares the template to be able to render the fills
the template to be able to render the fills in the place of slots, and then in the place of slots, and then renders the template with given context.
renders the template with given context.
NOTE: The template is mutated in the process! NOTE: The nodes in the template are mutated in the process!
""" """
prev_filled_slots_context: Optional[FilledSlotsContext] = context.get(FILLED_SLOTS_CONTENT_CONTEXT_KEY) # ---- Prepare slot fills ----
updated_filled_slots_context = _prepare_component_template_filled_slot_context( slot_name2fill_content = _collect_slot_fills_from_component_template(template, fill_content, registered_name)
template,
fill_content, # Give slot nodes knowledge of their parent component.
prev_filled_slots_context, for node in template.nodelist.get_nodes_by_type(IfSlotFilledConditionBranchNode):
registered_name, if isinstance(node, IfSlotFilledConditionBranchNode):
) trace_msg("ASSOC", "IFSB", node.slot_name, node.node_id, component_id=component_id)
with context.update({FILLED_SLOTS_CONTENT_CONTEXT_KEY: updated_filled_slots_context}): node.component_id = component_id
with context.update({}):
for slot_name, content_data in slot_name2fill_content.items():
# Slots whose content is None (i.e. unfilled) are dropped.
if not content_data:
continue
set_slot_fill(context, component_id, slot_name, content_data)
# ---- Render ----
return template.render(context) return template.render(context)
def _prepare_component_template_filled_slot_context( def _collect_slot_fills_from_component_template(
template: Template, template: Template,
fill_content: Union[DefaultFillContent, Iterable[NamedFillContent]], fill_content: Dict[str, FillContent],
slots_context: Optional[FilledSlotsContext],
registered_name: Optional[str], registered_name: Optional[str],
) -> FilledSlotsContext: ) -> Dict[SlotName, Optional[FillContent]]:
if isinstance(fill_content, NodeList): if DEFAULT_SLOT_KEY in fill_content:
default_fill_content = (fill_content, None) named_fills_content = fill_content.copy()
named_fills_content = {} default_fill_content = named_fills_content.pop(DEFAULT_SLOT_KEY)
else: else:
named_fills_content = fill_content
default_fill_content = None default_fill_content = None
named_fills_content = {name: (nodelist, alias) for name, nodelist, alias in list(fill_content)}
# If value is `None`, then slot is unfilled. # If value is `None`, then slot is unfilled.
slot_name2fill_content: Dict[SlotName, Optional[FillContent]] = {} slot_name2fill_content: Dict[SlotName, Optional[FillContent]] = {}
@ -380,10 +403,11 @@ def _prepare_component_template_filled_slot_context(
required_slot_names: Set[str] = set() required_slot_names: Set[str] = set()
# Collect fills and check for errors # Collect fills and check for errors
for node in template.nodelist.get_nodes_by_type((SlotNode, IfSlotFilledConditionBranchNode)): # type: ignore for node in template.nodelist.get_nodes_by_type(SlotNode):
if isinstance(node, SlotNode): # Type check so the rest of the logic has type of `node` is inferred
# Give slot node knowledge of its parent template. if not isinstance(node, SlotNode):
node.template = template continue
slot_name = node.name slot_name = node.name
if slot_name in slot_name2fill_content: if slot_name in slot_name2fill_content:
raise TemplateSyntaxError( raise TemplateSyntaxError(
@ -392,9 +416,10 @@ def _prepare_component_template_filled_slot_context(
f"To fix, check template '{template.name}' " f"To fix, check template '{template.name}' "
f"of component '{registered_name}'." f"of component '{registered_name}'."
) )
content_data: Optional[FillContent] = None # `None` -> unfilled
if node.is_required: if node.is_required:
required_slot_names.add(node.name) required_slot_names.add(node.name)
content_data: Optional[FillContent] = None # `None` -> unfilled
if node.is_default: if node.is_default:
if default_slot_encountered: if default_slot_encountered:
raise TemplateSyntaxError( raise TemplateSyntaxError(
@ -404,13 +429,13 @@ def _prepare_component_template_filled_slot_context(
) )
content_data = default_fill_content content_data = default_fill_content
default_slot_encountered = True default_slot_encountered = True
# If default fill was not found, try to fill it with named slot
# Effectively, this allows to fill in default slot as named ones.
if not content_data: if not content_data:
content_data = named_fills_content.get(node.name) content_data = named_fills_content.get(node.name)
slot_name2fill_content[slot_name] = content_data slot_name2fill_content[slot_name] = content_data
elif isinstance(node, IfSlotFilledConditionBranchNode):
node.template = template
else:
raise RuntimeError(f"Node of {type(node).__name__} does not require linking.")
# Check: Only component templates that include a 'default' slot # Check: Only component templates that include a 'default' slot
# can be invoked with implicit filling. # can be invoked with implicit filling.
@ -424,6 +449,17 @@ def _prepare_component_template_filled_slot_context(
unfilled_slots: Set[str] = {k for k, v in slot_name2fill_content.items() if v is None} unfilled_slots: Set[str] = {k for k, v in slot_name2fill_content.items() if v is None}
unmatched_fills: Set[str] = named_fills_content.keys() - slot_name2fill_content.keys() unmatched_fills: Set[str] = named_fills_content.keys() - slot_name2fill_content.keys()
_report_slot_errors(unfilled_slots, unmatched_fills, registered_name, required_slot_names)
return slot_name2fill_content
def _report_slot_errors(
unfilled_slots: Set[str],
unmatched_fills: Set[str],
registered_name: Optional[str],
required_slot_names: Set[str],
) -> None:
# Check that 'required' slots are filled. # Check that 'required' slots are filled.
for slot_name in unfilled_slots: for slot_name in unfilled_slots:
if slot_name in required_slot_names: if slot_name in required_slot_names:
@ -454,14 +490,3 @@ def _prepare_component_template_filled_slot_context(
if fuzzy_slot_name_matches: if fuzzy_slot_name_matches:
msg += f"\nDid you mean '{fuzzy_slot_name_matches[0]}'?" msg += f"\nDid you mean '{fuzzy_slot_name_matches[0]}'?"
raise TemplateSyntaxError(msg) raise TemplateSyntaxError(msg)
# Return updated FILLED_SLOTS_CONTEXT map
filled_slots_map: Dict[Tuple[SlotName, Template], FillContent] = {
(slot_name, template): content_data
for slot_name, content_data in slot_name2fill_content.items()
if content_data # Slots whose content is None (i.e. unfilled) are dropped.
}
if slots_context is not None:
return slots_context.new_child(filled_slots_map)
else:
return ChainMap(filled_slots_map)

View file

@ -10,20 +10,22 @@ from django_components.app_settings import app_settings
from django_components.component import RENDERED_COMMENT_TEMPLATE, ComponentNode from django_components.component import RENDERED_COMMENT_TEMPLATE, ComponentNode
from django_components.component_registry import ComponentRegistry from django_components.component_registry import ComponentRegistry
from django_components.component_registry import registry as component_registry from django_components.component_registry import registry as component_registry
from django_components.logger import trace_msg
from django_components.middleware import ( from django_components.middleware import (
CSS_DEPENDENCY_PLACEHOLDER, CSS_DEPENDENCY_PLACEHOLDER,
JS_DEPENDENCY_PLACEHOLDER, JS_DEPENDENCY_PLACEHOLDER,
is_dependency_middleware_active, is_dependency_middleware_active,
) )
from django_components.slots import ( from django_components.slots import (
FillNode,
IfSlotFilledConditionBranchNode, IfSlotFilledConditionBranchNode,
IfSlotFilledElseBranchNode, IfSlotFilledElseBranchNode,
IfSlotFilledNode, IfSlotFilledNode,
NamedFillNode,
SlotNode, SlotNode,
_IfSlotFilledBranchNode, _IfSlotFilledBranchNode,
parse_slot_fill_nodes_from_component_nodelist, parse_slot_fill_nodes_from_component_nodelist,
) )
from django_components.utils import gen_id
if TYPE_CHECKING: if TYPE_CHECKING:
from django_components.component import Component from django_components.component import Component
@ -145,18 +147,27 @@ def do_slot(parser: Parser, token: Token) -> SlotNode:
"Order of options is free." "Order of options is free."
) )
# Use a unique ID to be able to tie the fill nodes with components and slots
# NOTE: MUST be called BEFORE `parser.parse()` to ensure predictable numbering
slot_id = gen_id()
trace_msg("PARSE", "SLOT", slot_name, slot_id)
nodelist = parser.parse(parse_until=["endslot"]) nodelist = parser.parse(parse_until=["endslot"])
parser.delete_first_token() parser.delete_first_token()
return SlotNode( slot_node = SlotNode(
slot_name, slot_name,
nodelist, nodelist,
is_required=is_required, is_required=is_required,
is_default=is_default, is_default=is_default,
node_id=slot_id,
) )
trace_msg("PARSE", "SLOT", slot_name, slot_id, "...Done!")
return slot_node
@register.tag("fill") @register.tag("fill")
def do_fill(parser: Parser, token: Token) -> NamedFillNode: def do_fill(parser: Parser, token: Token) -> FillNode:
"""Block tag whose contents 'fill' (are inserted into) an identically named """Block tag whose contents 'fill' (are inserted into) an identically named
'slot'-block in the component template referred to by a parent component. 'slot'-block in the component template referred to by a parent component.
It exists to make component nesting easier. It exists to make component nesting easier.
@ -179,15 +190,25 @@ def do_fill(parser: Parser, token: Token) -> NamedFillNode:
alias_fexp = FilterExpression(alias, parser) alias_fexp = FilterExpression(alias, parser)
else: else:
raise TemplateSyntaxError(f"'{tag}' tag takes either 1 or 3 arguments: Received {len(args)}.") raise TemplateSyntaxError(f"'{tag}' tag takes either 1 or 3 arguments: Received {len(args)}.")
# Use a unique ID to be able to tie the fill nodes with components and slots
# NOTE: MUST be called BEFORE `parser.parse()` to ensure predictable numbering
fill_id = gen_id()
trace_msg("PARSE", "FILL", tgt_slot_name, fill_id)
nodelist = parser.parse(parse_until=["endfill"]) nodelist = parser.parse(parse_until=["endfill"])
parser.delete_first_token() parser.delete_first_token()
return NamedFillNode( fill_node = FillNode(
nodelist, nodelist,
name_fexp=FilterExpression(tgt_slot_name, tag), name_fexp=FilterExpression(tgt_slot_name, tag),
alias_fexp=alias_fexp, alias_fexp=alias_fexp,
node_id=fill_id,
) )
trace_msg("PARSE", "FILL", tgt_slot_name, fill_id, "...Done!")
return fill_node
@register.tag(name="component") @register.tag(name="component")
def do_component(parser: Parser, token: Token) -> ComponentNode: def do_component(parser: Parser, token: Token) -> ComponentNode:
@ -207,17 +228,31 @@ def do_component(parser: Parser, token: Token) -> ComponentNode:
bits = token.split_contents() bits = token.split_contents()
bits, isolated_context = check_for_isolated_context_keyword(bits) bits, isolated_context = check_for_isolated_context_keyword(bits)
component_name, context_args, context_kwargs = parse_component_with_args(parser, bits, "component") component_name, context_args, context_kwargs = parse_component_with_args(parser, bits, "component")
# Use a unique ID to be able to tie the fill nodes with components and slots
# NOTE: MUST be called BEFORE `parser.parse()` to ensure predictable numbering
component_id = gen_id()
trace_msg("PARSE", "COMP", component_name, component_id)
body: NodeList = parser.parse(parse_until=["endcomponent"]) body: NodeList = parser.parse(parse_until=["endcomponent"])
parser.delete_first_token() parser.delete_first_token()
fill_nodes = parse_slot_fill_nodes_from_component_nodelist(body, ComponentNode) fill_nodes = parse_slot_fill_nodes_from_component_nodelist(body, ComponentNode)
# Tag all fill nodes as children of this particular component instance
for node in fill_nodes:
trace_msg("ASSOC", "FILL", node.name_fexp, node.node_id, component_id=component_id)
node.component_id = component_id
component_node = ComponentNode( component_node = ComponentNode(
FilterExpression(component_name, parser), FilterExpression(component_name, parser),
context_args, context_args,
context_kwargs, context_kwargs,
isolated_context=isolated_context, isolated_context=isolated_context,
fill_nodes=fill_nodes, fill_nodes=fill_nodes,
component_id=component_id,
) )
trace_msg("PARSE", "COMP", component_name, component_id, "...Done!")
return component_node return component_node

View file

@ -35,3 +35,17 @@ def search(search_glob: Optional[str] = None, engine: Optional[Engine] = None) -
component_filenames.append(Path(path)) component_filenames.append(Path(path))
return SearchResult(searched_dirs=dirs, matched_files=component_filenames) return SearchResult(searched_dirs=dirs, matched_files=component_filenames)
# Global counter to ensure that all IDs generated by `gen_id` WILL be unique
_id = 0
def gen_id(length: int = 5) -> str:
"""Generate a unique ID that can be associated with a Node"""
# Global counter to avoid conflicts
global _id
_id += 1
# Pad the ID with `0`s up to 4 digits, e.g. `0007`
return f"{_id:04}"

View file

@ -1,5 +1,6 @@
from pathlib import Path from pathlib import Path
from textwrap import dedent from textwrap import dedent
from typing import Any, Dict, Optional
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
from django.template import Context, Template from django.template import Context, Template
@ -204,6 +205,60 @@ class ComponentTest(SimpleTestCase):
self.assertIn('<input type="text" name="variable" value="hello">', rendered, rendered) self.assertIn('<input type="text" name="variable" value="hello">', rendered, rendered)
def test_component_inside_slot(self):
class SlottedComponent(component.Component):
template_name = "slotted_template.html"
def get_context_data(self, name: Optional[str] = None) -> Dict[str, Any]:
return {
"name": name,
}
component.registry.register("test", SlottedComponent)
self.template = Template(
"""
{% load component_tags %}
{% component "test" name='Igor' %}
{% fill "header" %}
Name: {{ name }}
{% endfill %}
{% fill "main" %}
Day: {{ day }}
{% endfill %}
{% fill "footer" %}
{% component "test" name='Joe2' %}
{% fill "header" %}
Name2: {{ name }}
{% endfill %}
{% fill "main" %}
Day2: {{ day }}
{% endfill %}
{% endcomponent %}
{% endfill %}
{% endcomponent %}
"""
)
# {{ name }} should be "Jannete" everywhere
rendered = self.template.render(Context({"day": "Monday", "name": "Jannete"}))
self.assertHTMLEqual(
rendered,
"""
<custom-template>
<header>Name: Jannete</header>
<main>Day: Monday</main>
<footer>
<custom-template>
<header>Name2: Jannete</header>
<main>Day2: Monday</main>
<footer>Default footer</footer>
</custom-template>
</footer>
</custom-template>
""",
)
class InlineComponentTest(SimpleTestCase): class InlineComponentTest(SimpleTestCase):
def test_inline_html_component(self): def test_inline_html_component(self):
@ -482,3 +537,153 @@ class ComponentIsolationTests(SimpleTestCase):
</custom-template> </custom-template>
""", """,
) )
class SlotBehaviorTests(SimpleTestCase):
def setUp(self):
class SlottedComponent(component.Component):
template_name = "slotted_template.html"
def get_context_data(self, name: Optional[str] = None) -> Dict[str, Any]:
return {
"name": name,
}
component.registry.register("test", SlottedComponent)
self.template = Template(
"""
{% load component_tags %}
{% component "test" name='Igor' %}
{% fill "header" %}
Name: {{ name }}
{% endfill %}
{% fill "main" %}
Day: {{ day }}
{% endfill %}
{% fill "footer" %}
{% component "test" name='Joe2' %}
{% fill "header" %}
Name2: {{ name }}
{% endfill %}
{% fill "main" %}
Day2: {{ day }}
{% endfill %}
{% endcomponent %}
{% endfill %}
{% endcomponent %}
"""
)
@override_settings(
COMPONENTS={"slot_context_behavior": "allow_override"},
)
def test_slot_context_allow_override(self):
# {{ name }} should be neither Jannete not empty, because overriden everywhere
rendered = self.template.render(Context({"day": "Monday", "name": "Jannete"}))
self.assertHTMLEqual(
rendered,
"""
<custom-template>
<header>Name: Igor</header>
<main>Day: Monday</main>
<footer>
<custom-template>
<header>Name2: Joe2</header>
<main>Day2: Monday</main>
<footer>Default footer</footer>
</custom-template>
</footer>
</custom-template>
""",
)
# {{ name }} should be effectively the same as before, because overriden everywhere
rendered2 = self.template.render(Context({"day": "Monday"}))
self.assertHTMLEqual(rendered2, rendered)
@override_settings(
COMPONENTS={"slot_context_behavior": "isolated"},
)
def test_slot_context_isolated(self):
# {{ name }} should be "Jannete" everywhere
rendered = self.template.render(Context({"day": "Monday", "name": "Jannete"}))
self.assertHTMLEqual(
rendered,
"""
<custom-template>
<header>Name: Jannete</header>
<main>Day: Monday</main>
<footer>
<custom-template>
<header>Name2: Jannete</header>
<main>Day2: Monday</main>
<footer>Default footer</footer>
</custom-template>
</footer>
</custom-template>
""",
)
# {{ name }} should be empty everywhere
rendered2 = self.template.render(Context({"day": "Monday"}))
self.assertHTMLEqual(
rendered2,
"""
<custom-template>
<header>Name: </header>
<main>Day: Monday</main>
<footer>
<custom-template>
<header>Name2: </header>
<main>Day2: Monday</main>
<footer>Default footer</footer>
</custom-template>
</footer>
</custom-template>
""",
)
@override_settings(
COMPONENTS={
"slot_context_behavior": "prefer_root",
},
)
def test_slot_context_prefer_root(self):
# {{ name }} should be "Jannete" everywhere
rendered = self.template.render(Context({"day": "Monday", "name": "Jannete"}))
self.assertHTMLEqual(
rendered,
"""
<custom-template>
<header>Name: Jannete</header>
<main>Day: Monday</main>
<footer>
<custom-template>
<header>Name2: Jannete</header>
<main>Day2: Monday</main>
<footer>Default footer</footer>
</custom-template>
</footer>
</custom-template>
""",
)
# {{ name }} should be neither "Jannete" nor empty anywhere
rendered = self.template.render(Context({"day": "Monday"}))
self.assertHTMLEqual(
rendered,
"""
<custom-template>
<header>Name: Igor</header>
<main>Day: Monday</main>
<footer>
<custom-template>
<header>Name2: Joe2</header>
<main>Day2: Monday</main>
<footer>Default footer</footer>
</custom-template>
</footer>
</custom-template>
""",
)

View file

@ -1,6 +1,7 @@
from unittest.mock import PropertyMock, patch from unittest.mock import PropertyMock, patch
from django.template import Context, Template from django.template import Context, Template
from django.test import override_settings
from django_components import component from django_components import component
@ -65,7 +66,7 @@ class OuterContextComponent(component.Component):
template_name = "simple_template.html" template_name = "simple_template.html"
def get_context_data(self): def get_context_data(self):
return self.outer_context return self.outer_context.flatten()
component.registry.register(name="parent_component", component=ParentComponent) component.registry.register(name="parent_component", component=ParentComponent)
@ -385,7 +386,21 @@ class IsolatedContextSettingTests(SimpleTestCase):
class OuterContextPropertyTests(SimpleTestCase): class OuterContextPropertyTests(SimpleTestCase):
def test_outer_context_property_with_component(self): @override_settings(
COMPONENTS={"context_behavior": "global"},
)
def test_outer_context_property_with_component_global(self):
template = Template(
"{% load component_tags %}{% component_dependencies %}"
"{% component 'outer_context_component' only %}{% endcomponent %}"
)
rendered = template.render(Context({"variable": "outer_value"})).strip()
self.assertIn("outer_value", rendered, rendered)
@override_settings(
COMPONENTS={"context_behavior": "isolated"},
)
def test_outer_context_property_with_component_isolated(self):
template = Template( template = Template(
"{% load component_tags %}{% component_dependencies %}" "{% load component_tags %}{% component_dependencies %}"
"{% component 'outer_context_component' only %}{% endcomponent %}" "{% component 'outer_context_component' only %}{% endcomponent %}"

View file

@ -1320,8 +1320,8 @@ class IterationFillTest(SimpleTestCase):
component.registry.register("slot_in_a_loop", self.ComponentSimpleSlotInALoop) component.registry.register("slot_in_a_loop", self.ComponentSimpleSlotInALoop)
objects = [ objects = [
{"inner": ["OBJECT1_ITER1", "OBJECT2_ITER1"]}, {"inner": ["ITER1_OBJ1", "ITER1_OBJ2"]},
{"inner": ["OBJECT1_ITER2", "OBJECT2_ITER2"]}, {"inner": ["ITER2_OBJ1", "ITER2_OBJ2"]},
] ]
template = Template( template = Template(
@ -1343,10 +1343,10 @@ class IterationFillTest(SimpleTestCase):
self.assertHTMLEqual( self.assertHTMLEqual(
rendered, rendered,
""" """
OBJECT1_ITER1 ITER1_OBJ1
OBJECT2_ITER1 ITER1_OBJ2
OBJECT1_ITER2 ITER2_OBJ1
OBJECT2_ITER2 ITER2_OBJ2
""", """,
) )
@ -1354,8 +1354,8 @@ class IterationFillTest(SimpleTestCase):
component.registry.register("slot_in_a_loop", self.ComponentSimpleSlotInALoop) component.registry.register("slot_in_a_loop", self.ComponentSimpleSlotInALoop)
objects = [ objects = [
{"inner": ["OBJECT1_ITER1", "OBJECT2_ITER1"]}, {"inner": ["ITER1_OBJ1", "ITER1_OBJ2"]},
{"inner": ["OBJECT1_ITER2", "OBJECT2_ITER2"]}, {"inner": ["ITER2_OBJ1", "ITER2_OBJ2"]},
] ]
template = Template( template = Template(
@ -1389,14 +1389,14 @@ class IterationFillTest(SimpleTestCase):
""" """
OUTER_SCOPE_VARIABLE1 OUTER_SCOPE_VARIABLE1
OUTER_SCOPE_VARIABLE2 OUTER_SCOPE_VARIABLE2
OBJECT1_ITER1 ITER1_OBJ1
OUTER_SCOPE_VARIABLE2 OUTER_SCOPE_VARIABLE2
OBJECT2_ITER1 ITER1_OBJ2
OUTER_SCOPE_VARIABLE1 OUTER_SCOPE_VARIABLE1
OUTER_SCOPE_VARIABLE2 OUTER_SCOPE_VARIABLE2
OBJECT1_ITER2 ITER2_OBJ1
OUTER_SCOPE_VARIABLE2 OUTER_SCOPE_VARIABLE2
OBJECT2_ITER2 ITER2_OBJ2
""", """,
) )
@ -1404,8 +1404,8 @@ class IterationFillTest(SimpleTestCase):
component.registry.register("slot_in_a_loop", self.ComponentSimpleSlotInALoop) component.registry.register("slot_in_a_loop", self.ComponentSimpleSlotInALoop)
objects = [ objects = [
{"inner": ["OBJECT1_ITER1", "OBJECT2_ITER1"]}, {"inner": ["ITER1_OBJ1", "ITER1_OBJ2"]},
{"inner": ["OBJECT1_ITER2", "OBJECT2_ITER2"]}, {"inner": ["ITER2_OBJ1", "ITER2_OBJ2"]},
] ]
template = Template( template = Template(
@ -1427,10 +1427,10 @@ class IterationFillTest(SimpleTestCase):
self.assertHTMLEqual( self.assertHTMLEqual(
rendered, rendered,
""" """
OBJECT1_ITER1 default ITER1_OBJ1 default
OBJECT2_ITER1 default ITER1_OBJ2 default
OBJECT1_ITER2 default ITER2_OBJ1 default
OBJECT2_ITER2 default ITER2_OBJ2 default
""", """,
) )
@ -1440,8 +1440,8 @@ class IterationFillTest(SimpleTestCase):
component.registry.register("slot_in_a_loop", self.ComponentSimpleSlotInALoop) component.registry.register("slot_in_a_loop", self.ComponentSimpleSlotInALoop)
objects = [ objects = [
{"inner": ["OBJECT1_ITER1", "OBJECT2_ITER1"]}, {"inner": ["ITER1_OBJ1", "ITER1_OBJ2"]},
{"inner": ["OBJECT1_ITER2", "OBJECT2_ITER2"]}, {"inner": ["ITER2_OBJ1", "ITER2_OBJ2"]},
] ]
template = Template( template = Template(
@ -1475,13 +1475,13 @@ class IterationFillTest(SimpleTestCase):
""" """
OUTER_SCOPE_VARIABLE1 OUTER_SCOPE_VARIABLE1
OUTER_SCOPE_VARIABLE2 OUTER_SCOPE_VARIABLE2
OBJECT1_ITER1 default ITER1_OBJ1 default
OUTER_SCOPE_VARIABLE2 OUTER_SCOPE_VARIABLE2
OBJECT2_ITER1 default ITER1_OBJ2 default
OUTER_SCOPE_VARIABLE1 OUTER_SCOPE_VARIABLE1
OUTER_SCOPE_VARIABLE2 OUTER_SCOPE_VARIABLE2
OBJECT1_ITER2 default ITER2_OBJ1 default
OUTER_SCOPE_VARIABLE2 OUTER_SCOPE_VARIABLE2
OBJECT2_ITER2 default ITER2_OBJ2 default
""", """,
) )