refactor: use component IDs as keys for slot fill llokup

This commit is contained in:
Juro Oravec 2024-04-16 14:31:51 +02:00
parent c1369ab2c7
commit ce5b5c40d8
5 changed files with 210 additions and 115 deletions

View file

@ -20,18 +20,19 @@ 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.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.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 (
FillContent, FillContent,
FillNode, FillNode,
SlotName, SlotName,
SlotNode, SlotNode,
render_component_template_with_slots, render_component_template_with_slots,
OUTER_CONTEXT_CONTEXT_KEY,
DEFAULT_SLOT_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} -->" 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(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:
@ -263,7 +270,7 @@ class Component(View, metaclass=SimplifiedInterfaceMediaDefiningClass):
def on_node(node: Node) -> None: def on_node(node: Node) -> None:
if isinstance(node, SlotNode): if isinstance(node, SlotNode):
trace_msg("ASSOC", "SLOT", node.name, node.node_id, component_id=self.component_id) 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) 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, # If this is the outer-/top-most component node, then save the outer context,
# so it can be used by nested Slots. # so it can be used by nested Slots.
root_ctx_already_defined = OUTER_CONTEXT_CONTEXT_KEY in context capture_root_context(context)
if not root_ctx_already_defined:
context.push({OUTER_CONTEXT_CONTEXT_KEY: context.__copy__()})
# 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
@ -373,9 +378,9 @@ class ComponentNode(Node):
# Even if contexts are isolated, we still need to pass down the # Even if contexts are isolated, we still need to pass down the
# original context so variables in slots can be rendered using # original context so variables in slots can be rendered using
# the original context. # the original context.
orig_ctx = context root_ctx = get_root_context(context)
context = context.new() context = context.new()
context.push({OUTER_CONTEXT_CONTEXT_KEY: orig_ctx}) 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)
@ -390,6 +395,6 @@ class ComponentNode(Node):
def safe_resolve(context_item: FilterExpression, context: Context) -> Any: 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 return context_item.resolve(context) if hasattr(context_item, "resolve") else context_item

View 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]

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,12 +1,7 @@
import difflib import difflib
import json import json
import sys import sys
from typing import Dict, List, NamedTuple, Optional, Sequence, Set, Tuple, Type, Union from typing import Dict, List, NamedTuple, Optional, Sequence, 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): if sys.version_info[:2] < (3, 10):
from typing_extensions import TypeAlias 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.utils.safestring import SafeString, mark_safe
from django_components.app_settings import SlotContextBehavior, app_settings 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" DEFAULT_SLOT_KEY = "_DJANGO_COMPONENTS_DEFAULT_SLOT"
OUTER_CONTEXT_CONTEXT_KEY = "_DJANGO_COMPONENTS_OUTER_CONTEXT"
# Type aliases # Type aliases
@ -38,10 +35,6 @@ class FillContent(NamedTuple):
alias: Optional[AliasName] alias: Optional[AliasName]
FilledSlotsKey = Tuple[SlotName, Template]
FilledSlotsContext = ChainMap[FilledSlotsKey, FillContent]
class UserSlotVar: class UserSlotVar:
""" """
Extensible mechanism for offering 'fill' blocks in template access to properties 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}>" 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:
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) 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 = {} extra_context = {}
# Slot fill was NOT found. Will render the default fill
if slot_fill_content is None: 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:
@ -145,7 +143,7 @@ class SlotNode(Node):
See SlotContextBehavior for the description of each option. 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: if app_settings.SLOT_CONTEXT_BEHAVIOR == SlotContextBehavior.ALLOW_OVERRIDE:
return context return context
@ -230,7 +228,7 @@ class IfSlotFilledConditionBranchNode(_IfSlotFilledBranchNode, ComponentIdMixin)
super().__init__(nodelist) super().__init__(nodelist)
def evaluate(self, context: Context) -> bool: 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 is_filled = slot_fill 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,
@ -267,21 +265,6 @@ class IfSlotFilledNode(Node):
return "" 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( def parse_slot_fill_nodes_from_component_nodelist(
component_nodelist: NodeList, component_nodelist: NodeList,
ComponentNodeCls: Type[Node], ComponentNodeCls: Type[Node],
@ -305,7 +288,7 @@ def parse_slot_fill_nodes_from_component_nodelist(
and `fill "second_fill"`. and `fill "second_fill"`.
""" """
fill_nodes: Sequence[FillNode] = [] fill_nodes: Sequence[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,
@ -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( def render_component_template_with_slots(
component_id: str, component_id: str,
template: Template, template: Template,
@ -392,49 +364,29 @@ def render_component_template_with_slots(
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(
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:
slot_name2fill_content = _collect_slot_fills_from_component_template(template, fill_content, registered_name) slot_name2fill_content = _collect_slot_fills_from_component_template(template, fill_content, registered_name)
# Give slot nodes knowledge of their parent component. # 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) trace_msg("ASSOC", "IFSB", node.slot_name, node.node_id, component_id=component_id)
node.component_id = component_id node.component_id = component_id
# Return updated FILLED_SLOTS_CONTEXT map with context.update({}):
filled_slots_map: Dict[FilledSlotsKey, FillContent] = { for slot_name, content_data in slot_name2fill_content.items():
(component_id, slot_name): content_data # Slots whose content is None (i.e. unfilled) are dropped.
for slot_name, content_data in slot_name2fill_content.items() if not content_data:
if content_data # Slots whose content is None (i.e. unfilled) are dropped. continue
} set_slot_fill(context, component_id, slot_name, content_data)
if slots_context is not None: # ---- Render ----
return slots_context.new_child(filled_slots_map) return template.render(context)
else:
return ChainMap(filled_slots_map)
def _collect_slot_fills_from_component_template( def _collect_slot_fills_from_component_template(

View file

@ -1,9 +1,7 @@
import glob import glob
import random
from pathlib import Path 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.template.engine import Engine
from django_components.template_loader import Loader 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) return SearchResult(searched_dirs=dirs, matched_files=component_filenames)
def walk_nodelist(nodes: NodeList, callback: Callable[[Node], None]) -> None: # Global counter to ensure that all IDs generated by `gen_id` WILL be unique
"""Recursively walk a NodeList, calling `callback` for each Node.""" _id = 0
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
def gen_id(length: int = 5) -> str: def gen_id(length: int = 5) -> str:
# Generate random value """Generate a unique ID that can be associated with a Node"""
# See https://stackoverflow.com/questions/2782229 # Global counter to avoid conflicts
value = random.randrange(16**length) global _id
_id += 1
# Signed hexadecimal (lowercase). # Pad the ID with `0`s up to 4 digits, e.g. `0007`
# See https://docs.python.org/3/library/stdtypes.html#printf-style-string-formatting return f"{_id:04}"
return f"{value:x}"