diff --git a/src/django_components/component.py b/src/django_components/component.py
index 1cdd6fc7..5df37dfd 100644
--- a/src/django_components/component.py
+++ b/src/django_components/component.py
@@ -20,18 +20,19 @@ from django.views import View
# way the two modules depend on one another.
from django_components.component_registry import registry # NOQA
from django_components.component_registry import AlreadyRegistered, ComponentRegistry, NotRegistered, register # NOQA
+from django_components.context import capture_root_context, set_root_context, get_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.node import walk_nodelist
from django_components.slots import (
FillContent,
FillNode,
SlotName,
SlotNode,
render_component_template_with_slots,
- OUTER_CONTEXT_CONTEXT_KEY,
DEFAULT_SLOT_KEY,
)
-from django_components.utils import search, walk_nodelist, gen_id
+from django_components.utils import search, gen_id
RENDERED_COMMENT_TEMPLATE = ""
@@ -233,6 +234,12 @@ class Component(View, metaclass=SimplifiedInterfaceMediaDefiningClass):
return mark_safe(f"")
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:
template_string = self.get_template_string(context)
if template_string is not None:
@@ -263,7 +270,7 @@ class Component(View, metaclass=SimplifiedInterfaceMediaDefiningClass):
def on_node(node: Node) -> None:
if isinstance(node, SlotNode):
trace_msg("ASSOC", "SLOT", node.name, node.node_id, component_id=self.component_id)
- node.component_id = self.component_id
+ set_slot_component_association(context, node.node_id, self.component_id)
walk_nodelist(template.nodelist, on_node)
@@ -337,9 +344,7 @@ class ComponentNode(Node):
# If this is the outer-/top-most component node, then save the outer context,
# so it can be used by nested Slots.
- root_ctx_already_defined = OUTER_CONTEXT_CONTEXT_KEY in context
- if not root_ctx_already_defined:
- context.push({OUTER_CONTEXT_CONTEXT_KEY: context.__copy__()})
+ capture_root_context(context)
# Resolve FilterExpressions and Variables that were passed as args to the
# component, then call component's context method
@@ -373,9 +378,9 @@ class ComponentNode(Node):
# 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.
- orig_ctx = context
+ root_ctx = get_root_context(context)
context = context.new()
- context.push({OUTER_CONTEXT_CONTEXT_KEY: orig_ctx})
+ set_root_context(context, root_ctx)
with context.update(component_context):
rendered_component = component.render(context)
@@ -390,6 +395,6 @@ class ComponentNode(Node):
def safe_resolve(context_item: FilterExpression, context: Context) -> Any:
- """Resolve FilterExpressions and Variables in context if possible. Return other items unchanged."""
+ """Resolve FilterExpressions and Variables in context if possible. Return other items unchanged."""
return context_item.resolve(context) if hasattr(context_item, "resolve") else context_item
diff --git a/src/django_components/context.py b/src/django_components/context.py
new file mode 100644
index 00000000..d486bcd2
--- /dev/null
+++ b/src/django_components/context.py
@@ -0,0 +1,121 @@
+"""
+This file cetralizes 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 typing import Optional, TYPE_CHECKING
+
+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, context.__copy__())
+
+
+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]
diff --git a/src/django_components/node.py b/src/django_components/node.py
new file mode 100644
index 00000000..43b200c9
--- /dev/null
+++ b/src/django_components/node.py
@@ -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
diff --git a/src/django_components/slots.py b/src/django_components/slots.py
index db18c1c9..58e60730 100644
--- a/src/django_components/slots.py
+++ b/src/django_components/slots.py
@@ -1,12 +1,7 @@
import difflib
import json
import sys
-from typing import Dict, List, NamedTuple, Optional, Sequence, Set, Tuple, Type, Union
-
-if sys.version_info[:2] < (3, 9):
- from typing import ChainMap
-else:
- from collections import ChainMap
+from typing import Dict, List, NamedTuple, Optional, Sequence, Set, Type, Union
if sys.version_info[:2] < (3, 10):
from typing_extensions import TypeAlias
@@ -20,10 +15,12 @@ from django.template.exceptions import TemplateSyntaxError
from django.utils.safestring import SafeString, mark_safe
from django_components.app_settings import SlotContextBehavior, app_settings
+from django_components.context import get_root_context, get_slot_fill, set_slot_fill, get_slot_component_association
+from django_components.node import nodelist_has_content
+from django_components.logger import trace_msg
+from django_components.utils import gen_id
-FILLED_SLOTS_CONTENT_CONTEXT_KEY = "_DJANGO_COMPONENTS_FILLED_SLOTS"
DEFAULT_SLOT_KEY = "_DJANGO_COMPONENTS_DEFAULT_SLOT"
-OUTER_CONTEXT_CONTEXT_KEY = "_DJANGO_COMPONENTS_OUTER_CONTEXT"
# Type aliases
@@ -38,10 +35,6 @@ class FillContent(NamedTuple):
alias: Optional[AliasName]
-FilledSlotsKey = Tuple[SlotName, Template]
-FilledSlotsContext = ChainMap[FilledSlotsKey, FillContent]
-
-
class UserSlotVar:
"""
Extensible mechanism for offering 'fill' blocks in template access to properties
@@ -115,16 +108,21 @@ class SlotNode(Node):
return f""
def render(self, context: Context) -> SafeString:
- slot_fill_content = get_slot_fill(context, self.component_id, self.name, callee_node_name=f"SlotNode '{self.name}'")
+ component_id = get_slot_component_association(context, self.node_id)
trace_msg("RENDR", "SLOT", self.name, self.node_id, component_id=component_id)
+ slot_fill_content = get_slot_fill(context, component_id, self.name)
extra_context = {}
+
+ # Slot fill was NOT found. Will render the default fill
if slot_fill_content is None:
if self.is_required:
raise TemplateSyntaxError(
f"Slot '{self.name}' is marked as 'required' (i.e. non-optional), " f"yet no fill is provided. "
)
nodelist = self.nodelist
+
+ # Slot fill WAS found
else:
nodelist, alias = slot_fill_content
if alias:
@@ -145,7 +143,7 @@ class SlotNode(Node):
See SlotContextBehavior for the description of each option.
"""
- root_ctx: Context = context.get(OUTER_CONTEXT_CONTEXT_KEY, Context())
+ root_ctx = get_root_context(context) or Context()
if app_settings.SLOT_CONTEXT_BEHAVIOR == SlotContextBehavior.ALLOW_OVERRIDE:
return context
@@ -230,7 +228,7 @@ class IfSlotFilledConditionBranchNode(_IfSlotFilledBranchNode, ComponentIdMixin)
super().__init__(nodelist)
def evaluate(self, context: Context) -> bool:
- slot_fill = get_slot_fill(context, self.component_id, self.slot_name, callee_node_name=type(self).__name__)
+ slot_fill = get_slot_fill(context, self.component_id, self.slot_name)
is_filled = slot_fill is not None
# Make polarity switchable.
# i.e. if slot name is NOT filled and is_positive=False,
@@ -267,21 +265,6 @@ class IfSlotFilledNode(Node):
return ""
-def get_slot_fill(
- context: Context,
- component_id: str,
- slot_name: str,
- callee_node_name: str,
-) -> Optional[FillContent]:
- try:
- filled_slots_map: FilledSlotsContext = context[FILLED_SLOTS_CONTENT_CONTEXT_KEY]
- except KeyError:
- raise TemplateSyntaxError(f"Attempted to render {callee_node_name} outside a parent component.")
-
- slot_key = (component_id, slot_name)
- return filled_slots_map.get(slot_key, None)
-
-
def parse_slot_fill_nodes_from_component_nodelist(
component_nodelist: NodeList,
ComponentNodeCls: Type[Node],
@@ -305,7 +288,7 @@ def parse_slot_fill_nodes_from_component_nodelist(
and `fill "second_fill"`.
"""
fill_nodes: Sequence[FillNode] = []
- if _block_has_content(component_nodelist):
+ if nodelist_has_content(component_nodelist):
for parse_fn in (
_try_parse_as_default_fill,
_try_parse_as_named_fill_tag_set,
@@ -373,17 +356,6 @@ def _try_parse_as_default_fill(
]
-def _block_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 render_component_template_with_slots(
component_id: str,
template: Template,
@@ -392,49 +364,29 @@ def render_component_template_with_slots(
registered_name: Optional[str],
) -> str:
"""
- Given a template, context, and slot fills, this function first prepares
- the template to be able to render the fills in the place of slots, and then
- renders the template with given context.
+ This function first prepares the template to be able to render the fills
+ in the place of slots, and then 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)
- updated_filled_slots_context = _prepare_component_template_filled_slot_context(
- component_id,
- template,
- fill_content,
- prev_filled_slots_context,
- registered_name,
- )
-
- with context.update({FILLED_SLOTS_CONTENT_CONTEXT_KEY: updated_filled_slots_context}):
- return template.render(context)
-
-
-def _prepare_component_template_filled_slot_context(
- component_id: str,
- template: Template,
- fill_content: Dict[str, FillContent],
- slots_context: Optional[FilledSlotsContext],
- registered_name: Optional[str],
-) -> FilledSlotsContext:
+ # ---- Prepare slot fills ----
slot_name2fill_content = _collect_slot_fills_from_component_template(template, fill_content, registered_name)
# Give slot nodes knowledge of their parent component.
+ for node in template.nodelist.get_nodes_by_type(IfSlotFilledConditionBranchNode):
+ if isinstance(node, IfSlotFilledConditionBranchNode):
trace_msg("ASSOC", "IFSB", node.slot_name, node.node_id, component_id=component_id)
node.component_id = component_id
- # Return updated FILLED_SLOTS_CONTEXT map
- filled_slots_map: Dict[FilledSlotsKey, FillContent] = {
- (component_id, slot_name): 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.
- }
+ 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)
- if slots_context is not None:
- return slots_context.new_child(filled_slots_map)
- else:
- return ChainMap(filled_slots_map)
+ # ---- Render ----
+ return template.render(context)
def _collect_slot_fills_from_component_template(
diff --git a/src/django_components/utils.py b/src/django_components/utils.py
index 1e226f49..774c6892 100644
--- a/src/django_components/utils.py
+++ b/src/django_components/utils.py
@@ -1,9 +1,7 @@
import glob
-import random
from pathlib import Path
-from typing import Callable, List, NamedTuple, Optional
+from typing import List, NamedTuple, Optional
-from django.template.base import Node, NodeList
from django.template.engine import Engine
from django_components.template_loader import Loader
@@ -39,34 +37,15 @@ def search(search_glob: Optional[str] = None, engine: Optional[Engine] = None) -
return SearchResult(searched_dirs=dirs, matched_files=component_filenames)
-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
+# Global counter to ensure that all IDs generated by `gen_id` WILL be unique
+_id = 0
def gen_id(length: int = 5) -> str:
- # Generate random value
- # See https://stackoverflow.com/questions/2782229
- value = random.randrange(16**length)
+ """Generate a unique ID that can be associated with a Node"""
+ # Global counter to avoid conflicts
+ global _id
+ _id += 1
- # Signed hexadecimal (lowercase).
- # See https://docs.python.org/3/library/stdtypes.html#printf-style-string-formatting
- return f"{value:x}"
+ # Pad the ID with `0`s up to 4 digits, e.g. `0007`
+ return f"{_id:04}"