refactor: fix slot context behavior (#445)

This commit is contained in:
Juro Oravec 2024-04-23 21:35:45 +02:00 committed by GitHub
parent f3d6337ecc
commit 3ad0dd8677
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 287 additions and 80 deletions

View file

@ -20,12 +20,7 @@ from django.views import View
# way the two modules depend on one another. # way the two modules depend on one another.
from django_components.component_registry import registry # NOQA from django_components.component_registry import registry # NOQA
from django_components.component_registry import AlreadyRegistered, ComponentRegistry, NotRegistered, register # NOQA from django_components.component_registry import AlreadyRegistered, ComponentRegistry, NotRegistered, register # NOQA
from django_components.context import ( from django_components.context import make_isolated_context_copy, prepare_context, set_slot_component_association
capture_root_context,
get_root_context,
set_root_context,
set_slot_component_association,
)
from django_components.logger import logger, trace_msg from django_components.logger import logger, trace_msg
from django_components.middleware import is_dependency_middleware_active from django_components.middleware import is_dependency_middleware_active
from django_components.node import walk_nodelist from django_components.node import walk_nodelist
@ -268,6 +263,7 @@ class Component(View, metaclass=SimplifiedInterfaceMediaDefiningClass):
# NOTE: This if/else is important to avoid nested Contexts, # NOTE: This if/else is important to avoid nested Contexts,
# See https://github.com/EmilStenstrom/django-components/issues/414 # See https://github.com/EmilStenstrom/django-components/issues/414
context = context_data if isinstance(context_data, Context) else Context(context_data) context = context_data if isinstance(context_data, Context) else Context(context_data)
prepare_context(context, outer_context=self.outer_context or Context())
template = self.get_template(context) template = self.get_template(context)
# Associate the slots with this component for this context # Associate the slots with this component for this context
@ -347,10 +343,6 @@ class ComponentNode(Node):
resolved_component_name = self.name_fexp.resolve(context) resolved_component_name = self.name_fexp.resolve(context)
component_cls: Type[Component] = registry.get(resolved_component_name) 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.
capture_root_context(context)
# Resolve FilterExpressions and Variables that were passed as args to the # Resolve FilterExpressions and Variables that were passed as args to the
# component, then call component's context method # component, then call component's context method
# to get values to insert into the context # to get values to insert into the context
@ -380,12 +372,7 @@ class ComponentNode(Node):
# Prevent outer context from leaking into the template of the component # Prevent outer context from leaking into the template of the component
if self.isolated_context: if self.isolated_context:
# Even if contexts are isolated, we still need to pass down the context = make_isolated_context_copy(context)
# original context so variables in slots can be rendered using
# the original context.
root_ctx = get_root_context(context)
context = context.new()
set_root_context(context, root_ctx)
with context.update(component_context): with context.update(component_context):
rendered_component = component.render(context) rendered_component = component.render(context)

View file

@ -5,22 +5,65 @@ pass data across components, nodes, slots, and contexts.
You can think of the Context as our storage system. You can think of the Context as our storage system.
""" """
from copy import copy
from typing import TYPE_CHECKING, Optional from typing import TYPE_CHECKING, Optional
from django.template import Context from django.template import Context
from django_components.logger import trace_msg from django_components.logger import trace_msg
from django_components.utils import find_last_index
if TYPE_CHECKING: if TYPE_CHECKING:
from django_components.slots import FillContent from django_components.slots import FillContent
_FILLED_SLOTS_CONTENT_CONTEXT_KEY = "_DJANGO_COMPONENTS_FILLED_SLOTS" _FILLED_SLOTS_CONTENT_CONTEXT_KEY = "_DJANGO_COMPONENTS_FILLED_SLOTS"
_OUTER_CONTEXT_CONTEXT_KEY = "_DJANGO_COMPONENTS_OUTER_CONTEXT" _OUTER_ROOT_CTX_CONTEXT_KEY = "_DJANGO_COMPONENTS_OUTER_ROOT_CTX"
_SLOT_COMPONENT_ASSOC_KEY = "_DJANGO_COMPONENTS_SLOT_COMP_ASSOC" _SLOT_COMPONENT_ASSOC_KEY = "_DJANGO_COMPONENTS_SLOT_COMP_ASSOC"
def prepare_context(context: Context, outer_context: Optional[Context]) -> None:
"""Initialize the internal context state."""
# This is supposed to run ALWAYS at Component.render
if outer_context is not None:
set_outer_root_context(context, outer_context)
# Initialize mapping dicts within this rendering run.
# This is shared across the whole render chain, thus we set it only once.
if _SLOT_COMPONENT_ASSOC_KEY not in context:
context[_SLOT_COMPONENT_ASSOC_KEY] = {}
if _FILLED_SLOTS_CONTENT_CONTEXT_KEY not in context:
context[_FILLED_SLOTS_CONTENT_CONTEXT_KEY] = {}
# If we're inside a forloop, we need to make a disposable copy of slot -> comp
# mapping, which can be modified in the loop. We do so by copying it onto the latest
# context layer.
#
# This is necessary, because otherwise if we have a nested loop with a same
# component used recursively, the inner slot -> comp mapping would leak into the outer.
#
# NOTE: If you ever need to debug this, insert a print/debug statement into
# `django.template.defaulttags.ForNode.render` to inspect the context object
# inside the for loop.
if "forloop" in context:
context.dicts[-1][_SLOT_COMPONENT_ASSOC_KEY] = context[_SLOT_COMPONENT_ASSOC_KEY].copy()
def make_isolated_context_copy(context: Context) -> Context:
# Even if contexts are isolated, we still need to pass down the
# metadata so variables in slots can be rendered using the correct context.
root_ctx = get_outer_root_context(context)
slot_assoc = context.get(_SLOT_COMPONENT_ASSOC_KEY, {})
slot_fills = context.get(_FILLED_SLOTS_CONTENT_CONTEXT_KEY, {})
context_copy = context.new()
context_copy[_SLOT_COMPONENT_ASSOC_KEY] = slot_assoc
context_copy[_FILLED_SLOTS_CONTENT_CONTEXT_KEY] = slot_fills
set_outer_root_context(context_copy, root_ctx)
copy_forloop_context(context, context_copy)
return context_copy
def get_slot_fill(context: Context, component_id: str, slot_name: str) -> Optional["FillContent"]: 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. Use this function to obtain a slot fill from the current context.
@ -28,8 +71,8 @@ def get_slot_fill(context: Context, component_id: str, slot_name: str) -> Option
See `set_slot_fill` for more details. See `set_slot_fill` for more details.
""" """
trace_msg("GET", "FILL", slot_name, component_id) trace_msg("GET", "FILL", slot_name, component_id)
slot_key = (_FILLED_SLOTS_CONTENT_CONTEXT_KEY, component_id, slot_name) slot_key = f"{component_id}__{slot_name}"
return context.get(slot_key, None) return context[_FILLED_SLOTS_CONTENT_CONTEXT_KEY].get(slot_key, None)
def set_slot_fill(context: Context, component_id: str, slot_name: str, value: "FillContent") -> None: def set_slot_fill(context: Context, component_id: str, slot_name: str, value: "FillContent") -> None:
@ -38,77 +81,82 @@ def set_slot_fill(context: Context, component_id: str, slot_name: str, value: "F
Note that we make use of the fact that Django's Context is a stack - we can push and pop 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. 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) trace_msg("SET", "FILL", slot_name, component_id)
slot_key = (_FILLED_SLOTS_CONTENT_CONTEXT_KEY, component_id, slot_name) slot_key = f"{component_id}__{slot_name}"
context[slot_key] = value context[_FILLED_SLOTS_CONTENT_CONTEXT_KEY][slot_key] = value
def get_root_context(context: Context) -> Optional[Context]: def get_outer_root_context(context: Context) -> Optional[Context]:
""" """
Use this function to get the root context. Use this function to get the outer root context.
Root context is the top-most context, AKA the context that was passed to See `set_outer_root_context` for more details.
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) return context.get(_OUTER_ROOT_CTX_CONTEXT_KEY)
def set_root_context(context: Context, root_ctx: Context) -> None: def set_outer_root_context(context: Context, outer_ctx: Optional[Context]) -> None:
""" """
Use this function to set the root context. Use this function to set the outer root context.
Root context is the top-most context, AKA the context that was passed to When we consider a component's template, then outer context is the context
the initial `Template.render()`. that was available just outside of the component's template (AKA it was in
We pass through the root context to allow configure how slot fills should be rendered. the PARENT template).
See the `SLOT_CONTEXT_BEHAVIOR` setting. Once we have the outer context, next we get the outer ROOT context. This is
the context that was available at the top level of the PARENT template.
We pass through this context to allow to configure how slot fills should be
rendered using the `SLOT_CONTEXT_BEHAVIOR` setting.
""" """
context.push({_OUTER_CONTEXT_CONTEXT_KEY: root_ctx}) if outer_ctx and len(outer_ctx.dicts) > 1:
outer_root_context: Context = outer_ctx.new()
# NOTE_1:
# - Index 0 are the defaults set in BaseContext
# - Index 1 is the context generated by `Component.get_context_data`
# of the parent's component
# - All later indices (2, 3, ...) are extra layers added by the rendering
# logic (each Node usually adds it's own context layer)
outer_root_context.push(outer_ctx.dicts[1])
else:
outer_root_context = Context()
# Include the mappings.
if _SLOT_COMPONENT_ASSOC_KEY in context:
outer_root_context[_SLOT_COMPONENT_ASSOC_KEY] = context[_SLOT_COMPONENT_ASSOC_KEY]
if _FILLED_SLOTS_CONTENT_CONTEXT_KEY in context:
outer_root_context[_FILLED_SLOTS_CONTENT_CONTEXT_KEY] = context[_FILLED_SLOTS_CONTENT_CONTEXT_KEY]
context[_OUTER_ROOT_CTX_CONTEXT_KEY] = outer_root_context
def capture_root_context(context: Context) -> None: def set_slot_component_association(
""" context: Context,
Set the root context if it was not set before. slot_id: str,
component_id: str,
Root context is the top-most context, AKA the context that was passed to ) -> None:
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, copy(context))
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. 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. We use SlotNodes to render slot fills. SlotNodes are created only at Template
However, when we are using components with slots in (another) template, we can render parse time.
the same component multiple time. So we can have multiple FillNodes intended to be used However, when we refer to components with slots in (another) template (using
with the same SlotNode. `{% component %}`), 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 So how do we tell the SlotNode which FillNode to render? We do so by tagging
and FillNodes with a unique component_id, which ties them together. And then we tell SlotNode the ComponentNode and FillNodes with a unique component_id, which ties them
which component_id to use to be able to find the correct Component/Fill. 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 We don't want to store this info on the Nodes themselves, as we need to treat
immutable due to caching of Templates by Django. them as immutable due to caching of Templates by Django.
Hence, we use the Context to store the associations of SlotNode <-> Component for Hence, we use the Context to store the associations of SlotNode <-> Component
the current context stack. for the current context stack.
""" """
key = (_SLOT_COMPONENT_ASSOC_KEY, slot_id) context[_SLOT_COMPONENT_ASSOC_KEY][slot_id] = component_id
context[key] = component_id
def get_slot_component_association(context: Context, slot_id: str) -> str: def get_slot_component_association(context: Context, slot_id: str) -> str:
@ -118,5 +166,17 @@ def get_slot_component_association(context: Context, slot_id: str) -> str:
See `set_slot_component_association` for more details. See `set_slot_component_association` for more details.
""" """
key = (_SLOT_COMPONENT_ASSOC_KEY, slot_id) return context[_SLOT_COMPONENT_ASSOC_KEY][slot_id]
return context[key]
def copy_forloop_context(from_context: Context, to_context: Context) -> None:
"""Forward the info about the current loop"""
# Note that the ForNode (which implements for loop behavior) does not
# only add the `forloop` key, but also keys corresponding to the loop elements
# So if the loop syntax is `{% for my_val in my_lists %}`, then ForNode also
# sets a `my_val` key.
# For this reason, instead of copying individual keys, we copy the whole stack layer
# set by ForNode.
if "forloop" in from_context:
forloop_dict_index = find_last_index(from_context.dicts, lambda d: "forloop" in d)
to_context.update(from_context.dicts[forloop_dict_index])

View file

@ -10,7 +10,13 @@ from django.template.exceptions import TemplateSyntaxError
from django.utils.safestring import SafeString, mark_safe from django.utils.safestring import SafeString, mark_safe
from django_components.app_settings import SlotContextBehavior, app_settings from django_components.app_settings import SlotContextBehavior, app_settings
from django_components.context import get_root_context, get_slot_component_association, get_slot_fill, set_slot_fill from django_components.context import (
copy_forloop_context,
get_outer_root_context,
get_slot_component_association,
get_slot_fill,
set_slot_fill,
)
from django_components.logger import trace_msg from django_components.logger import trace_msg
from django_components.node import nodelist_has_content from django_components.node import nodelist_has_content
from django_components.utils import gen_id from django_components.utils import gen_id
@ -139,14 +145,16 @@ class SlotNode(Node):
See SlotContextBehavior for the description of each option. See SlotContextBehavior for the description of each option.
""" """
root_ctx = get_root_context(context) or Context() root_ctx = get_outer_root_context(context) or Context()
if app_settings.SLOT_CONTEXT_BEHAVIOR == SlotContextBehavior.ALLOW_OVERRIDE: if app_settings.SLOT_CONTEXT_BEHAVIOR == SlotContextBehavior.ALLOW_OVERRIDE:
return context return context
elif app_settings.SLOT_CONTEXT_BEHAVIOR == SlotContextBehavior.ISOLATED: elif app_settings.SLOT_CONTEXT_BEHAVIOR == SlotContextBehavior.ISOLATED:
return root_ctx new_context: Context = copy(root_ctx)
copy_forloop_context(context, new_context)
return new_context
elif app_settings.SLOT_CONTEXT_BEHAVIOR == SlotContextBehavior.PREFER_ROOT: elif app_settings.SLOT_CONTEXT_BEHAVIOR == SlotContextBehavior.PREFER_ROOT:
new_context: Context = copy(context) new_context = copy(context)
new_context.update(root_ctx.flatten()) new_context.update(root_ctx.flatten())
return new_context return new_context
else: else:
@ -483,8 +491,8 @@ def _report_slot_errors(
for fill_name in unmatched_fills: for fill_name in unmatched_fills:
fuzzy_slot_name_matches = difflib.get_close_matches(fill_name, unfilled_slots, n=1, cutoff=0.7) fuzzy_slot_name_matches = difflib.get_close_matches(fill_name, unfilled_slots, n=1, cutoff=0.7)
msg = ( msg = (
f"Component '{registered_name}' passed fill " f"Component '{registered_name}' passed fill that refers to undefined slot:"
f"that refers to undefined slot: '{fill_name}'." f" '{fill_name}'."
f"\nUnfilled slot names are: {sorted(unfilled_slots)}." f"\nUnfilled slot names are: {sorted(unfilled_slots)}."
) )
if fuzzy_slot_name_matches: if fuzzy_slot_name_matches:

View file

@ -1,6 +1,6 @@
import glob import glob
from pathlib import Path from pathlib import Path
from typing import List, NamedTuple, Optional from typing import Any, Callable, List, NamedTuple, Optional
from django.template.engine import Engine from django.template.engine import Engine
@ -49,3 +49,10 @@ def gen_id(length: int = 5) -> str:
# Pad the ID with `0`s up to 4 digits, e.g. `0007` # Pad the ID with `0`s up to 4 digits, e.g. `0007`
return f"{_id:04}" return f"{_id:04}"
def find_last_index(lst: List, predicate: Callable[[Any], bool]) -> Any:
for r_idx, elem in enumerate(reversed(lst)):
if predicate(elem):
return len(lst) - 1 - r_idx
return -1

View file

@ -1,8 +1,9 @@
import re import re
import textwrap import textwrap
from typing import Callable, Optional from typing import Any, Callable, Dict, Optional
from django.template import Context, Template, TemplateSyntaxError from django.template import Context, Template, TemplateSyntaxError
from django.test import override_settings
# isort: off # isort: off
from .django_test_setup import * # NOQA from .django_test_setup import * # NOQA
@ -85,6 +86,34 @@ class ComponentWithDefaultAndRequiredSlot(component.Component):
template_name = "template_with_default_and_required_slot.html" template_name = "template_with_default_and_required_slot.html"
class _ComplexChildComponent(component.Component):
template = """
{% load component_tags %}
<div>
{% slot "content" default %}
No slot!
{% endslot %}
</div>
"""
class _ComplexParentComponent(component.Component):
template = """
{% load component_tags %}
ITEMS: {{ items }}
{% for item in items %}
<li>
{% component "complex_child" %}
{{ item.value }}
{% endcomponent %}
</li>
{% endfor %}
"""
def get_context_data(self, items, *args, **kwargs) -> Dict[str, Any]:
return {"items": items}
class ComponentTemplateTagTest(BaseTestCase): class ComponentTemplateTagTest(BaseTestCase):
def setUp(self): def setUp(self):
# NOTE: component.registry is global, so need to clear before each test # NOTE: component.registry is global, so need to clear before each test
@ -1019,6 +1048,8 @@ class ComponentNestingTests(BaseTestCase):
super().setUpClass() super().setUpClass()
component.registry.register("dashboard", _DashboardComponent) component.registry.register("dashboard", _DashboardComponent)
component.registry.register("calendar", _CalendarComponent) component.registry.register("calendar", _CalendarComponent)
component.registry.register("complex_child", _ComplexChildComponent)
component.registry.register("complex_parent", _ComplexParentComponent)
@classmethod @classmethod
def tearDownClass(cls) -> None: def tearDownClass(cls) -> None:
@ -1052,6 +1083,100 @@ class ComponentNestingTests(BaseTestCase):
""" """
self.assertHTMLEqual(rendered, expected) self.assertHTMLEqual(rendered, expected)
@override_settings(
COMPONENTS={
"context_behavior": "isolated",
"slot_context_behavior": "isolated",
}
)
def test_component_nesting_slot_inside_component_fill_isolated(self):
template = Template(
"""
{% load component_tags %}
{% component "dashboard" %}{% endcomponent %}
"""
)
rendered = template.render(Context({"items": [1, 2, 3]}))
expected = """
<div class="dashboard-component">
<div class="calendar-component">
<h1>
Welcome to your dashboard!
</h1>
<main>
Here are your to-do items for today:
</main>
</div>
<ol>
</ol>
</div>
"""
self.assertHTMLEqual(rendered, expected)
@override_settings(
COMPONENTS={
"context_behavior": "isolated",
"slot_context_behavior": "isolated",
}
)
def test_component_nesting_slot_inside_component_fill_isolated_2(self):
template = Template(
"""
{% load component_tags %}
{% component "dashboard" %}
{% fill "header" %}
Whoa!
{% endfill %}
{% endcomponent %}
"""
)
rendered = template.render(Context({"items": [1, 2, 3]}))
expected = """
<div class="dashboard-component">
<div class="calendar-component">
<h1>
Whoa!
</h1>
<main>
Here are your to-do items for today:
</main>
</div>
<ol>
</ol>
</div>
"""
self.assertHTMLEqual(rendered, expected)
@override_settings(
COMPONENTS={
"context_behavior": "isolated",
"slot_context_behavior": "isolated",
}
)
def test_component_nesting_deep_slot_inside_component_fill_isolated(self):
template = Template(
"""
{% load component_tags %}
{% component "complex_parent" items=items %}{% endcomponent %}
"""
)
items = [{"value": 1}, {"value": 2}, {"value": 3}]
rendered = template.render(Context({"items": items}))
expected = """
ITEMS: [{&#x27;value&#x27;: 1}, {&#x27;value&#x27;: 2}, {&#x27;value&#x27;: 3}]
<li>
<div> 1 </div>
</li>
<li>
<div> 2 </div>
</li>
<li>
<div> 3 </div>
</li>
"""
self.assertHTMLEqual(rendered, expected)
def test_component_nesting_component_with_fill_and_super(self): def test_component_nesting_component_with_fill_and_super(self):
template = Template( template = Template(
""" """

View file

@ -1,6 +1,7 @@
from typing import List
from unittest.mock import Mock from unittest.mock import Mock
from django.template import Context from django.template import Context, Node
from django.template.response import TemplateResponse from django.template.response import TemplateResponse
from django.test import SimpleTestCase from django.test import SimpleTestCase
@ -32,3 +33,22 @@ def create_and_process_template_response(template, context=None, use_middleware=
else: else:
response.render() response.render()
return response.content.decode("utf-8") return response.content.decode("utf-8")
def print_nodes(nodes: List[Node], indent=0) -> None:
"""
Render a Nodelist, inlining child nodes with extra on separate lines and with
extra indentation.
"""
for node in nodes:
child_nodes: List[Node] = []
for attr in node.child_nodelists:
attr_child_nodes = getattr(node, attr, None) or []
if attr_child_nodes:
child_nodes.extend(attr_child_nodes)
repr = str(node)
repr = "\n".join([(" " * 4 * indent) + line for line in repr.split("\n")])
print(repr)
if child_nodes:
print_nodes(child_nodes, indent=indent + 1)