mirror of
https://github.com/django-components/django-components.git
synced 2025-08-16 20:20:14 +00:00
refactor: use component IDs as keys for slot fill llokup
This commit is contained in:
parent
c1369ab2c7
commit
ce5b5c40d8
5 changed files with 210 additions and 115 deletions
|
@ -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 = "<!-- _RENDERED {name} -->"
|
||||
|
||||
|
@ -233,6 +234,12 @@ class Component(View, metaclass=SimplifiedInterfaceMediaDefiningClass):
|
|||
return mark_safe(f"<script>{self.js}</script>")
|
||||
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)
|
||||
|
|
121
src/django_components/context.py
Normal file
121
src/django_components/context.py
Normal file
|
@ -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]
|
38
src/django_components/node.py
Normal file
38
src/django_components/node.py
Normal 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
|
|
@ -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"<Slot Node: {self.name}. Contents: {repr(self.nodelist)}. Options: {self.active_flags}>"
|
||||
|
||||
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(
|
||||
|
|
|
@ -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}"
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue