From 094e05054dca40cd157938d661522cda02bc9705 Mon Sep 17 00:00:00 2001 From: Juro Oravec Date: Mon, 15 Apr 2024 10:00:30 +0200 Subject: [PATCH 01/28] refactor: make fill parsers always return list --- src/django_components/slots.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/django_components/slots.py b/src/django_components/slots.py index 72551803..3a7a3485 100644 --- a/src/django_components/slots.py +++ b/src/django_components/slots.py @@ -286,7 +286,7 @@ def parse_slot_fill_nodes_from_component_nodelist( def _try_parse_as_named_fill_tag_set( nodelist: NodeList, ComponentNodeCls: Type[Node], -) -> Optional[Iterable[NamedFillNode]]: +) -> Sequence[NamedFillNode]: result = [] seen_name_fexps: Set[FilterExpression] = set() for node in nodelist: @@ -303,19 +303,19 @@ def _try_parse_as_named_fill_tag_set( elif isinstance(node, TextNode) and node.s.isspace(): pass else: - return None + return [] return result def _try_parse_as_default_fill( nodelist: NodeList, ComponentNodeCls: Type[Node], -) -> Optional[ImplicitFillNode]: +) -> Sequence[ImplicitFillNode]: nodes_stack: List[Node] = list(nodelist) while nodes_stack: node = nodes_stack.pop() if isinstance(node, NamedFillNode): - return None + return [] elif isinstance(node, ComponentNodeCls): # Stop searching here, as fill tags are permitted inside component blocks # embedded within a default fill node. @@ -323,7 +323,7 @@ def _try_parse_as_default_fill( for nodelist_attr_name in node.child_nodelists: nodes_stack.extend(getattr(node, nodelist_attr_name, [])) else: - return ImplicitFillNode(nodelist=nodelist) + return [ImplicitFillNode(nodelist=nodelist)] def _block_has_content(nodelist: NodeList) -> bool: From 8d3a2ba8db95b2266acbc63cf600971b335cfa7c Mon Sep 17 00:00:00 2001 From: Juro Oravec Date: Mon, 15 Apr 2024 10:04:27 +0200 Subject: [PATCH 02/28] refactor: simplify slot intermediate slot types --- src/django_components/component.py | 35 +++++++++++++----------------- src/django_components/slots.py | 30 ++++++++++++++----------- 2 files changed, 32 insertions(+), 33 deletions(-) diff --git a/src/django_components/component.py b/src/django_components/component.py index fdcc80df..9dc2c7c8 100644 --- a/src/django_components/component.py +++ b/src/django_components/component.py @@ -2,7 +2,7 @@ import inspect import os import sys 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, Sequence, Tuple, Type, Union from django.core.exceptions import ImproperlyConfigured from django.forms.widgets import Media, MediaDefiningClass @@ -23,12 +23,12 @@ from django_components.component_registry import AlreadyRegistered, ComponentReg from django_components.logger import logger from django_components.middleware import is_dependency_middleware_active from django_components.slots import ( - DefaultFillContent, ImplicitFillNode, - NamedFillContent, NamedFillNode, + FillContent, SlotName, render_component_template_with_slots, + DEFAULT_SLOT_KEY, ) from django_components.utils import search @@ -186,7 +186,7 @@ class Component(View, metaclass=SimplifiedInterfaceMediaDefiningClass): self, registered_name: Optional[str] = None, outer_context: Optional[Context] = None, - fill_content: Union[DefaultFillContent, Iterable[NamedFillContent]] = (), # type: ignore + fill_content: dict[str, FillContent] = {}, # type: ignore ): self.registered_name: Optional[str] = registered_name self.outer_context: Context = outer_context or Context() @@ -280,14 +280,13 @@ class Component(View, metaclass=SimplifiedInterfaceMediaDefiningClass): escape_content: bool = True, ) -> None: """Fill component slots outside of template rendering.""" - self.fill_content = [ - ( - slot_name, + self.fill_content = { + slot_name: FillContent( TextNode(escape(content) if escape_content else content), None, ) for (slot_name, content) in slots_data.items() - ] + } class ComponentNode(Node): @@ -299,20 +298,14 @@ class ComponentNode(Node): context_args: List[FilterExpression], context_kwargs: Mapping[str, FilterExpression], isolated_context: bool = False, - fill_nodes: Union[ImplicitFillNode, Iterable[NamedFillNode]] = (), + fill_nodes: Sequence[Union[ImplicitFillNode, NamedFillNode]] = (), ) -> None: self.name_fexp = name_fexp self.context_args = context_args or [] self.context_kwargs = context_kwargs or {} self.isolated_context = isolated_context self.fill_nodes = fill_nodes - self.nodelist = self._create_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) + self.nodelist = NodeList(fill_nodes) def __repr__(self) -> str: return "".format( @@ -330,16 +323,18 @@ class ComponentNode(Node): 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()} - if isinstance(self.fill_nodes, ImplicitFillNode): - fill_content = self.fill_nodes.nodelist + if len(self.fill_nodes) == 1 and isinstance(self.fill_nodes[0], ImplicitFillNode): + fill_content: Dict[str, FillContent] = { + DEFAULT_SLOT_KEY: FillContent(self.fill_nodes[0].nodelist, None) + } else: - fill_content = [] + fill_content = {} for fill_node in self.fill_nodes: # Note that outer component context is used to resolve variables in # fill tag. resolved_name = fill_node.name_fexp.resolve(context) 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( registered_name=resolved_component_name, diff --git a/src/django_components/slots.py b/src/django_components/slots.py index 3a7a3485..c5d4a3ad 100644 --- a/src/django_components/slots.py +++ b/src/django_components/slots.py @@ -1,6 +1,6 @@ import difflib import sys -from typing import Dict, Iterable, List, Optional, Set, Tuple, Type, Union +from typing import Dict, List, NamedTuple, Optional, Sequence, Set, Tuple, Type, Union if sys.version_info[:2] < (3, 9): from typing import ChainMap @@ -19,16 +19,20 @@ from django.template.exceptions import TemplateSyntaxError from django.utils.safestring import SafeString, mark_safe FILLED_SLOTS_CONTENT_CONTEXT_KEY = "_DJANGO_COMPONENTS_FILLED_SLOTS" +DEFAULT_SLOT_KEY = "_DJANGO_COMPONENTS_DEFAULT_SLOT" # Type aliases SlotName = str AliasName = str -DefaultFillContent: TypeAlias = NodeList -NamedFillContent = Tuple[SlotName, NodeList, Optional[AliasName]] -FillContent = Tuple[NodeList, Optional[AliasName]] +class FillContent(NamedTuple): + """Data passed from component to slot to render that slot""" + nodes: NodeList + alias: Optional[AliasName] + + FilledSlotsContext = ChainMap[Tuple[SlotName, Template], FillContent] @@ -103,7 +107,7 @@ class SlotNode(Node, TemplateAwareNodeMixin): extra_context = {} try: - slot_fill_content: FillContent = filled_slots_map[(self.name, self.template)] + slot_fill_content = filled_slots_map[(self.name, self.template)] except KeyError: if self.is_required: raise TemplateSyntaxError( @@ -244,7 +248,7 @@ class IfSlotFilledNode(Node): def parse_slot_fill_nodes_from_component_nodelist( component_nodelist: NodeList, ComponentNodeCls: Type[Node], -) -> Union[Iterable[NamedFillNode], ImplicitFillNode]: +) -> Sequence[Union[NamedFillNode, ImplicitFillNode]]: """ Given a component body (`django.template.NodeList`), find all slot fills, whether defined explicitly with `{% fill %}` or implicitly. @@ -263,7 +267,7 @@ def parse_slot_fill_nodes_from_component_nodelist( Then this function returns the nodes (`django.template.Node`) for `fill "first_fill"` and `fill "second_fill"`. """ - fill_nodes: Union[Iterable[NamedFillNode], ImplicitFillNode] = [] + fill_nodes: Sequence[Union[NamedFillNode, ImplicitFillNode]] = [] if _block_has_content(component_nodelist): for parse_fn in ( _try_parse_as_default_fill, @@ -340,7 +344,7 @@ def _block_has_content(nodelist: NodeList) -> bool: def render_component_template_with_slots( template: Template, context: Context, - fill_content: Union[DefaultFillContent, Iterable[NamedFillContent]], + fill_content: Dict[str, FillContent], registered_name: Optional[str], ) -> str: """ @@ -363,16 +367,16 @@ def render_component_template_with_slots( def _prepare_component_template_filled_slot_context( template: Template, - fill_content: Union[DefaultFillContent, Iterable[NamedFillContent]], + fill_content: Dict[str, FillContent], slots_context: Optional[FilledSlotsContext], registered_name: Optional[str], ) -> FilledSlotsContext: - if isinstance(fill_content, NodeList): - default_fill_content = (fill_content, None) - named_fills_content = {} + if DEFAULT_SLOT_KEY in fill_content: + named_fills_content = fill_content.copy() + default_fill_content = named_fills_content.pop(DEFAULT_SLOT_KEY) else: + named_fills_content = fill_content 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. slot_name2fill_content: Dict[SlotName, Optional[FillContent]] = {} From ec12a3bcb8e2cd0081cfdae55b35805bd5bb823e Mon Sep 17 00:00:00 2001 From: Juro Oravec Date: Mon, 15 Apr 2024 10:05:27 +0200 Subject: [PATCH 03/28] feat: capture root context --- src/django_components/component.py | 14 ++++++++++++++ src/django_components/slots.py | 1 + 2 files changed, 15 insertions(+) diff --git a/src/django_components/component.py b/src/django_components/component.py index 9dc2c7c8..2f7ba442 100644 --- a/src/django_components/component.py +++ b/src/django_components/component.py @@ -28,6 +28,7 @@ from django_components.slots import ( FillContent, SlotName, render_component_template_with_slots, + OUTER_CONTEXT_CONTEXT_KEY, DEFAULT_SLOT_KEY, ) from django_components.utils import search @@ -317,6 +318,12 @@ class ComponentNode(Node): resolved_component_name = self.name_fexp.resolve(context) 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. + root_ctx_already_defined = OUTER_CONTEXT_CONTEXT_KEY in 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 # component, then call component's context method # to get values to insert into the context @@ -344,8 +351,15 @@ class ComponentNode(Node): 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: + # 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 context = context.new() + context.push({ OUTER_CONTEXT_CONTEXT_KEY: orig_ctx }) + with context.update(component_context): rendered_component = component.render(context) diff --git a/src/django_components/slots.py b/src/django_components/slots.py index c5d4a3ad..069fa75a 100644 --- a/src/django_components/slots.py +++ b/src/django_components/slots.py @@ -20,6 +20,7 @@ from django.utils.safestring import SafeString, mark_safe 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 From d297249d9fe26ee045e465a5ddc4f1e82daf85d5 Mon Sep 17 00:00:00 2001 From: Juro Oravec Date: Mon, 15 Apr 2024 10:06:07 +0200 Subject: [PATCH 04/28] feat: resolve slot context based on settings --- src/django_components/app_settings.py | 104 ++++++++++++++++++++++++++ src/django_components/slots.py | 26 ++++++- 2 files changed, 128 insertions(+), 2 deletions(-) diff --git a/src/django_components/app_settings.py b/src/django_components/app_settings.py index d643fd8b..9c0cc58b 100644 --- a/src/django_components/app_settings.py +++ b/src/django_components/app_settings.py @@ -9,6 +9,98 @@ class ContextBehavior(str, Enum): 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: def __init__(self) -> None: self.settings = getattr(settings, "COMPONENTS", {}) @@ -37,5 +129,17 @@ class AppSettings: valid_values = [behavior.value for behavior in ContextBehavior] raise ValueError(f"Invalid context behavior: {raw_value}. Valid options are {valid_values}") + @property + def SLOT_CONTEXT_BEHAVIOR(self) -> SlotContextBehavior: + raw_value = self.settings.setdefault("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() diff --git a/src/django_components/slots.py b/src/django_components/slots.py index 069fa75a..fe9fa5c5 100644 --- a/src/django_components/slots.py +++ b/src/django_components/slots.py @@ -18,6 +18,8 @@ from django.template.defaulttags import CommentNode from django.template.exceptions import TemplateSyntaxError from django.utils.safestring import SafeString, mark_safe +from django_components.app_settings import app_settings, SlotContextBehavior + FILLED_SLOTS_CONTENT_CONTEXT_KEY = "_DJANGO_COMPONENTS_FILLED_SLOTS" DEFAULT_SLOT_KEY = "_DJANGO_COMPONENTS_DEFAULT_SLOT" OUTER_CONTEXT_CONTEXT_KEY = "_DJANGO_COMPONENTS_OUTER_CONTEXT" @@ -122,8 +124,28 @@ class SlotNode(Node, TemplateAwareNodeMixin): raise TemplateSyntaxError() extra_context[alias] = UserSlotVar(self, context) - with context.update(extra_context): - return nodelist.render(context) + used_ctx = self.resolve_slot_context(context) + with used_ctx.update(extra_context): + return nodelist.render(used_ctx) + + 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 = context.get(OUTER_CONTEXT_CONTEXT_KEY, 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__() + new_context.push(root_ctx) + return new_context + else: + raise ValueError(f"Unknown value for SLOT_CONTEXT_BEHAVIOR: '{app_settings.SLOT_CONTEXT_BEHAVIOR}'") class BaseFillNode(Node): From c0c9e145a97743e94cf885c583f2da36825064ef Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 15 Apr 2024 08:07:45 +0000 Subject: [PATCH 05/28] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/django_components/component.py | 16 +++++++--------- src/django_components/slots.py | 5 +++-- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/src/django_components/component.py b/src/django_components/component.py index 2f7ba442..509c9c89 100644 --- a/src/django_components/component.py +++ b/src/django_components/component.py @@ -23,13 +23,13 @@ from django_components.component_registry import AlreadyRegistered, ComponentReg from django_components.logger import logger from django_components.middleware import is_dependency_middleware_active from django_components.slots import ( + DEFAULT_SLOT_KEY, + OUTER_CONTEXT_CONTEXT_KEY, + FillContent, ImplicitFillNode, NamedFillNode, - FillContent, SlotName, render_component_template_with_slots, - OUTER_CONTEXT_CONTEXT_KEY, - DEFAULT_SLOT_KEY, ) from django_components.utils import search @@ -322,7 +322,7 @@ class ComponentNode(Node): # 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__() }) + context.push({OUTER_CONTEXT_CONTEXT_KEY: context.__copy__()}) # Resolve FilterExpressions and Variables that were passed as args to the # component, then call component's context method @@ -331,9 +331,7 @@ class ComponentNode(Node): resolved_context_kwargs = {key: safe_resolve(kwarg, context) for key, kwarg in self.context_kwargs.items()} if len(self.fill_nodes) == 1 and isinstance(self.fill_nodes[0], ImplicitFillNode): - fill_content: Dict[str, FillContent] = { - DEFAULT_SLOT_KEY: FillContent(self.fill_nodes[0].nodelist, None) - } + fill_content: Dict[str, FillContent] = {DEFAULT_SLOT_KEY: FillContent(self.fill_nodes[0].nodelist, None)} else: fill_content = {} for fill_node in self.fill_nodes: @@ -358,8 +356,8 @@ class ComponentNode(Node): # the original context. orig_ctx = context context = context.new() - context.push({ OUTER_CONTEXT_CONTEXT_KEY: orig_ctx }) - + context.push({OUTER_CONTEXT_CONTEXT_KEY: orig_ctx}) + with context.update(component_context): rendered_component = component.render(context) diff --git a/src/django_components/slots.py b/src/django_components/slots.py index fe9fa5c5..c83bec84 100644 --- a/src/django_components/slots.py +++ b/src/django_components/slots.py @@ -18,7 +18,7 @@ from django.template.defaulttags import CommentNode from django.template.exceptions import TemplateSyntaxError from django.utils.safestring import SafeString, mark_safe -from django_components.app_settings import app_settings, SlotContextBehavior +from django_components.app_settings import SlotContextBehavior, app_settings FILLED_SLOTS_CONTENT_CONTEXT_KEY = "_DJANGO_COMPONENTS_FILLED_SLOTS" DEFAULT_SLOT_KEY = "_DJANGO_COMPONENTS_DEFAULT_SLOT" @@ -32,6 +32,7 @@ AliasName = str class FillContent(NamedTuple): """Data passed from component to slot to render that slot""" + nodes: NodeList alias: Optional[AliasName] @@ -127,7 +128,7 @@ class SlotNode(Node, TemplateAwareNodeMixin): used_ctx = self.resolve_slot_context(context) with used_ctx.update(extra_context): return nodelist.render(used_ctx) - + def resolve_slot_context(self, context: Context) -> Context: """ Prepare the context used in a slot fill based on the settings. From 9d9462162a31d6836014ce32c0969e9ed4e01f7c Mon Sep 17 00:00:00 2001 From: Juro Oravec Date: Mon, 15 Apr 2024 10:56:28 +0200 Subject: [PATCH 06/28] refactor: merge Base, Implicit and Named FillNodes into FillNode --- src/django_components/component.py | 9 +-- src/django_components/slots.py | 71 +++++++++---------- .../templatetags/component_tags.py | 6 +- 3 files changed, 40 insertions(+), 46 deletions(-) diff --git a/src/django_components/component.py b/src/django_components/component.py index 509c9c89..e9e925cc 100644 --- a/src/django_components/component.py +++ b/src/django_components/component.py @@ -23,11 +23,8 @@ from django_components.component_registry import AlreadyRegistered, ComponentReg from django_components.logger import logger from django_components.middleware import is_dependency_middleware_active from django_components.slots import ( - DEFAULT_SLOT_KEY, - OUTER_CONTEXT_CONTEXT_KEY, + FillNode, FillContent, - ImplicitFillNode, - NamedFillNode, SlotName, render_component_template_with_slots, ) @@ -299,7 +296,7 @@ class ComponentNode(Node): context_args: List[FilterExpression], context_kwargs: Mapping[str, FilterExpression], isolated_context: bool = False, - fill_nodes: Sequence[Union[ImplicitFillNode, NamedFillNode]] = (), + fill_nodes: Sequence[FillNode] = (), ) -> None: self.name_fexp = name_fexp self.context_args = context_args or [] @@ -330,7 +327,7 @@ class ComponentNode(Node): 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()} - if len(self.fill_nodes) == 1 and isinstance(self.fill_nodes[0], ImplicitFillNode): + if len(self.fill_nodes) == 1 and self.fill_nodes[0].is_implicit: fill_content: Dict[str, FillContent] = {DEFAULT_SLOT_KEY: FillContent(self.fill_nodes[0].nodelist, None)} else: fill_content = {} diff --git a/src/django_components/slots.py b/src/django_components/slots.py index c83bec84..1a19a8d3 100644 --- a/src/django_components/slots.py +++ b/src/django_components/slots.py @@ -1,4 +1,5 @@ import difflib +import json import sys from typing import Dict, List, NamedTuple, Optional, Sequence, Set, Tuple, Type, Union @@ -13,7 +14,7 @@ else: from typing import TypeAlias from django.template import Context, Template -from django.template.base import FilterExpression, Node, NodeList, TextNode +from django.template.base import FilterExpression, Node, NodeList, TextNode, Parser from django.template.defaulttags import CommentNode from django.template.exceptions import TemplateSyntaxError from django.utils.safestring import SafeString, mark_safe @@ -149,12 +150,25 @@ class SlotNode(Node, TemplateAwareNodeMixin): raise ValueError(f"Unknown value for SLOT_CONTEXT_BEHAVIOR: '{app_settings.SLOT_CONTEXT_BEHAVIOR}'") -class BaseFillNode(Node): - def __init__(self, nodelist: NodeList): - self.nodelist: NodeList = nodelist +class FillNode(Node): + is_implicit: bool + """ + 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: - raise NotImplementedError + def __init__( + self, + nodelist: NodeList, + name_fexp: FilterExpression, + alias_fexp: Optional[FilterExpression] = None, + is_implicit: bool = False, + ): + self.nodelist: NodeList = nodelist + self.name_fexp = name_fexp + self.alias_fexp = alias_fexp + self.is_implicit = is_implicit def render(self, context: Context) -> str: raise TemplateSyntaxError( @@ -162,19 +176,7 @@ class BaseFillNode(Node): "You are probably seeing this because you have used one outside " "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: return f"<{type(self)} Name: {self.name_fexp}. Contents: {repr(self.nodelist)}.>" @@ -192,17 +194,6 @@ class NamedFillNode(BaseFillNode): 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): def __init__(self, nodelist: NodeList) -> None: self.nodelist = nodelist @@ -272,7 +263,7 @@ class IfSlotFilledNode(Node): def parse_slot_fill_nodes_from_component_nodelist( component_nodelist: NodeList, ComponentNodeCls: Type[Node], -) -> Sequence[Union[NamedFillNode, ImplicitFillNode]]: +) -> Sequence[FillNode]: """ Given a component body (`django.template.NodeList`), find all slot fills, whether defined explicitly with `{% fill %}` or implicitly. @@ -291,7 +282,7 @@ def parse_slot_fill_nodes_from_component_nodelist( Then this function returns the nodes (`django.template.Node`) for `fill "first_fill"` and `fill "second_fill"`. """ - fill_nodes: Sequence[Union[NamedFillNode, ImplicitFillNode]] = [] + fill_nodes: Sequence[FillNode] = [] if _block_has_content(component_nodelist): for parse_fn in ( _try_parse_as_default_fill, @@ -314,11 +305,11 @@ def parse_slot_fill_nodes_from_component_nodelist( def _try_parse_as_named_fill_tag_set( nodelist: NodeList, ComponentNodeCls: Type[Node], -) -> Sequence[NamedFillNode]: +) -> Sequence[FillNode]: result = [] seen_name_fexps: Set[FilterExpression] = set() for node in nodelist: - if isinstance(node, NamedFillNode): + if isinstance(node, FillNode): if node.name_fexp in seen_name_fexps: raise TemplateSyntaxError( f"Multiple fill tags cannot target the same slot name: " @@ -338,11 +329,11 @@ def _try_parse_as_named_fill_tag_set( def _try_parse_as_default_fill( nodelist: NodeList, ComponentNodeCls: Type[Node], -) -> Sequence[ImplicitFillNode]: +) -> Sequence[FillNode]: nodes_stack: List[Node] = list(nodelist) while nodes_stack: node = nodes_stack.pop() - if isinstance(node, NamedFillNode): + if isinstance(node, FillNode): return [] elif isinstance(node, ComponentNodeCls): # Stop searching here, as fill tags are permitted inside component blocks @@ -351,7 +342,13 @@ def _try_parse_as_default_fill( for nodelist_attr_name in node.child_nodelists: nodes_stack.extend(getattr(node, nodelist_attr_name, [])) else: - return [ImplicitFillNode(nodelist=nodelist)] + return [ + FillNode( + nodelist=nodelist, + name_fexp=FilterExpression(json.dumps(DEFAULT_SLOT_KEY), Parser('')), + is_implicit=True, + ) + ] def _block_has_content(nodelist: NodeList) -> bool: diff --git a/src/django_components/templatetags/component_tags.py b/src/django_components/templatetags/component_tags.py index 677248b2..656506ce 100644 --- a/src/django_components/templatetags/component_tags.py +++ b/src/django_components/templatetags/component_tags.py @@ -19,7 +19,7 @@ from django_components.slots import ( IfSlotFilledConditionBranchNode, IfSlotFilledElseBranchNode, IfSlotFilledNode, - NamedFillNode, + FillNode, SlotNode, _IfSlotFilledBranchNode, parse_slot_fill_nodes_from_component_nodelist, @@ -156,7 +156,7 @@ def do_slot(parser: Parser, token: Token) -> SlotNode: @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 'slot'-block in the component template referred to by a parent component. It exists to make component nesting easier. @@ -182,7 +182,7 @@ def do_fill(parser: Parser, token: Token) -> NamedFillNode: nodelist = parser.parse(parse_until=["endfill"]) parser.delete_first_token() - return NamedFillNode( + return FillNode( nodelist, name_fexp=FilterExpression(tgt_slot_name, tag), alias_fexp=alias_fexp, From 7fbccbf00962acf9712ea4d97ca7554c4a2d5b39 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 15 Apr 2024 08:59:41 +0000 Subject: [PATCH 07/28] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/django_components/component.py | 7 +------ src/django_components/slots.py | 6 +++--- src/django_components/templatetags/component_tags.py | 2 +- 3 files changed, 5 insertions(+), 10 deletions(-) diff --git a/src/django_components/component.py b/src/django_components/component.py index e9e925cc..66ad82cf 100644 --- a/src/django_components/component.py +++ b/src/django_components/component.py @@ -22,12 +22,7 @@ from django_components.component_registry import registry # NOQA from django_components.component_registry import AlreadyRegistered, ComponentRegistry, NotRegistered, register # NOQA from django_components.logger import logger from django_components.middleware import is_dependency_middleware_active -from django_components.slots import ( - FillNode, - FillContent, - SlotName, - render_component_template_with_slots, -) +from django_components.slots import FillContent, FillNode, SlotName, render_component_template_with_slots from django_components.utils import search RENDERED_COMMENT_TEMPLATE = "" diff --git a/src/django_components/slots.py b/src/django_components/slots.py index 1a19a8d3..afd75ce0 100644 --- a/src/django_components/slots.py +++ b/src/django_components/slots.py @@ -14,7 +14,7 @@ else: from typing import TypeAlias from django.template import Context, Template -from django.template.base import FilterExpression, Node, NodeList, TextNode, Parser +from django.template.base import FilterExpression, Node, NodeList, Parser, TextNode from django.template.defaulttags import CommentNode from django.template.exceptions import TemplateSyntaxError from django.utils.safestring import SafeString, mark_safe @@ -176,7 +176,7 @@ class FillNode(Node): "You are probably seeing this because you have used one outside " "a {% component %} context." ) - + def __repr__(self) -> str: return f"<{type(self)} Name: {self.name_fexp}. Contents: {repr(self.nodelist)}.>" @@ -345,7 +345,7 @@ def _try_parse_as_default_fill( return [ FillNode( nodelist=nodelist, - name_fexp=FilterExpression(json.dumps(DEFAULT_SLOT_KEY), Parser('')), + name_fexp=FilterExpression(json.dumps(DEFAULT_SLOT_KEY), Parser("")), is_implicit=True, ) ] diff --git a/src/django_components/templatetags/component_tags.py b/src/django_components/templatetags/component_tags.py index 656506ce..5525629c 100644 --- a/src/django_components/templatetags/component_tags.py +++ b/src/django_components/templatetags/component_tags.py @@ -16,10 +16,10 @@ from django_components.middleware import ( is_dependency_middleware_active, ) from django_components.slots import ( + FillNode, IfSlotFilledConditionBranchNode, IfSlotFilledElseBranchNode, IfSlotFilledNode, - FillNode, SlotNode, _IfSlotFilledBranchNode, parse_slot_fill_nodes_from_component_nodelist, From 390b16f76459093a3282f05f94c8ab87333c8cdf Mon Sep 17 00:00:00 2001 From: Juro Oravec Date: Mon, 15 Apr 2024 13:24:40 +0200 Subject: [PATCH 08/28] refactor: fix minor errors --- src/django_components/component.py | 9 ++++++++- src/django_components/slots.py | 6 +++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/django_components/component.py b/src/django_components/component.py index 66ad82cf..4974b78e 100644 --- a/src/django_components/component.py +++ b/src/django_components/component.py @@ -22,7 +22,14 @@ from django_components.component_registry import registry # NOQA from django_components.component_registry import AlreadyRegistered, ComponentRegistry, NotRegistered, register # NOQA from django_components.logger import logger from django_components.middleware import is_dependency_middleware_active -from django_components.slots import FillContent, FillNode, SlotName, render_component_template_with_slots +from django_components.slots import ( + FillContent, + FillNode, + SlotName, + render_component_template_with_slots, + OUTER_CONTEXT_CONTEXT_KEY, + DEFAULT_SLOT_KEY, +) from django_components.utils import search RENDERED_COMMENT_TEMPLATE = "" diff --git a/src/django_components/slots.py b/src/django_components/slots.py index afd75ce0..ac188659 100644 --- a/src/django_components/slots.py +++ b/src/django_components/slots.py @@ -14,7 +14,7 @@ else: from typing import TypeAlias from django.template import Context, Template -from django.template.base import FilterExpression, Node, NodeList, Parser, TextNode +from django.template.base import FilterExpression, Node, NodeList, TextNode, Parser from django.template.defaulttags import CommentNode from django.template.exceptions import TemplateSyntaxError from django.utils.safestring import SafeString, mark_safe @@ -143,8 +143,8 @@ class SlotNode(Node, TemplateAwareNodeMixin): elif app_settings.SLOT_CONTEXT_BEHAVIOR == SlotContextBehavior.ISOLATED: return root_ctx elif app_settings.SLOT_CONTEXT_BEHAVIOR == SlotContextBehavior.PREFER_ROOT: - new_context = context.__copy__() - new_context.push(root_ctx) + new_context: Context = context.__copy__() + new_context.update(root_ctx) return new_context else: raise ValueError(f"Unknown value for SLOT_CONTEXT_BEHAVIOR: '{app_settings.SLOT_CONTEXT_BEHAVIOR}'") From 7b64aa67913fa348af76ac9b8efecc7fd90e8313 Mon Sep 17 00:00:00 2001 From: Juro Oravec Date: Mon, 15 Apr 2024 23:38:15 +0200 Subject: [PATCH 09/28] refactor: update example of config to sampleproject settings --- sampleproject/sampleproject/settings.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/sampleproject/sampleproject/settings.py b/sampleproject/sampleproject/settings.py index 57bbde96..fc47858e 100644 --- a/sampleproject/sampleproject/settings.py +++ b/sampleproject/sampleproject/settings.py @@ -80,6 +80,14 @@ TEMPLATES = [ WSGI_APPLICATION = "sampleproject.wsgi.application" +# COMPONENTS = { +# "autodiscover": True, +# "libraries": [], +# "template_cache_size": 128, +# "context_behavior": "isolated", # "global" | "isolated" +# "slot_context_behavior": "allow_override", # "allow_override" | "prefer_root" | "isolated" +# } + # Database # https://docs.djangoproject.com/en/4.0/ref/settings/#databases From 969f0bdc324003c59c106b696c79023d39de5dfd Mon Sep 17 00:00:00 2001 From: Juro Oravec Date: Mon, 15 Apr 2024 23:39:15 +0200 Subject: [PATCH 10/28] refactor: make settings getter react to changes in settings --- src/django_components/app_settings.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/django_components/app_settings.py b/src/django_components/app_settings.py index 9c0cc58b..46f9949f 100644 --- a/src/django_components/app_settings.py +++ b/src/django_components/app_settings.py @@ -1,5 +1,5 @@ from enum import Enum -from typing import List +from typing import Dict, List from django.conf import settings @@ -102,24 +102,25 @@ class SlotContextBehavior(str, Enum): class AppSettings: - def __init__(self) -> None: - self.settings = getattr(settings, "COMPONENTS", {}) + @property + def settings(self) -> Dict: + return getattr(settings, "COMPONENTS", {}) @property def AUTODISCOVER(self) -> bool: - return self.settings.setdefault("autodiscover", True) + return self.settings.get("autodiscover", True) @property def LIBRARIES(self) -> List: - return self.settings.setdefault("libraries", []) + return self.settings.get("libraries", []) @property def TEMPLATE_CACHE_SIZE(self) -> int: - return self.settings.setdefault("template_cache_size", 128) + return self.settings.get("template_cache_size", 128) @property 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) def _validate_context_behavior(self, raw_value: ContextBehavior) -> ContextBehavior: @@ -131,7 +132,7 @@ class AppSettings: @property def SLOT_CONTEXT_BEHAVIOR(self) -> SlotContextBehavior: - raw_value = self.settings.setdefault("slot_context_behavior", SlotContextBehavior.PREFER_ROOT.value) + 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: From 1dd492314aa6b3fecf851e398841738080807ec5 Mon Sep 17 00:00:00 2001 From: Juro Oravec Date: Mon, 15 Apr 2024 23:50:17 +0200 Subject: [PATCH 11/28] refactor: use component_id instead of Template as slot fill cache key --- src/django_components/component.py | 24 ++- src/django_components/slots.py | 179 +++++++++------ .../templatetags/component_tags.py | 11 + src/django_components/utils.py | 37 +++- tests/test_component.py | 203 ++++++++++++++++++ tests/test_context.py | 19 +- 6 files changed, 398 insertions(+), 75 deletions(-) diff --git a/src/django_components/component.py b/src/django_components/component.py index 4974b78e..bc322c3b 100644 --- a/src/django_components/component.py +++ b/src/django_components/component.py @@ -26,11 +26,12 @@ 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 +from django_components.utils import search, walk_nodelist, gen_id RENDERED_COMMENT_TEMPLATE = "" @@ -185,12 +186,14 @@ class Component(View, metaclass=SimplifiedInterfaceMediaDefiningClass): def __init__( self, registered_name: Optional[str] = None, + component_id: Optional[str] = None, outer_context: Optional[Context] = None, - fill_content: dict[str, FillContent] = {}, # type: ignore + fill_content: Dict[str, FillContent] = {}, # type: ignore ): self.registered_name: Optional[str] = registered_name self.outer_context: Context = outer_context or Context() self.fill_content = fill_content + self.component_id = component_id or gen_id() def __init_subclass__(cls, **kwargs: Any) -> None: cls.class_hash = hash(inspect.getfile(cls) + cls.__name__) @@ -255,10 +258,19 @@ class Component(View, metaclass=SimplifiedInterfaceMediaDefiningClass): context = context_data if isinstance(context_data, Context) else Context(context_data) template = self.get_template(context) + # Associate the slots with this component + def on_node(node: Node) -> None: + if isinstance(node, SlotNode): + node.component_id = self.component_id + + walk_nodelist(template.nodelist, on_node) + if slots_data: 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( self, @@ -299,7 +311,9 @@ class ComponentNode(Node): context_kwargs: Mapping[str, FilterExpression], isolated_context: bool = False, fill_nodes: Sequence[FillNode] = (), + component_id: Optional[str] = None, ) -> None: + self.component_id = component_id or gen_id() self.name_fexp = name_fexp self.context_args = context_args or [] self.context_kwargs = context_kwargs or {} @@ -329,7 +343,8 @@ class ComponentNode(Node): 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()} - if len(self.fill_nodes) == 1 and self.fill_nodes[0].is_implicit: + is_default_slot = len(self.fill_nodes) == 1 and self.fill_nodes[0].is_implicit + if is_default_slot: fill_content: Dict[str, FillContent] = {DEFAULT_SLOT_KEY: FillContent(self.fill_nodes[0].nodelist, None)} else: fill_content = {} @@ -344,6 +359,7 @@ class ComponentNode(Node): registered_name=resolved_component_name, outer_context=context, fill_content=fill_content, + component_id=self.component_id, ) component_context: dict = component.get_context_data(*resolved_context_args, **resolved_context_kwargs) diff --git a/src/django_components/slots.py b/src/django_components/slots.py index ac188659..f75ae263 100644 --- a/src/django_components/slots.py +++ b/src/django_components/slots.py @@ -38,7 +38,8 @@ class FillContent(NamedTuple): alias: Optional[AliasName] -FilledSlotsContext = ChainMap[Tuple[SlotName, Template], FillContent] +FilledSlotsKey = Tuple[SlotName, Template] +FilledSlotsContext = ChainMap[FilledSlotsKey, FillContent] class UserSlotVar: @@ -61,25 +62,32 @@ class UserSlotVar: return mark_safe(self._slot.nodelist.render(self._context)) -class TemplateAwareNodeMixin: - _template: Template +class ComponentIdMixin: + """ + 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 - def template(self) -> Template: + def component_id(self) -> str: try: - return self._template + return self._component_id except AttributeError: raise RuntimeError( 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 - def template(self, value: Template) -> None: - self._template = value + @component_id.setter + def component_id(self, value: Template) -> None: + self._component_id = value -class SlotNode(Node, TemplateAwareNodeMixin): +class SlotNode(Node, ComponentIdMixin): def __init__( self, name: str, @@ -105,15 +113,10 @@ class SlotNode(Node, TemplateAwareNodeMixin): return f"" def render(self, context: Context) -> SafeString: - try: - filled_slots_map: FilledSlotsContext = context[FILLED_SLOTS_CONTENT_CONTEXT_KEY] - except KeyError: - raise TemplateSyntaxError(f"Attempted to render SlotNode '{self.name}' outside a parent component.") + slot_fill_content = get_slot_fill(context, self.component_id, self.name, callee_node_name=f"SlotNode '{self.name}'") extra_context = {} - try: - slot_fill_content = filled_slots_map[(self.name, self.template)] - except KeyError: + 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. " @@ -136,7 +139,7 @@ class SlotNode(Node, TemplateAwareNodeMixin): See SlotContextBehavior for the description of each option. """ - root_ctx = context.get(OUTER_CONTEXT_CONTEXT_KEY, Context()) + root_ctx: Context = context.get(OUTER_CONTEXT_CONTEXT_KEY, Context()) if app_settings.SLOT_CONTEXT_BEHAVIOR == SlotContextBehavior.ALLOW_OVERRIDE: return context @@ -144,13 +147,13 @@ class SlotNode(Node, TemplateAwareNodeMixin): return root_ctx elif app_settings.SLOT_CONTEXT_BEHAVIOR == SlotContextBehavior.PREFER_ROOT: new_context: Context = context.__copy__() - new_context.update(root_ctx) + 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 FillNode(Node): +class FillNode(Node, ComponentIdMixin): is_implicit: bool """ Set when a `component` tag pair is passed template content that @@ -165,7 +168,7 @@ class FillNode(Node): alias_fexp: Optional[FilterExpression] = None, is_implicit: bool = False, ): - self.nodelist: NodeList = nodelist + self.nodelist = nodelist self.name_fexp = name_fexp self.alias_fexp = alias_fexp self.is_implicit = is_implicit @@ -205,7 +208,7 @@ class _IfSlotFilledBranchNode(Node): raise NotImplementedError -class IfSlotFilledConditionBranchNode(_IfSlotFilledBranchNode, TemplateAwareNodeMixin): +class IfSlotFilledConditionBranchNode(_IfSlotFilledBranchNode, ComponentIdMixin): def __init__( self, slot_name: str, @@ -217,14 +220,8 @@ class IfSlotFilledConditionBranchNode(_IfSlotFilledBranchNode, TemplateAwareNode super().__init__(nodelist) def evaluate(self, context: Context) -> bool: - try: - filled_slots: FilledSlotsContext = context[FILLED_SLOTS_CONTENT_CONTEXT_KEY] - 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 + slot_fill = get_slot_fill(context, self.component_id, self.slot_name, callee_node_name=type(self).__name__) + is_filled = slot_fill is not None # Make polarity switchable. # i.e. if slot name is NOT filled and is_positive=False, # then False == False -> True @@ -260,6 +257,21 @@ 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], @@ -363,6 +375,7 @@ def _block_has_content(nodelist: NodeList) -> bool: def render_component_template_with_slots( + component_id: str, template: Template, context: Context, fill_content: Dict[str, FillContent], @@ -377,21 +390,49 @@ def render_component_template_with_slots( """ 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: + 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((SlotNode, IfSlotFilledConditionBranchNode)): + if isinstance(node, (SlotNode, IfSlotFilledConditionBranchNode)): + 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. + } + + if slots_context is not None: + return slots_context.new_child(filled_slots_map) + else: + return ChainMap(filled_slots_map) + + +def _collect_slot_fills_from_component_template( + template: Template, + fill_content: Dict[str, FillContent], + registered_name: Optional[str], +) -> Dict[SlotName, Optional[FillContent]]: if DEFAULT_SLOT_KEY in fill_content: named_fills_content = fill_content.copy() default_fill_content = named_fills_content.pop(DEFAULT_SLOT_KEY) @@ -405,38 +446,40 @@ def _prepare_component_template_filled_slot_context( required_slot_names: Set[str] = set() # Collect fills and check for errors - for node in template.nodelist.get_nodes_by_type((SlotNode, IfSlotFilledConditionBranchNode)): # type: ignore - if isinstance(node, SlotNode): - # Give slot node knowledge of its parent template. - node.template = template - slot_name = node.name - if slot_name in slot_name2fill_content: + for node in template.nodelist.get_nodes_by_type(SlotNode): + # Type check so the rest of the logic has type of `node` is inferred + if not isinstance(node, SlotNode): + continue + + slot_name = node.name + if slot_name in slot_name2fill_content: + raise TemplateSyntaxError( + f"Slot name '{slot_name}' re-used within the same template. " + f"Slot names must be unique." + f"To fix, check template '{template.name}' " + f"of component '{registered_name}'." + ) + if node.is_required: + required_slot_names.add(node.name) + + content_data: Optional[FillContent] = None # `None` -> unfilled + if node.is_default: + if default_slot_encountered: raise TemplateSyntaxError( - f"Slot name '{slot_name}' re-used within the same template. " - f"Slot names must be unique." + "Only one component slot may be marked as 'default'. " f"To fix, check template '{template.name}' " f"of component '{registered_name}'." ) - content_data: Optional[FillContent] = None # `None` -> unfilled - if node.is_required: - required_slot_names.add(node.name) - if node.is_default: - if default_slot_encountered: - raise TemplateSyntaxError( - "Only one component slot may be marked as 'default'. " - f"To fix, check template '{template.name}' " - f"of component '{registered_name}'." - ) - content_data = default_fill_content - default_slot_encountered = True - if not content_data: - content_data = named_fills_content.get(node.name) - 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.") + content_data = default_fill_content + 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: + content_data = named_fills_content.get(node.name) + + slot_name2fill_content[slot_name] = content_data + # Check: Only component templates that include a 'default' slot # can be invoked with implicit filling. if default_fill_content and not default_slot_encountered: @@ -449,6 +492,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} 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. for slot_name in unfilled_slots: if slot_name in required_slot_names: @@ -479,14 +533,3 @@ def _prepare_component_template_filled_slot_context( if fuzzy_slot_name_matches: msg += f"\nDid you mean '{fuzzy_slot_name_matches[0]}'?" 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) diff --git a/src/django_components/templatetags/component_tags.py b/src/django_components/templatetags/component_tags.py index 5525629c..d28621b9 100644 --- a/src/django_components/templatetags/component_tags.py +++ b/src/django_components/templatetags/component_tags.py @@ -24,6 +24,7 @@ from django_components.slots import ( _IfSlotFilledBranchNode, parse_slot_fill_nodes_from_component_nodelist, ) +from django_components.utils import gen_id if TYPE_CHECKING: from django_components.component import Component @@ -210,12 +211,22 @@ def do_component(parser: Parser, token: Token) -> ComponentNode: body: NodeList = parser.parse(parse_until=["endcomponent"]) parser.delete_first_token() fill_nodes = parse_slot_fill_nodes_from_component_nodelist(body, ComponentNode) + + # Use a unique ID to be able to tie the fill nodes with this specific component + # and its slots + component_id = gen_id() + + # Tag all fill nodes as children of this particular component instance + for node in fill_nodes: + node.component_id = component_id + component_node = ComponentNode( FilterExpression(component_name, parser), context_args, context_kwargs, isolated_context=isolated_context, fill_nodes=fill_nodes, + component_id=component_id, ) return component_node diff --git a/src/django_components/utils.py b/src/django_components/utils.py index 04c2e522..1e226f49 100644 --- a/src/django_components/utils.py +++ b/src/django_components/utils.py @@ -1,7 +1,9 @@ import glob +import random from pathlib import Path -from typing import List, NamedTuple, Optional +from typing import Callable, List, NamedTuple, Optional +from django.template.base import Node, NodeList from django.template.engine import Engine from django_components.template_loader import Loader @@ -35,3 +37,36 @@ def search(search_glob: Optional[str] = None, engine: Optional[Engine] = None) - component_filenames.append(Path(path)) 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 + + +def gen_id(length: int = 5) -> str: + # Generate random value + # See https://stackoverflow.com/questions/2782229 + value = random.randrange(16**length) + + # Signed hexadecimal (lowercase). + # See https://docs.python.org/3/library/stdtypes.html#printf-style-string-formatting + return f"{value:x}" diff --git a/tests/test_component.py b/tests/test_component.py index 7cf41a5f..06d52c31 100644 --- a/tests/test_component.py +++ b/tests/test_component.py @@ -204,6 +204,60 @@ class ComponentTest(SimpleTestCase): self.assertIn('', rendered, rendered) + def test_component_inside_slot(self): + class SlottedComponent(component.Component): + template_name = "slotted_template.html" + + def get_context_data(self, name: str | None = None) -> component.Dict[str, component.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, + """ + +
Name: Jannete
+
Day: Monday
+
+ +
Name2: Jannete
+
Day2: Monday
+
Default footer
+
+
+
+ """, + ) + class InlineComponentTest(SimpleTestCase): def test_inline_html_component(self): @@ -482,3 +536,152 @@ class ComponentIsolationTests(SimpleTestCase): """, ) + +class SlotBehaviorTests(SimpleTestCase): + def setUp(self): + class SlottedComponent(component.Component): + template_name = "slotted_template.html" + + def get_context_data(self, name: str | None = None) -> component.Dict[str, component.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, + """ + +
Name: Igor
+
Day: Monday
+
+ +
Name2: Joe2
+
Day2: Monday
+
Default footer
+
+
+
+ """, + ) + + # {{ 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, + """ + +
Name: Jannete
+
Day: Monday
+
+ +
Name2: Jannete
+
Day2: Monday
+
Default footer
+
+
+
+ """, + ) + + # {{ name }} should be empty everywhere + rendered2 = self.template.render(Context({ "day": "Monday" })) + self.assertHTMLEqual( + rendered2, + """ + +
Name:
+
Day: Monday
+
+ +
Name2:
+
Day2: Monday
+
Default footer
+
+
+
+ """, + ) + + @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, + """ + +
Name: Jannete
+
Day: Monday
+
+ +
Name2: Jannete
+
Day2: Monday
+
Default footer
+
+
+
+ """, + ) + + # {{ name }} should be neither "Jannete" nor empty anywhere + rendered = self.template.render(Context({ "day": "Monday" })) + self.assertHTMLEqual( + rendered, + """ + +
Name: Igor
+
Day: Monday
+
+ +
Name2: Joe2
+
Day2: Monday
+
Default footer
+
+
+
+ """, + ) diff --git a/tests/test_context.py b/tests/test_context.py index b5c3c2b6..4a58a0cc 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -1,6 +1,7 @@ from unittest.mock import PropertyMock, patch from django.template import Context, Template +from django.test import override_settings from django_components import component @@ -65,7 +66,7 @@ class OuterContextComponent(component.Component): template_name = "simple_template.html" def get_context_data(self): - return self.outer_context + return self.outer_context.flatten() component.registry.register(name="parent_component", component=ParentComponent) @@ -385,7 +386,21 @@ class IsolatedContextSettingTests(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( "{% load component_tags %}{% component_dependencies %}" "{% component 'outer_context_component' only %}{% endcomponent %}" From f3a2bcbc4f770e491ddb634451554ab534cda75c Mon Sep 17 00:00:00 2001 From: Juro Oravec Date: Tue, 16 Apr 2024 14:23:53 +0200 Subject: [PATCH 12/28] refactor: rename for easier debugging --- tests/test_templatetags.py | 48 +++++++++++++++++++------------------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/tests/test_templatetags.py b/tests/test_templatetags.py index 91bfdbb2..62c71b0c 100644 --- a/tests/test_templatetags.py +++ b/tests/test_templatetags.py @@ -1320,8 +1320,8 @@ class IterationFillTest(SimpleTestCase): component.registry.register("slot_in_a_loop", self.ComponentSimpleSlotInALoop) objects = [ - {"inner": ["OBJECT1_ITER1", "OBJECT2_ITER1"]}, - {"inner": ["OBJECT1_ITER2", "OBJECT2_ITER2"]}, + {"inner": ["ITER1_OBJ1", "ITER1_OBJ2"]}, + {"inner": ["ITER2_OBJ1", "ITER2_OBJ2"]}, ] template = Template( @@ -1343,10 +1343,10 @@ class IterationFillTest(SimpleTestCase): self.assertHTMLEqual( rendered, """ - OBJECT1_ITER1 - OBJECT2_ITER1 - OBJECT1_ITER2 - OBJECT2_ITER2 + ITER1_OBJ1 + ITER1_OBJ2 + ITER2_OBJ1 + ITER2_OBJ2 """, ) @@ -1354,8 +1354,8 @@ class IterationFillTest(SimpleTestCase): component.registry.register("slot_in_a_loop", self.ComponentSimpleSlotInALoop) objects = [ - {"inner": ["OBJECT1_ITER1", "OBJECT2_ITER1"]}, - {"inner": ["OBJECT1_ITER2", "OBJECT2_ITER2"]}, + {"inner": ["ITER1_OBJ1", "ITER1_OBJ2"]}, + {"inner": ["ITER2_OBJ1", "ITER2_OBJ2"]}, ] template = Template( @@ -1389,14 +1389,14 @@ class IterationFillTest(SimpleTestCase): """ OUTER_SCOPE_VARIABLE1 OUTER_SCOPE_VARIABLE2 - OBJECT1_ITER1 + ITER1_OBJ1 OUTER_SCOPE_VARIABLE2 - OBJECT2_ITER1 + ITER1_OBJ2 OUTER_SCOPE_VARIABLE1 OUTER_SCOPE_VARIABLE2 - OBJECT1_ITER2 + ITER2_OBJ1 OUTER_SCOPE_VARIABLE2 - OBJECT2_ITER2 + ITER2_OBJ2 """, ) @@ -1404,8 +1404,8 @@ class IterationFillTest(SimpleTestCase): component.registry.register("slot_in_a_loop", self.ComponentSimpleSlotInALoop) objects = [ - {"inner": ["OBJECT1_ITER1", "OBJECT2_ITER1"]}, - {"inner": ["OBJECT1_ITER2", "OBJECT2_ITER2"]}, + {"inner": ["ITER1_OBJ1", "ITER1_OBJ2"]}, + {"inner": ["ITER2_OBJ1", "ITER2_OBJ2"]}, ] template = Template( @@ -1427,10 +1427,10 @@ class IterationFillTest(SimpleTestCase): self.assertHTMLEqual( rendered, """ - OBJECT1_ITER1 default - OBJECT2_ITER1 default - OBJECT1_ITER2 default - OBJECT2_ITER2 default + ITER1_OBJ1 default + ITER1_OBJ2 default + ITER2_OBJ1 default + ITER2_OBJ2 default """, ) @@ -1440,8 +1440,8 @@ class IterationFillTest(SimpleTestCase): component.registry.register("slot_in_a_loop", self.ComponentSimpleSlotInALoop) objects = [ - {"inner": ["OBJECT1_ITER1", "OBJECT2_ITER1"]}, - {"inner": ["OBJECT1_ITER2", "OBJECT2_ITER2"]}, + {"inner": ["ITER1_OBJ1", "ITER1_OBJ2"]}, + {"inner": ["ITER2_OBJ1", "ITER2_OBJ2"]}, ] template = Template( @@ -1475,13 +1475,13 @@ class IterationFillTest(SimpleTestCase): """ OUTER_SCOPE_VARIABLE1 OUTER_SCOPE_VARIABLE2 - OBJECT1_ITER1 default + ITER1_OBJ1 default OUTER_SCOPE_VARIABLE2 - OBJECT2_ITER1 default + ITER1_OBJ2 default OUTER_SCOPE_VARIABLE1 OUTER_SCOPE_VARIABLE2 - OBJECT1_ITER2 default + ITER2_OBJ1 default OUTER_SCOPE_VARIABLE2 - OBJECT2_ITER2 default + ITER2_OBJ2 default """, ) From f86eeb25a9ca33553331742f99ba40ee3d634739 Mon Sep 17 00:00:00 2001 From: Juro Oravec Date: Tue, 16 Apr 2024 14:26:53 +0200 Subject: [PATCH 13/28] feat: add unique IDs to our nodes for easier debugging --- src/django_components/slots.py | 8 +++++- .../templatetags/component_tags.py | 28 +++++++++++++++---- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/src/django_components/slots.py b/src/django_components/slots.py index f75ae263..8b989333 100644 --- a/src/django_components/slots.py +++ b/src/django_components/slots.py @@ -87,18 +87,20 @@ class ComponentIdMixin: self._component_id = value -class SlotNode(Node, ComponentIdMixin): +class SlotNode(Node): def __init__( self, name: str, nodelist: NodeList, is_required: bool = False, is_default: bool = False, + node_id: Optional[str] = None, ): self.name = name self.nodelist = nodelist self.is_required = is_required self.is_default = is_default + self.node_id = node_id or gen_id() @property def active_flags(self) -> List[str]: @@ -167,7 +169,9 @@ class FillNode(Node, ComponentIdMixin): 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 @@ -214,9 +218,11 @@ class IfSlotFilledConditionBranchNode(_IfSlotFilledBranchNode, ComponentIdMixin) slot_name: str, nodelist: NodeList, is_positive: Union[bool, None] = True, + node_id: Optional[str] = None, ) -> None: self.slot_name = slot_name self.is_positive: Optional[bool] = is_positive + self.node_id = node_id or gen_id() super().__init__(nodelist) def evaluate(self, context: Context) -> bool: diff --git a/src/django_components/templatetags/component_tags.py b/src/django_components/templatetags/component_tags.py index d28621b9..aeb9d729 100644 --- a/src/django_components/templatetags/component_tags.py +++ b/src/django_components/templatetags/component_tags.py @@ -146,15 +146,22 @@ def do_slot(parser: Parser, token: Token) -> SlotNode: "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() + nodelist = parser.parse(parse_until=["endslot"]) parser.delete_first_token() - return SlotNode( + slot_node = SlotNode( slot_name, nodelist, is_required=is_required, is_default=is_default, + node_id=slot_id, ) + return slot_node + @register.tag("fill") def do_fill(parser: Parser, token: Token) -> FillNode: @@ -180,15 +187,23 @@ def do_fill(parser: Parser, token: Token) -> FillNode: alias_fexp = FilterExpression(alias, parser) else: 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() + nodelist = parser.parse(parse_until=["endfill"]) parser.delete_first_token() - return FillNode( + fill_node = FillNode( nodelist, name_fexp=FilterExpression(tgt_slot_name, tag), alias_fexp=alias_fexp, + node_id=fill_id, ) + return fill_node + @register.tag(name="component") def do_component(parser: Parser, token: Token) -> ComponentNode: @@ -208,14 +223,15 @@ def do_component(parser: Parser, token: Token) -> ComponentNode: bits = token.split_contents() bits, isolated_context = check_for_isolated_context_keyword(bits) 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() + body: NodeList = parser.parse(parse_until=["endcomponent"]) parser.delete_first_token() fill_nodes = parse_slot_fill_nodes_from_component_nodelist(body, ComponentNode) - # Use a unique ID to be able to tie the fill nodes with this specific component - # and its slots - component_id = gen_id() - # Tag all fill nodes as children of this particular component instance for node in fill_nodes: node.component_id = component_id From c1369ab2c7f44692520cdd8822300a31408889c3 Mon Sep 17 00:00:00 2001 From: Juro Oravec Date: Tue, 16 Apr 2024 14:30:00 +0200 Subject: [PATCH 14/28] feat: add tracing logger statements --- src/django_components/component.py | 15 ++++++--- src/django_components/logger.py | 32 +++++++++++++++++++ src/django_components/slots.py | 9 ++++-- .../templatetags/component_tags.py | 8 +++++ 4 files changed, 57 insertions(+), 7 deletions(-) diff --git a/src/django_components/component.py b/src/django_components/component.py index bc322c3b..1cdd6fc7 100644 --- a/src/django_components/component.py +++ b/src/django_components/component.py @@ -20,7 +20,7 @@ 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.logger import logger +from django_components.logger import logger, trace_msg from django_components.middleware import is_dependency_middleware_active from django_components.slots import ( FillContent, @@ -258,9 +258,11 @@ class Component(View, metaclass=SimplifiedInterfaceMediaDefiningClass): context = context_data if isinstance(context_data, Context) else Context(context_data) template = self.get_template(context) - # Associate the slots with this component + # 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) node.component_id = self.component_id walk_nodelist(template.nodelist, on_node) @@ -328,6 +330,8 @@ class ComponentNode(Node): ) def render(self, context: Context) -> str: + trace_msg("RENDR", "COMP", self.name_fexp, self.component_id) + resolved_component_name = self.name_fexp.resolve(context) component_cls: Type[Component] = registry.get(resolved_component_name) @@ -377,9 +381,12 @@ class ComponentNode(Node): rendered_component = component.render(context) 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: - 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: diff --git a/src/django_components/logger.py b/src/django_components/logger.py index dbb5f0e7..9c4d547d 100644 --- a/src/django_components/logger.py +++ b/src/django_components/logger.py @@ -1,3 +1,35 @@ import logging +from typing import Literal, Optional logger = logging.getLogger("django_components") + + +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: + """ + Log a tracing statement to `logger.debug` 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()` + logger.debug(full_msg) diff --git a/src/django_components/slots.py b/src/django_components/slots.py index 8b989333..db18c1c9 100644 --- a/src/django_components/slots.py +++ b/src/django_components/slots.py @@ -116,6 +116,7 @@ class SlotNode(Node): 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}'") + trace_msg("RENDR", "SLOT", self.name, self.node_id, component_id=component_id) extra_context = {} if slot_fill_content is None: @@ -133,7 +134,10 @@ class SlotNode(Node): used_ctx = self.resolve_slot_context(context) with used_ctx.update(extra_context): - return nodelist.render(used_ctx) + 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: """ @@ -417,8 +421,7 @@ def _prepare_component_template_filled_slot_context( 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((SlotNode, IfSlotFilledConditionBranchNode)): - if isinstance(node, (SlotNode, 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 diff --git a/src/django_components/templatetags/component_tags.py b/src/django_components/templatetags/component_tags.py index aeb9d729..018d5816 100644 --- a/src/django_components/templatetags/component_tags.py +++ b/src/django_components/templatetags/component_tags.py @@ -10,6 +10,7 @@ from django_components.app_settings import app_settings from django_components.component import RENDERED_COMMENT_TEMPLATE, ComponentNode from django_components.component_registry import ComponentRegistry from django_components.component_registry import registry as component_registry +from django_components.logger import trace_msg from django_components.middleware import ( CSS_DEPENDENCY_PLACEHOLDER, JS_DEPENDENCY_PLACEHOLDER, @@ -149,6 +150,7 @@ def do_slot(parser: Parser, token: Token) -> SlotNode: # 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"]) parser.delete_first_token() @@ -160,6 +162,7 @@ def do_slot(parser: Parser, token: Token) -> SlotNode: node_id=slot_id, ) + trace_msg("PARSE", "SLOT", slot_name, slot_id, "...Done!") return slot_node @@ -191,6 +194,7 @@ def do_fill(parser: Parser, token: Token) -> FillNode: # 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"]) parser.delete_first_token() @@ -202,6 +206,7 @@ def do_fill(parser: Parser, token: Token) -> FillNode: node_id=fill_id, ) + trace_msg("PARSE", "FILL", tgt_slot_name, fill_id, "...Done!") return fill_node @@ -227,6 +232,7 @@ def do_component(parser: Parser, token: Token) -> ComponentNode: # 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"]) parser.delete_first_token() @@ -234,6 +240,7 @@ def do_component(parser: Parser, token: Token) -> 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( @@ -245,6 +252,7 @@ def do_component(parser: Parser, token: Token) -> ComponentNode: component_id=component_id, ) + trace_msg("PARSE", "COMP", component_name, component_id, "...Done!") return component_node From ce5b5c40d87c9b8a941cb703be2b0ac1890e8883 Mon Sep 17 00:00:00 2001 From: Juro Oravec Date: Tue, 16 Apr 2024 14:31:51 +0200 Subject: [PATCH 15/28] 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}" From 089cda54c5adff8bda4717d7e998251da9087d00 Mon Sep 17 00:00:00 2001 From: Juro Oravec Date: Tue, 16 Apr 2024 14:37:04 +0200 Subject: [PATCH 16/28] refactor: fix tests --- tests/test_component.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_component.py b/tests/test_component.py index 06d52c31..3bc05264 100644 --- a/tests/test_component.py +++ b/tests/test_component.py @@ -208,7 +208,7 @@ class ComponentTest(SimpleTestCase): class SlottedComponent(component.Component): template_name = "slotted_template.html" - def get_context_data(self, name: str | None = None) -> component.Dict[str, component.Any]: + def get_context_data(self, name: component.Optional[str] = None) -> component.Dict[str, component.Any]: return { "name": name, } @@ -542,7 +542,7 @@ class SlotBehaviorTests(SimpleTestCase): class SlottedComponent(component.Component): template_name = "slotted_template.html" - def get_context_data(self, name: str | None = None) -> component.Dict[str, component.Any]: + def get_context_data(self, name: component.Optional[str] = None) -> component.Dict[str, component.Any]: return { "name": name, } From ab7f3e0cdb7daac6e03ba47d4f03673060a7b6e6 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 16 Apr 2024 12:37:59 +0000 Subject: [PATCH 17/28] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/django_components/component.py | 11 ++++++++--- src/django_components/context.py | 2 +- src/django_components/slots.py | 15 ++++++++------- .../templatetags/component_tags.py | 2 +- tests/test_component.py | 17 +++++++++-------- 5 files changed, 27 insertions(+), 20 deletions(-) diff --git a/src/django_components/component.py b/src/django_components/component.py index 5df37dfd..c419fb3f 100644 --- a/src/django_components/component.py +++ b/src/django_components/component.py @@ -20,19 +20,24 @@ 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.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.node import walk_nodelist from django_components.slots import ( + DEFAULT_SLOT_KEY, FillContent, FillNode, SlotName, SlotNode, render_component_template_with_slots, - DEFAULT_SLOT_KEY, ) -from django_components.utils import search, gen_id +from django_components.utils import gen_id, search RENDERED_COMMENT_TEMPLATE = "" diff --git a/src/django_components/context.py b/src/django_components/context.py index d486bcd2..1c3d0636 100644 --- a/src/django_components/context.py +++ b/src/django_components/context.py @@ -5,7 +5,7 @@ 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 typing import TYPE_CHECKING, Optional from django.template import Context diff --git a/src/django_components/slots.py b/src/django_components/slots.py index 58e60730..ddfe6cc7 100644 --- a/src/django_components/slots.py +++ b/src/django_components/slots.py @@ -9,15 +9,15 @@ else: from typing import TypeAlias from django.template import Context, Template -from django.template.base import FilterExpression, Node, NodeList, TextNode, Parser +from django.template.base import FilterExpression, Node, NodeList, Parser, TextNode from django.template.defaulttags import CommentNode 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.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" @@ -58,10 +58,11 @@ class UserSlotVar: class ComponentIdMixin: """ 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 @@ -369,7 +370,7 @@ def render_component_template_with_slots( NOTE: The nodes in the template are mutated in the process! """ - # ---- Prepare slot fills ---- + # ---- 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. @@ -411,7 +412,7 @@ def _collect_slot_fills_from_component_template( # Type check so the rest of the logic has type of `node` is inferred if not isinstance(node, SlotNode): continue - + slot_name = node.name if slot_name in slot_name2fill_content: raise TemplateSyntaxError( @@ -440,7 +441,7 @@ def _collect_slot_fills_from_component_template( content_data = named_fills_content.get(node.name) slot_name2fill_content[slot_name] = content_data - + # Check: Only component templates that include a 'default' slot # can be invoked with implicit filling. if default_fill_content and not default_slot_encountered: diff --git a/src/django_components/templatetags/component_tags.py b/src/django_components/templatetags/component_tags.py index 018d5816..aeeb79f9 100644 --- a/src/django_components/templatetags/component_tags.py +++ b/src/django_components/templatetags/component_tags.py @@ -190,7 +190,7 @@ def do_fill(parser: Parser, token: Token) -> FillNode: alias_fexp = FilterExpression(alias, parser) else: 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() diff --git a/tests/test_component.py b/tests/test_component.py index 3bc05264..eddb3e03 100644 --- a/tests/test_component.py +++ b/tests/test_component.py @@ -240,7 +240,7 @@ class ComponentTest(SimpleTestCase): ) # {{ name }} should be "Jannete" everywhere - rendered = self.template.render(Context({ "day": "Monday", "name": "Jannete" })) + rendered = self.template.render(Context({"day": "Monday", "name": "Jannete"})) self.assertHTMLEqual( rendered, """ @@ -537,6 +537,7 @@ class ComponentIsolationTests(SimpleTestCase): """, ) + class SlotBehaviorTests(SimpleTestCase): def setUp(self): class SlottedComponent(component.Component): @@ -578,7 +579,7 @@ class SlotBehaviorTests(SimpleTestCase): ) 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" })) + rendered = self.template.render(Context({"day": "Monday", "name": "Jannete"})) self.assertHTMLEqual( rendered, """ @@ -597,7 +598,7 @@ class SlotBehaviorTests(SimpleTestCase): ) # {{ name }} should be effectively the same as before, because overriden everywhere - rendered2 = self.template.render(Context({ "day": "Monday" })) + rendered2 = self.template.render(Context({"day": "Monday"})) self.assertHTMLEqual(rendered2, rendered) @override_settings( @@ -605,7 +606,7 @@ class SlotBehaviorTests(SimpleTestCase): ) def test_slot_context_isolated(self): # {{ name }} should be "Jannete" everywhere - rendered = self.template.render(Context({ "day": "Monday", "name": "Jannete" })) + rendered = self.template.render(Context({"day": "Monday", "name": "Jannete"})) self.assertHTMLEqual( rendered, """ @@ -624,7 +625,7 @@ class SlotBehaviorTests(SimpleTestCase): ) # {{ name }} should be empty everywhere - rendered2 = self.template.render(Context({ "day": "Monday" })) + rendered2 = self.template.render(Context({"day": "Monday"})) self.assertHTMLEqual( rendered2, """ @@ -644,12 +645,12 @@ class SlotBehaviorTests(SimpleTestCase): @override_settings( COMPONENTS={ - "slot_context_behavior": "prefer_root", + "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" })) + rendered = self.template.render(Context({"day": "Monday", "name": "Jannete"})) self.assertHTMLEqual( rendered, """ @@ -668,7 +669,7 @@ class SlotBehaviorTests(SimpleTestCase): ) # {{ name }} should be neither "Jannete" nor empty anywhere - rendered = self.template.render(Context({ "day": "Monday" })) + rendered = self.template.render(Context({"day": "Monday"})) self.assertHTMLEqual( rendered, """ From bb114b4002c93d91c6b84e617972508cb786140e Mon Sep 17 00:00:00 2001 From: Juro Oravec Date: Tue, 16 Apr 2024 14:41:33 +0200 Subject: [PATCH 18/28] refactor: fix linter errors --- src/django_components/app_settings.py | 6 +++--- src/django_components/slots.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/django_components/app_settings.py b/src/django_components/app_settings.py index 46f9949f..ba4db478 100644 --- a/src/django_components/app_settings.py +++ b/src/django_components/app_settings.py @@ -14,7 +14,7 @@ class SlotContextBehavior(str, Enum): """ 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 @@ -45,7 +45,7 @@ class SlotContextBehavior(str, Enum): 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 @@ -76,7 +76,7 @@ class SlotContextBehavior(str, Enum): 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 diff --git a/src/django_components/slots.py b/src/django_components/slots.py index ddfe6cc7..54b1e8c9 100644 --- a/src/django_components/slots.py +++ b/src/django_components/slots.py @@ -6,7 +6,7 @@ from typing import Dict, List, NamedTuple, Optional, Sequence, Set, Type, Union if sys.version_info[:2] < (3, 10): from typing_extensions import TypeAlias else: - from typing import TypeAlias + from typing import TypeAlias # noqa # TODO: Is this required? from django.template import Context, Template from django.template.base import FilterExpression, Node, NodeList, Parser, TextNode From 623a5fe36565314452f9f8e1f09393f72140da8f Mon Sep 17 00:00:00 2001 From: Juro Oravec Date: Tue, 16 Apr 2024 21:01:09 +0200 Subject: [PATCH 19/28] refactor: fix types for render and render_to_response --- src/django_components/component.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/django_components/component.py b/src/django_components/component.py index c419fb3f..5be4903d 100644 --- a/src/django_components/component.py +++ b/src/django_components/component.py @@ -261,7 +261,7 @@ class Component(View, metaclass=SimplifiedInterfaceMediaDefiningClass): def render( self, - context_data: Dict[str, Any], + context_data: Union[Dict[str, Any], Context], slots_data: Optional[Dict[SlotName, str]] = None, escape_slots_content: bool = True, ) -> str: @@ -288,7 +288,7 @@ class Component(View, metaclass=SimplifiedInterfaceMediaDefiningClass): def render_to_response( self, - context_data: Dict[str, Any], + context_data: Union[Dict[str, Any], Context], slots_data: Optional[Dict[SlotName, str]] = None, escape_slots_content: bool = True, *args: Any, From f28e5695f43fc11301e4289952211daa22796b45 Mon Sep 17 00:00:00 2001 From: Juro Oravec Date: Tue, 16 Apr 2024 21:21:54 +0200 Subject: [PATCH 20/28] docs: add settings info to README --- README.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/README.md b/README.md index de74f649..38730d81 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,8 @@ Read on to learn about the details! ## 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. 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 - variable is 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 Django components supports [logging with Django](https://docs.djangoproject.com/en/5.0/howto/logging/#logging-how-to). This can help with troubleshooting. From 1d0da559b467e64dd4f985174666283e0fa1ef31 Mon Sep 17 00:00:00 2001 From: Juro Oravec Date: Tue, 16 Apr 2024 23:06:30 +0200 Subject: [PATCH 21/28] refactor: remove typealias import --- src/django_components/slots.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/django_components/slots.py b/src/django_components/slots.py index 54b1e8c9..bb83516d 100644 --- a/src/django_components/slots.py +++ b/src/django_components/slots.py @@ -1,13 +1,7 @@ import difflib import json -import sys from typing import Dict, List, NamedTuple, Optional, Sequence, Set, Type, Union -if sys.version_info[:2] < (3, 10): - from typing_extensions import TypeAlias -else: - from typing import TypeAlias # noqa # TODO: Is this required? - from django.template import Context, Template from django.template.base import FilterExpression, Node, NodeList, Parser, TextNode from django.template.defaulttags import CommentNode From 3e75db59aefee6424ded444d656f0536d063c3fe Mon Sep 17 00:00:00 2001 From: Juro Oravec Date: Tue, 16 Apr 2024 23:09:12 +0200 Subject: [PATCH 22/28] refactor: replace Sequence with List --- src/django_components/component.py | 6 +++--- src/django_components/slots.py | 10 +++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/django_components/component.py b/src/django_components/component.py index 5be4903d..4debcc85 100644 --- a/src/django_components/component.py +++ b/src/django_components/component.py @@ -2,7 +2,7 @@ import inspect import os import sys from pathlib import Path -from typing import Any, ClassVar, Dict, List, Mapping, MutableMapping, Optional, Sequence, Tuple, Type, Union +from typing import Any, ClassVar, Dict, List, Mapping, MutableMapping, Optional, Tuple, Type, Union from django.core.exceptions import ImproperlyConfigured from django.forms.widgets import Media, MediaDefiningClass @@ -324,7 +324,7 @@ class ComponentNode(Node): context_args: List[FilterExpression], context_kwargs: Mapping[str, FilterExpression], isolated_context: bool = False, - fill_nodes: Sequence[FillNode] = (), + fill_nodes: Optional[List[FillNode]] = None, component_id: Optional[str] = None, ) -> None: self.component_id = component_id or gen_id() @@ -332,7 +332,7 @@ class ComponentNode(Node): self.context_args = context_args or [] self.context_kwargs = context_kwargs or {} self.isolated_context = isolated_context - self.fill_nodes = fill_nodes + self.fill_nodes = fill_nodes or [] self.nodelist = NodeList(fill_nodes) def __repr__(self) -> str: diff --git a/src/django_components/slots.py b/src/django_components/slots.py index bb83516d..a13bd219 100644 --- a/src/django_components/slots.py +++ b/src/django_components/slots.py @@ -1,6 +1,6 @@ import difflib import json -from typing import Dict, List, NamedTuple, Optional, Sequence, Set, Type, Union +from typing import Dict, List, NamedTuple, Optional, Set, Type, Union from django.template import Context, Template from django.template.base import FilterExpression, Node, NodeList, Parser, TextNode @@ -263,7 +263,7 @@ class IfSlotFilledNode(Node): def parse_slot_fill_nodes_from_component_nodelist( component_nodelist: NodeList, ComponentNodeCls: Type[Node], -) -> Sequence[FillNode]: +) -> List[FillNode]: """ Given a component body (`django.template.NodeList`), find all slot fills, whether defined explicitly with `{% fill %}` or implicitly. @@ -282,7 +282,7 @@ def parse_slot_fill_nodes_from_component_nodelist( Then this function returns the nodes (`django.template.Node`) for `fill "first_fill"` and `fill "second_fill"`. """ - fill_nodes: Sequence[FillNode] = [] + fill_nodes: List[FillNode] = [] if nodelist_has_content(component_nodelist): for parse_fn in ( _try_parse_as_default_fill, @@ -305,7 +305,7 @@ def parse_slot_fill_nodes_from_component_nodelist( def _try_parse_as_named_fill_tag_set( nodelist: NodeList, ComponentNodeCls: Type[Node], -) -> Sequence[FillNode]: +) -> List[FillNode]: result = [] seen_name_fexps: Set[FilterExpression] = set() for node in nodelist: @@ -329,7 +329,7 @@ def _try_parse_as_named_fill_tag_set( def _try_parse_as_default_fill( nodelist: NodeList, ComponentNodeCls: Type[Node], -) -> Sequence[FillNode]: +) -> List[FillNode]: nodes_stack: List[Node] = list(nodelist) while nodes_stack: node = nodes_stack.pop() From 091a692993692a601796c219b0028158d9506770 Mon Sep 17 00:00:00 2001 From: Juro Oravec Date: Tue, 16 Apr 2024 23:14:50 +0200 Subject: [PATCH 23/28] refactor: remove extra type: ignore --- src/django_components/component.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/django_components/component.py b/src/django_components/component.py index 4debcc85..46194f75 100644 --- a/src/django_components/component.py +++ b/src/django_components/component.py @@ -194,7 +194,7 @@ class Component(View, metaclass=SimplifiedInterfaceMediaDefiningClass): registered_name: Optional[str] = None, component_id: Optional[str] = None, outer_context: Optional[Context] = None, - fill_content: Dict[str, FillContent] = {}, # type: ignore + fill_content: Dict[str, FillContent] = {}, ): self.registered_name: Optional[str] = registered_name self.outer_context: Context = outer_context or Context() From 691b663ed5df929d686f346914bf5feee67f6ff0 Mon Sep 17 00:00:00 2001 From: Juro Oravec Date: Tue, 16 Apr 2024 23:15:22 +0200 Subject: [PATCH 24/28] refactor: use copy() instead of __copy__ --- src/django_components/context.py | 3 ++- src/django_components/slots.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/django_components/context.py b/src/django_components/context.py index 1c3d0636..c52c9be5 100644 --- a/src/django_components/context.py +++ b/src/django_components/context.py @@ -5,6 +5,7 @@ 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 @@ -84,7 +85,7 @@ def capture_root_context(context: Context) -> None: """ root_ctx_already_defined = _OUTER_CONTEXT_CONTEXT_KEY in context if not root_ctx_already_defined: - set_root_context(context, context.__copy__()) + set_root_context(context, copy(context)) def set_slot_component_association(context: Context, slot_id: str, component_id: str) -> None: diff --git a/src/django_components/slots.py b/src/django_components/slots.py index a13bd219..cdaa21d0 100644 --- a/src/django_components/slots.py +++ b/src/django_components/slots.py @@ -1,5 +1,6 @@ import difflib import json +from copy import copy from typing import Dict, List, NamedTuple, Optional, Set, Type, Union from django.template import Context, Template @@ -145,7 +146,7 @@ class SlotNode(Node): elif app_settings.SLOT_CONTEXT_BEHAVIOR == SlotContextBehavior.ISOLATED: return root_ctx elif app_settings.SLOT_CONTEXT_BEHAVIOR == SlotContextBehavior.PREFER_ROOT: - new_context: Context = context.__copy__() + new_context: Context = copy(context) new_context.update(root_ctx.flatten()) return new_context else: From 1b6cc7c1926f988336443d8dae2e14d3fadf5144 Mon Sep 17 00:00:00 2001 From: Juro Oravec Date: Tue, 16 Apr 2024 23:16:19 +0200 Subject: [PATCH 25/28] refactor: fix settings in sampleproject --- sampleproject/sampleproject/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sampleproject/sampleproject/settings.py b/sampleproject/sampleproject/settings.py index fc47858e..63cb37fa 100644 --- a/sampleproject/sampleproject/settings.py +++ b/sampleproject/sampleproject/settings.py @@ -85,7 +85,7 @@ WSGI_APPLICATION = "sampleproject.wsgi.application" # "libraries": [], # "template_cache_size": 128, # "context_behavior": "isolated", # "global" | "isolated" -# "slot_context_behavior": "allow_override", # "allow_override" | "prefer_root" | "isolated" +# "slot_context_behavior": "prefer_root", # "allow_override" | "prefer_root" | "isolated" # } From 8b8121bde2dd306e2eeed3e5d3ed159d0c666014 Mon Sep 17 00:00:00 2001 From: Juro Oravec Date: Tue, 16 Apr 2024 23:17:41 +0200 Subject: [PATCH 26/28] docs: fix typos --- README.md | 2 +- src/django_components/context.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 38730d81..9c940b84 100644 --- a/README.md +++ b/README.md @@ -716,7 +716,7 @@ except that, if variable is not found in the root, then the surrounding context You can change this with the `slot_contet_behavior` setting. Options are: - `"prefer_root"` - Default - as described above -- `"isolated"` - Same behavior as Vue - variable is taken ONLY from the root context +- `"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 diff --git a/src/django_components/context.py b/src/django_components/context.py index c52c9be5..f3d92662 100644 --- a/src/django_components/context.py +++ b/src/django_components/context.py @@ -1,5 +1,5 @@ """ -This file cetralizes various ways we use Django's Context class +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 bacd683c44e3e476097850be5c8f3967d25a275e Mon Sep 17 00:00:00 2001 From: Juro Oravec Date: Tue, 16 Apr 2024 23:21:37 +0200 Subject: [PATCH 27/28] refactor: fix type imports in tests --- tests/test_component.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_component.py b/tests/test_component.py index eddb3e03..0dbff29b 100644 --- a/tests/test_component.py +++ b/tests/test_component.py @@ -1,5 +1,6 @@ from pathlib import Path from textwrap import dedent +from typing import Any, Dict, Optional from django.core.exceptions import ImproperlyConfigured from django.template import Context, Template @@ -208,7 +209,7 @@ class ComponentTest(SimpleTestCase): class SlottedComponent(component.Component): template_name = "slotted_template.html" - def get_context_data(self, name: component.Optional[str] = None) -> component.Dict[str, component.Any]: + def get_context_data(self, name: Optional[str] = None) -> Dict[str, Any]: return { "name": name, } @@ -543,7 +544,7 @@ class SlotBehaviorTests(SimpleTestCase): class SlottedComponent(component.Component): template_name = "slotted_template.html" - def get_context_data(self, name: component.Optional[str] = None) -> component.Dict[str, component.Any]: + def get_context_data(self, name: Optional[str] = None) -> Dict[str, Any]: return { "name": name, } From 2c451693abd886cee180828513fead5726c51ad5 Mon Sep 17 00:00:00 2001 From: Juro Oravec Date: Wed, 17 Apr 2024 11:17:06 +0200 Subject: [PATCH 28/28] refactor: add TRACE log level --- src/django_components/logger.py | 64 +++++++++++++++++++++++++++++++-- 1 file changed, 61 insertions(+), 3 deletions(-) diff --git a/src/django_components/logger.py b/src/django_components/logger.py index 9c4d547d..4dfdb56d 100644 --- a/src/django_components/logger.py +++ b/src/django_components/logger.py @@ -1,7 +1,64 @@ import logging -from typing import Literal, Optional +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") +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( @@ -13,7 +70,8 @@ def trace_msg( component_id: Optional[str] = None, ) -> None: """ - Log a tracing statement to `logger.debug` like so: + 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"` """ @@ -32,4 +90,4 @@ def trace_msg( # NOTE: When debugging tests during development, it may be easier to change # this to `print()` - logger.debug(full_msg) + trace(logger, full_msg)