From ce5b5c40d87c9b8a941cb703be2b0ac1890e8883 Mon Sep 17 00:00:00 2001 From: Juro Oravec Date: Tue, 16 Apr 2024 14:31:51 +0200 Subject: [PATCH] refactor: use component IDs as keys for slot fill llokup --- src/django_components/component.py | 23 +++--- src/django_components/context.py | 121 +++++++++++++++++++++++++++++ src/django_components/node.py | 38 +++++++++ src/django_components/slots.py | 104 +++++++------------------ src/django_components/utils.py | 39 +++------- 5 files changed, 210 insertions(+), 115 deletions(-) create mode 100644 src/django_components/context.py create mode 100644 src/django_components/node.py 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}"