feat: Expose slot input as Slot.contents (#1180)

* feat: expose slot input as Slot.contents

* refactor: fix linter errors
This commit is contained in:
Juro Oravec 2025-05-14 11:17:09 +02:00 committed by GitHub
parent 53d80684bb
commit 0d05ef4cb2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 576 additions and 222 deletions

View file

@ -2,6 +2,8 @@
## 🚨📢 v0.140.0
⚠️ Major release ⚠️ - Please test thoroughly before / after upgrading.
#### 🚨📢 BREAKING CHANGES
**Middleware**
@ -221,6 +223,28 @@
{% component "profile" name="John" job="Developer" / %}
```
**Slots**
- If you instantiated `Slot` class with kwargs, you should now use `contents` instead of `content_func`.
Before:
```py
slot = Slot(content_func=lambda *a, **kw: "CONTENT")
```
After:
```py
slot = Slot(contents=lambda *a, **kw: "CONTENT")
```
Alternatively, pass the function / content as first positional argument:
```py
slot = Slot(lambda *a, **kw: "CONTENT")
```
**Miscellaneous**
- The second argument to `render_dependencies()` is now `strategy` instead of `type`.
@ -436,6 +460,12 @@
Then, the `contents` attribute of the `BaseNode` instance will contain the string `"Hello, world!"`.
- `Slot` class now has a `Slot.contents` attribute, which contains the original contents:
- If `Slot` was created from `{% fill %}` tag, `Slot.contents` will contain the body of the `{% fill %}` tag.
- If `Slot` was created from string via `Slot("...")`, `Slot.contents` will contain that string.
- If `Slot` was created from a function, `Slot.contents` will contain that function.
#### Fix
- Fix bug: Context processors data was being generated anew for each component. Now the data is correctly created once and reused across components with the same request ([#1165](https://github.com/django-components/django-components/issues/1165)).

View file

@ -24,7 +24,7 @@ from weakref import ReferenceType, WeakValueDictionary, finalize
from django.core.exceptions import ImproperlyConfigured
from django.forms.widgets import Media as MediaCls
from django.http import HttpRequest, HttpResponse
from django.template.base import NodeList, Origin, Parser, Template, TextNode, Token
from django.template.base import NodeList, Origin, Parser, Template, Token
from django.template.context import Context, RequestContext
from django.template.loader import get_template
from django.template.loader_tags import BLOCK_CONTEXT_KEY, BlockContext
@ -74,7 +74,6 @@ from django_components.slots import (
SlotRef,
SlotResult,
_is_extracting_fill,
_nodelist_to_slot_render_func,
resolve_fills,
)
from django_components.template import cached_template
@ -2716,14 +2715,14 @@ class Component(metaclass=ComponentMeta):
# NOTE: `gen_escaped_content_func` is defined as a separate function, instead of being inlined within
# the forloop, because the value the forloop variable points to changes with each loop iteration.
def gen_escaped_content_func(content: SlotFunc, slot_name: str) -> Slot:
# Case: Already Slot, already escaped, and names tracing names assigned, so nothing to do.
# Case: Already Slot, already escaped, and names assigned, so nothing to do.
if isinstance(content, Slot) and content.escaped and content.slot_name and content.component_name:
return content
# Otherwise, we create a new instance of Slot, whether `content` was already Slot or not.
# This is so that user can potentially define a single `Slot` and use it in multiple components.
# so we can assign metadata to our internal copies.
if not isinstance(content, Slot) or not content.escaped:
# We wrap the original function so we post-process it by escaping the result.
def content_fn(ctx: Context, slot_data: Dict, slot_ref: SlotRef) -> SlotResult:
rendered = content(ctx, slot_data, slot_ref)
return conditional_escape(rendered) if escape_content else rendered
@ -2737,12 +2736,15 @@ class Component(metaclass=ComponentMeta):
used_component_name = content.component_name or self.name
used_slot_name = content.slot_name or slot_name
used_nodelist = content.nodelist
used_contents = content.contents if content.contents is not None else content_func
else:
used_component_name = self.name
used_slot_name = slot_name
used_nodelist = None
used_contents = content_func
slot = Slot(
contents=used_contents,
content_func=content_func,
component_name=used_component_name,
slot_name=used_slot_name,
@ -2753,16 +2755,17 @@ class Component(metaclass=ComponentMeta):
return slot
for slot_name, content in fills.items():
# Case: No content, so nothing to do.
if content is None:
continue
# Case: Content is a string / scalar
elif not callable(content):
slot = _nodelist_to_slot_render_func(
component_name=self.name,
slot_name=slot_name,
nodelist=NodeList([TextNode(conditional_escape(content) if escape_content else content)]),
data_var=None,
default_var=None,
escaped_content = conditional_escape(content) if escape_content else content
# NOTE: `Slot.content_func` and `Slot.nodelist` are set in `Slot.__init__()`
slot: Slot = Slot(
contents=escaped_content, component_name=self.name, slot_name=slot_name, escaped=True
)
# Case: Content is a callable, so either a plain function or a `Slot` instance.
else:
slot = gen_escaped_content_func(content, slot_name)
@ -2943,7 +2946,7 @@ class ComponentNode(BaseNode):
component_cls: Type[Component] = self.registry.get(self.name)
slot_fills = resolve_fills(context, self.nodelist, self.name)
slot_fills = resolve_fills(context, self, self.name)
component: Component = component_cls(
registered_name=self.name,

View file

@ -13,6 +13,7 @@ from typing import (
Optional,
Protocol,
Set,
Tuple,
TypeVar,
Union,
cast,
@ -34,17 +35,17 @@ from django_components.util.logger import trace_component_msg
from django_components.util.misc import get_index, get_last_index, is_identifier
if TYPE_CHECKING:
from django_components.component import ComponentContext
from django_components.component import ComponentContext, ComponentNode
TSlotData = TypeVar("TSlotData", bound=Mapping, contravariant=True)
DEFAULT_SLOT_KEY = "default"
FILL_GEN_CONTEXT_KEY = "_DJANGO_COMPONENTS_GEN_FILL"
SLOT_DATA_KWARG = "data"
SLOT_NAME_KWARG = "name"
SLOT_DEFAULT_KWARG = "default"
SLOT_REQUIRED_KEYWORD = "required"
SLOT_DEFAULT_KEYWORD = "default"
SLOT_REQUIRED_FLAG = "required"
SLOT_DEFAULT_FLAG = "default"
FILL_DATA_KWARG = "data"
FILL_DEFAULT_KWARG = "default"
# Public types
@ -60,7 +61,13 @@ class SlotFunc(Protocol, Generic[TSlotData]):
class Slot(Generic[TSlotData]):
"""This class holds the slot content function along with related metadata."""
content_func: SlotFunc[TSlotData]
contents: Any
"""
The original value that was passed to the `Slot` constructor.
If the slot was defined with `{% fill %}` tag, this will be the raw string contents of the slot.
"""
content_func: SlotFunc[TSlotData] = cast(SlotFunc[TSlotData], None)
escaped: bool = False
"""Whether the slot content has been escaped."""
@ -70,17 +77,30 @@ class Slot(Generic[TSlotData]):
slot_name: Optional[str] = None
"""Name of the slot that originally defined or accepted this slot fill."""
nodelist: Optional[NodeList] = None
"""Nodelist of the slot content."""
"""If the slot was defined with `{% fill %} tag, this will be the Nodelist of the slot content."""
def __post_init__(self) -> None:
# Since the `Slot` instance is treated as a function, it may be passed as `contents`
# to the `Slot()` constructor. In that case we need to unwrap to the original value
# if `Slot()` constructor got another Slot instance.
# NOTE: If `Slot` was passed as `contents`, we do NOT take the metadata from the inner Slot instance.
# Instead we treat is simply as a function.
# NOTE: Try to avoid infinite loop if `Slot.contents` points to itself.
seen_contents = set()
while isinstance(self.contents, Slot) and self.contents not in seen_contents:
seen_contents.add(id(self.contents))
self.contents = self.contents.contents
if id(self.contents) in seen_contents:
raise ValueError("Detected infinite loop in `Slot.contents` pointing to itself")
if self.content_func is None:
self.contents, new_nodelist, self.content_func = self._resolve_contents(self.contents)
if self.nodelist is None:
self.nodelist = new_nodelist
if not callable(self.content_func):
raise ValueError(f"Slot content must be a callable, got: {self.content_func}")
# Allow passing Slot instances as content functions
if isinstance(self.content_func, Slot):
inner_slot = self.content_func
self.content_func = inner_slot.content_func
# Allow to treat the instances as functions
def __call__(self, ctx: Context, slot_data: TSlotData, slot_ref: "SlotRef") -> SlotResult:
return self.content_func(ctx, slot_data, slot_ref)
@ -96,6 +116,22 @@ class Slot(Generic[TSlotData]):
slot_name = f"'{self.slot_name}'" if self.slot_name else None
return f"<{self.__class__.__name__} component_name={comp_name} slot_name={slot_name}>"
def _resolve_contents(self, contents: Any) -> Tuple[Any, NodeList, SlotFunc[TSlotData]]:
# Case: Content is a string / scalar, so we can use `TextNode` to render it.
if not callable(contents):
slot = _nodelist_to_slot(
component_name=self.component_name or "<Slot._resolve_contents>",
slot_name=self.slot_name,
nodelist=NodeList([TextNode(contents)]),
contents=contents,
data_var=None,
default_var=None,
)
return slot.contents, slot.nodelist, slot.content_func
# Otherwise, we're dealing with a function.
return contents, None, contents
# NOTE: This must be defined here, so we don't have any forward references
# otherwise Pydantic has problem resolving the types.
@ -313,7 +349,7 @@ class SlotNode(BaseNode):
tag = "slot"
end_tag = "endslot"
allowed_flags = [SLOT_DEFAULT_KEYWORD, SLOT_REQUIRED_KEYWORD]
allowed_flags = [SLOT_DEFAULT_FLAG, SLOT_REQUIRED_FLAG]
# NOTE:
# In the current implementation, the slots are resolved only at the render time.
@ -356,8 +392,8 @@ class SlotNode(BaseNode):
component_path = component_ctx.component_path
slot_fills = component_ctx.fills
slot_name = name
is_default = self.flags[SLOT_DEFAULT_KEYWORD]
is_required = self.flags[SLOT_REQUIRED_KEYWORD]
is_default = self.flags[SLOT_DEFAULT_FLAG]
is_required = self.flags[SLOT_REQUIRED_FLAG]
trace_component_msg(
"RENDER_SLOT_START",
@ -515,12 +551,15 @@ class SlotNode(BaseNode):
slot_fill = SlotFill(
name=slot_name,
is_filled=False,
slot=_nodelist_to_slot_render_func(
slot=_nodelist_to_slot(
component_name=component_name,
slot_name=slot_name,
nodelist=self.nodelist,
contents=self.contents,
data_var=None,
default_var=None,
# Escaped because this was defined in the template
escaped=True,
),
)
@ -752,28 +791,28 @@ class FillNode(BaseNode):
if data is not None:
if not isinstance(data, str):
raise TemplateSyntaxError(f"Fill tag '{SLOT_DATA_KWARG}' kwarg must resolve to a string, got {data}")
raise TemplateSyntaxError(f"Fill tag '{FILL_DATA_KWARG}' kwarg must resolve to a string, got {data}")
if not is_identifier(data):
raise RuntimeError(
f"Fill tag kwarg '{SLOT_DATA_KWARG}' does not resolve to a valid Python identifier, got '{data}'"
f"Fill tag kwarg '{FILL_DATA_KWARG}' does not resolve to a valid Python identifier, got '{data}'"
)
if default is not None:
if not isinstance(default, str):
raise TemplateSyntaxError(
f"Fill tag '{SLOT_DEFAULT_KWARG}' kwarg must resolve to a string, got {default}"
f"Fill tag '{FILL_DEFAULT_KWARG}' kwarg must resolve to a string, got {default}"
)
if not is_identifier(default):
raise RuntimeError(
f"Fill tag kwarg '{SLOT_DEFAULT_KWARG}' does not resolve to a valid Python identifier,"
f"Fill tag kwarg '{FILL_DEFAULT_KWARG}' does not resolve to a valid Python identifier,"
f" got '{default}'"
)
# data and default cannot be bound to the same variable
if data and default and data == default:
raise RuntimeError(
f"Fill '{name}' received the same string for slot default ({SLOT_DEFAULT_KWARG}=...)"
f" and slot data ({SLOT_DATA_KWARG}=...)"
f"Fill '{name}' received the same string for slot default ({FILL_DEFAULT_KWARG}=...)"
f" and slot data ({FILL_DATA_KWARG}=...)"
)
fill_data = FillWithData(
@ -873,7 +912,7 @@ class FillWithData(NamedTuple):
def resolve_fills(
context: Context,
nodelist: NodeList,
component_node: "ComponentNode",
component_name: str,
) -> Dict[SlotName, Slot]:
"""
@ -924,6 +963,9 @@ def resolve_fills(
"""
slots: Dict[SlotName, Slot] = {}
nodelist = component_node.nodelist
contents = component_node.contents
if not nodelist:
return slots
@ -941,12 +983,15 @@ def resolve_fills(
)
if not nodelist_is_empty:
slots[DEFAULT_SLOT_KEY] = _nodelist_to_slot_render_func(
slots[DEFAULT_SLOT_KEY] = _nodelist_to_slot(
component_name=component_name,
slot_name=None, # Will be populated later
nodelist=nodelist,
contents=contents,
data_var=None,
default_var=None,
# Escaped because this was defined in the template
escaped=True,
)
# The content has fills
@ -954,13 +999,16 @@ def resolve_fills(
# NOTE: If slot fills are explicitly defined, we use them even if they are empty (or only whitespace).
# This is different from the default slot, where we ignore empty content.
for fill in maybe_fills:
slots[fill.name] = _nodelist_to_slot_render_func(
slots[fill.name] = _nodelist_to_slot(
component_name=component_name,
slot_name=fill.name,
nodelist=fill.fill.nodelist,
contents=fill.fill.contents,
data_var=fill.data_var,
default_var=fill.default_var,
extra_context=fill.extra_context,
# Escaped because this was defined in the template
escaped=True,
)
return slots
@ -1026,12 +1074,14 @@ def _escape_slot_name(name: str) -> str:
return escaped_name
def _nodelist_to_slot_render_func(
def _nodelist_to_slot(
component_name: str,
slot_name: Optional[str],
nodelist: NodeList,
contents: Optional[str] = None,
data_var: Optional[str] = None,
default_var: Optional[str] = None,
escaped: bool = False,
extra_context: Optional[Dict[str, Any]] = None,
) -> Slot:
if data_var:
@ -1117,8 +1167,13 @@ def _nodelist_to_slot_render_func(
content_func=cast(SlotFunc, render_func),
component_name=component_name,
slot_name=slot_name,
escaped=False,
escaped=escaped,
nodelist=nodelist,
# The `contents` param passed to this function may be `None`, because it's taken from
# `BaseNode.contents` which is `None` for self-closing tags like `{% fill "footer" / %}`.
# But `Slot(contents=None)` would result in `Slot.contents` being the render function.
# So we need to special-case this.
contents=contents if contents is not None else "",
)

View file

@ -4,13 +4,13 @@ For tests focusing on the `component` tag, see `test_templatetags_component.py`
"""
import re
from typing import Dict, no_type_check
from typing import no_type_check
import pytest
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.http import HttpRequest, HttpResponse
from django.template import Context, RequestContext, Template, TemplateSyntaxError
from django.template import Context, RequestContext, Template
from django.template.base import TextNode
from django.test import Client
from django.urls import path
@ -24,7 +24,6 @@ from django_components import (
register,
types,
)
from django_components.slots import SlotRef
from django_components.urls import urlpatterns as dc_urlpatterns
from django_components.testing import djc_test
@ -283,6 +282,38 @@ class TestComponent:
""",
)
def test_get_component_by_id(self):
class SimpleComponent(Component):
pass
assert get_component_by_class_id(SimpleComponent.class_id) == SimpleComponent
def test_get_component_by_id_raises_on_missing_component(self):
with pytest.raises(KeyError):
get_component_by_class_id("nonexistent")
def test_get_context_data_returns_none(self):
class SimpleComponent(Component):
template = "Hello"
def get_template_data(self, args, kwargs, slots, context):
return None
assert SimpleComponent.render() == "Hello"
@djc_test
class TestComponentRenderAPI:
def test_component_render_id(self):
class SimpleComponent(Component):
template = "render_id: {{ render_id }}"
def get_template_data(self, args, kwargs, slots, context):
return {"render_id": self.id}
rendered = SimpleComponent.render()
assert rendered == "render_id: ca1bc3e"
def test_input(self):
class TestComponent(Component):
@no_type_check
@ -325,98 +356,6 @@ class TestComponent:
""",
)
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_prepends_exceptions_with_component_path(self, components_settings):
@register("broken")
class Broken(Component):
template: types.django_html = """
{% load component_tags %}
<div> injected: {{ data|safe }} </div>
<main>
{% slot "content" default / %}
</main>
"""
def get_template_data(self, args, kwargs, slots, context):
data = self.inject("my_provide")
data["data1"] # This should raise TypeError
return {"data": data}
@register("provider")
class Provider(Component):
def get_template_data(self, args, kwargs, slots, context):
return {"data": kwargs["data"]}
template: types.django_html = """
{% load component_tags %}
{% provide "my_provide" key="hi" data=data %}
{% slot "content" default / %}
{% endprovide %}
"""
@register("parent")
class Parent(Component):
def get_template_data(self, args, kwargs, slots, context):
return {"data": kwargs["data"]}
template: types.django_html = """
{% load component_tags %}
{% component "provider" data=data %}
{% component "broken" %}
{% slot "content" default / %}
{% endcomponent %}
{% endcomponent %}
"""
@register("root")
class Root(Component):
template: types.django_html = """
{% load component_tags %}
{% component "parent" data=123 %}
{% fill "content" %}
456
{% endfill %}
{% endcomponent %}
"""
with pytest.raises(
TypeError,
match=re.escape(
"An error occured while rendering components Root > parent > provider > provider(slot:content) > broken:\n" # noqa: E501
"tuple indices must be integers or slices, not str"
),
):
Root.render()
def test_get_component_by_id(self):
class SimpleComponent(Component):
pass
assert get_component_by_class_id(SimpleComponent.class_id) == SimpleComponent
def test_get_component_by_id_raises_on_missing_component(self):
with pytest.raises(KeyError):
get_component_by_class_id("nonexistent")
def test_component_render_id(self):
class SimpleComponent(Component):
template = "render_id: {{ render_id }}"
def get_template_data(self, args, kwargs, slots, context):
return {"render_id": self.id}
rendered = SimpleComponent.render()
assert rendered == "render_id: ca1bc3e"
def test_get_context_data_returns_none(self):
class SimpleComponent(Component):
template = "Hello"
def get_template_data(self, args, kwargs, slots, context):
return None
assert SimpleComponent.render() == "Hello"
@djc_test
class TestComponentRender:
@ -586,92 +525,6 @@ class TestComponentRender:
"HELLO",
)
@djc_test(
parametrize=(
["components_settings", "is_isolated"],
[
[{"context_behavior": "django"}, False],
[{"context_behavior": "isolated"}, True],
],
["django", "isolated"],
)
)
def test_render_slot_as_func(self, components_settings, is_isolated):
class SimpleComponent(Component):
template: types.django_html = """
{% load component_tags %}
{% slot "first" required data1="abc" data2:hello="world" data2:one=123 %}
SLOT_DEFAULT
{% endslot %}
"""
def get_template_data(self, args, kwargs, slots, context):
return {
"the_arg": args[0],
"the_kwarg": kwargs.pop("the_kwarg", None),
"kwargs": kwargs,
}
def first_slot(ctx: Context, slot_data: Dict, slot_ref: SlotRef):
assert isinstance(ctx, Context)
# NOTE: Since the slot has access to the Context object, it should behave
# the same way as it does in templates - when in "isolated" mode, then the
# slot fill has access only to the "root" context, but not to the data of
# get_template_data() of SimpleComponent.
if is_isolated:
assert ctx.get("the_arg") is None
assert ctx.get("the_kwarg") is None
assert ctx.get("kwargs") is None
assert ctx.get("abc") is None
else:
assert ctx["the_arg"] == "1"
assert ctx["the_kwarg"] == 3
assert ctx["kwargs"] == {}
assert ctx["abc"] == "def"
slot_data_expected = {
"data1": "abc",
"data2": {"hello": "world", "one": 123},
}
assert slot_data_expected == slot_data
assert isinstance(slot_ref, SlotRef)
assert "SLOT_DEFAULT" == str(slot_ref).strip()
return f"FROM_INSIDE_FIRST_SLOT | {slot_ref}"
rendered = SimpleComponent.render(
context={"abc": "def"},
args=["1"],
kwargs={"the_kwarg": 3},
slots={"first": first_slot},
)
assertHTMLEqual(
rendered,
"FROM_INSIDE_FIRST_SLOT | SLOT_DEFAULT",
)
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_render_raises_on_missing_slot(self, components_settings):
class SimpleComponent(Component):
template: types.django_html = """
{% load component_tags %}
{% slot "first" required %}
{% endslot %}
"""
with pytest.raises(
TemplateSyntaxError,
match=re.escape(
"Slot 'first' is marked as 'required' (i.e. non-optional), yet no fill is provided."
),
):
SimpleComponent.render()
SimpleComponent.render(
slots={"first": "FIRST_SLOT"},
)
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_render_with_include(self, components_settings):
class SimpleComponent(Component):
@ -921,6 +774,69 @@ class TestComponentRender:
"Variable: <strong data-djc-id-ca1bc3e>ca1bc3e</strong>",
)
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_prepends_exceptions_with_component_path(self, components_settings):
@register("broken")
class Broken(Component):
template: types.django_html = """
{% load component_tags %}
<div> injected: {{ data|safe }} </div>
<main>
{% slot "content" default / %}
</main>
"""
def get_template_data(self, args, kwargs, slots, context):
data = self.inject("my_provide")
data["data1"] # This should raise TypeError
return {"data": data}
@register("provider")
class Provider(Component):
def get_template_data(self, args, kwargs, slots, context):
return {"data": kwargs["data"]}
template: types.django_html = """
{% load component_tags %}
{% provide "my_provide" key="hi" data=data %}
{% slot "content" default / %}
{% endprovide %}
"""
@register("parent")
class Parent(Component):
def get_template_data(self, args, kwargs, slots, context):
return {"data": kwargs["data"]}
template: types.django_html = """
{% load component_tags %}
{% component "provider" data=data %}
{% component "broken" %}
{% slot "content" default / %}
{% endcomponent %}
{% endcomponent %}
"""
@register("root")
class Root(Component):
template: types.django_html = """
{% load component_tags %}
{% component "parent" data=123 %}
{% fill "content" %}
456
{% endfill %}
{% endcomponent %}
"""
with pytest.raises(
TypeError,
match=re.escape(
"An error occured while rendering components Root > parent > provider > provider(slot:content) > broken:\n" # noqa: E501
"tuple indices must be integers or slices, not str"
),
):
Root.render()
@djc_test
class TestComponentHook:

351
tests/test_slots.py Normal file
View file

@ -0,0 +1,351 @@
"""
Tests focusing on the Python part of slots.
For tests focusing on the `{% slot %}` tag, see `test_templatetags_slot_fill.py`
"""
import re
from typing import Dict
import pytest
from django.template import Context, Template, TemplateSyntaxError
from django.template.base import NodeList, TextNode
from pytest_django.asserts import assertHTMLEqual
from django_components import Component, register, types
from django_components.slots import Slot, SlotRef
from django_components.testing import djc_test
from .testutils import PARAMETRIZE_CONTEXT_BEHAVIOR, setup_test_config
setup_test_config({"autodiscover": False})
# Test interaction of the `Slot` instances with Component rendering
@djc_test
class TestSlot:
@djc_test(
parametrize=(
["components_settings", "is_isolated"],
[
[{"context_behavior": "django"}, False],
[{"context_behavior": "isolated"}, True],
],
["django", "isolated"],
)
)
def test_render_slot_as_func(self, components_settings, is_isolated):
class SimpleComponent(Component):
template: types.django_html = """
{% load component_tags %}
{% slot "first" required data1="abc" data2:hello="world" data2:one=123 %}
SLOT_DEFAULT
{% endslot %}
"""
def get_template_data(self, args, kwargs, slots, context):
return {
"the_arg": args[0],
"the_kwarg": kwargs.pop("the_kwarg", None),
"kwargs": kwargs,
}
def first_slot(ctx: Context, slot_data: Dict, slot_ref: SlotRef):
assert isinstance(ctx, Context)
# NOTE: Since the slot has access to the Context object, it should behave
# the same way as it does in templates - when in "isolated" mode, then the
# slot fill has access only to the "root" context, but not to the data of
# get_template_data() of SimpleComponent.
if is_isolated:
assert ctx.get("the_arg") is None
assert ctx.get("the_kwarg") is None
assert ctx.get("kwargs") is None
assert ctx.get("abc") is None
else:
assert ctx["the_arg"] == "1"
assert ctx["the_kwarg"] == 3
assert ctx["kwargs"] == {}
assert ctx["abc"] == "def"
slot_data_expected = {
"data1": "abc",
"data2": {"hello": "world", "one": 123},
}
assert slot_data_expected == slot_data
assert isinstance(slot_ref, SlotRef)
assert "SLOT_DEFAULT" == str(slot_ref).strip()
return f"FROM_INSIDE_FIRST_SLOT | {slot_ref}"
rendered = SimpleComponent.render(
context={"abc": "def"},
args=["1"],
kwargs={"the_kwarg": 3},
slots={"first": first_slot},
)
assertHTMLEqual(
rendered,
"FROM_INSIDE_FIRST_SLOT | SLOT_DEFAULT",
)
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_render_raises_on_missing_slot(self, components_settings):
class SimpleComponent(Component):
template: types.django_html = """
{% load component_tags %}
{% slot "first" required %}
{% endslot %}
"""
with pytest.raises(
TemplateSyntaxError,
match=re.escape(
"Slot 'first' is marked as 'required' (i.e. non-optional), yet no fill is provided."
),
):
SimpleComponent.render()
with pytest.raises(
TemplateSyntaxError,
match=re.escape(
"Slot 'first' is marked as 'required' (i.e. non-optional), yet no fill is provided."
),
):
SimpleComponent.render(
slots={"first": None},
)
SimpleComponent.render(
slots={"first": "FIRST_SLOT"},
)
# Part of the slot caching feature - test that static content slots reuse the slot function.
# See https://github.com/django-components/django-components/issues/1164#issuecomment-2854682354
def test_slots_reuse_functions__string(self):
captured_slots = {}
class SimpleComponent(Component):
template: types.django_html = """
{% load component_tags %}
{% slot "first" required %}
{% endslot %}
"""
def get_template_data(self, args, kwargs, slots, context):
nonlocal captured_slots
captured_slots = slots
SimpleComponent.render(
slots={"first": "FIRST_SLOT"},
)
first_slot_func = captured_slots["first"]
first_nodelist: NodeList = first_slot_func.nodelist
assert isinstance(first_slot_func, Slot)
assert first_slot_func.content_func is not None
assert first_slot_func.contents == "FIRST_SLOT"
assert len(first_nodelist) == 1
assert isinstance(first_nodelist[0], TextNode)
assert first_nodelist[0].s == "FIRST_SLOT"
captured_slots = {}
SimpleComponent.render(
slots={"first": "FIRST_SLOT"},
)
second_slot_func = captured_slots["first"]
second_nodelist: NodeList = second_slot_func.nodelist
assert isinstance(second_slot_func, Slot)
assert second_slot_func.content_func is not None
assert second_slot_func.contents == "FIRST_SLOT"
assert len(second_nodelist) == 1
assert isinstance(second_nodelist[0], TextNode)
assert second_nodelist[0].s == "FIRST_SLOT"
assert first_slot_func.contents == second_slot_func.contents
# Part of the slot caching feature - test that consistent functions passed as slots
# reuse the slot function.
def test_slots_reuse_functions__func(self):
captured_slots = {}
class SimpleComponent(Component):
template: types.django_html = """
{% load component_tags %}
{% slot "first" required %}
{% endslot %}
"""
def get_template_data(self, args, kwargs, slots, context):
nonlocal captured_slots
captured_slots = slots
slot_func = lambda ctx, slot_data, slot_ref: "FROM_INSIDE_SLOT" # noqa: E731
SimpleComponent.render(
slots={"first": slot_func},
)
first_slot_func = captured_slots["first"]
assert isinstance(first_slot_func, Slot)
assert callable(first_slot_func.content_func)
assert callable(first_slot_func.contents)
assert first_slot_func.nodelist is None
captured_slots = {}
SimpleComponent.render(
slots={"first": slot_func},
)
second_slot_func = captured_slots["first"]
assert isinstance(second_slot_func, Slot)
assert callable(second_slot_func.content_func)
assert callable(second_slot_func.contents)
assert second_slot_func.nodelist is None
# NOTE: Both are functions, but different, because internally we wrap the function
# to escape the results.
assert first_slot_func.contents is not second_slot_func.contents
# Part of the slot caching feature - test that `Slot` instances with identical function
# passed as slots reuse the slot function.
def test_slots_reuse_functions__slot(self):
captured_slots = {}
class SimpleComponent(Component):
template: types.django_html = """
{% load component_tags %}
{% slot "first" required %}
{% endslot %}
"""
def get_template_data(self, args, kwargs, slots, context):
nonlocal captured_slots
captured_slots = slots
slot_func = lambda ctx, slot_data, slot_ref: "FROM_INSIDE_SLOT" # noqa: E731
SimpleComponent.render(
slots={"first": Slot(slot_func)},
)
first_slot_func = captured_slots["first"]
assert isinstance(first_slot_func, Slot)
assert callable(first_slot_func.content_func)
assert callable(first_slot_func.contents)
assert first_slot_func.nodelist is None
captured_slots = {}
SimpleComponent.render(
slots={"first": Slot(slot_func)},
)
second_slot_func = captured_slots["first"]
assert isinstance(second_slot_func, Slot)
assert callable(second_slot_func.content_func)
assert callable(second_slot_func.contents)
assert second_slot_func.nodelist is None
assert first_slot_func.contents == second_slot_func.contents
# Part of the slot caching feature - test that identical slot fill content
# slots reuse the slot function.
def test_slots_reuse_functions__fill_tag_default(self):
captured_slots = {}
@register("test")
class SimpleComponent(Component):
template: types.django_html = """
{% load component_tags %}
{% slot "first" default %}
{% endslot %}
"""
def get_template_data(self, args, kwargs, slots, context):
nonlocal captured_slots
captured_slots = slots
template_str: types.django_html = """
{% load component_tags %}
{% component "test" %}
FROM_INSIDE_DEFAULT_SLOT
{% endcomponent %}
"""
template = Template(template_str)
template.render(Context())
first_slot_func = captured_slots["default"]
first_nodelist: NodeList = first_slot_func.nodelist
assert isinstance(first_slot_func, Slot)
assert callable(first_slot_func.content_func)
assert first_slot_func.contents == "\n FROM_INSIDE_DEFAULT_SLOT\n "
assert len(first_nodelist) == 1
assert isinstance(first_nodelist[0], TextNode)
assert first_nodelist[0].s == "\n FROM_INSIDE_DEFAULT_SLOT\n "
captured_slots = {}
template.render(Context())
second_slot_func = captured_slots["default"]
second_nodelist: NodeList = second_slot_func.nodelist
assert isinstance(second_slot_func, Slot)
assert callable(second_slot_func.content_func)
assert second_slot_func.contents == "\n FROM_INSIDE_DEFAULT_SLOT\n "
assert len(second_nodelist) == 1
assert isinstance(second_nodelist[0], TextNode)
assert second_nodelist[0].s == "\n FROM_INSIDE_DEFAULT_SLOT\n "
assert first_slot_func.contents == second_slot_func.contents
# Part of the slot caching feature - test that identical slot fill content
# slots reuse the slot function.
def test_slots_reuse_functions__fill_tag_named(self):
captured_slots = {}
@register("test")
class SimpleComponent(Component):
template: types.django_html = """
{% load component_tags %}
{% slot "first" default %}
{% endslot %}
"""
def get_template_data(self, args, kwargs, slots, context):
nonlocal captured_slots
captured_slots = slots
template_str: types.django_html = """
{% load component_tags %}
{% component "test" %}
{% fill "first" %}
FROM_INSIDE_NAMED_SLOT
{% endfill %}
{% endcomponent %}
"""
template = Template(template_str)
template.render(Context())
first_slot_func = captured_slots["first"]
first_nodelist: NodeList = first_slot_func.nodelist
assert isinstance(first_slot_func, Slot)
assert callable(first_slot_func.content_func)
assert first_slot_func.contents == "\n FROM_INSIDE_NAMED_SLOT\n "
assert len(first_nodelist) == 1
assert isinstance(first_nodelist[0], TextNode)
assert first_nodelist[0].s == "\n FROM_INSIDE_NAMED_SLOT\n "
captured_slots = {}
template.render(Context())
second_slot_func = captured_slots["first"]
second_nodelist: NodeList = second_slot_func.nodelist
assert isinstance(second_slot_func, Slot)
assert callable(second_slot_func.content_func)
assert second_slot_func.contents == "\n FROM_INSIDE_NAMED_SLOT\n "
assert len(second_nodelist) == 1
assert isinstance(second_nodelist[0], TextNode)
assert second_nodelist[0].s == "\n FROM_INSIDE_NAMED_SLOT\n "
assert first_slot_func.contents == second_slot_func.contents

View file

@ -2374,7 +2374,6 @@ class TestSlotInput:
def get_template_data(self, args, kwargs, slots, context):
nonlocal seen_slots
seen_slots = slots
return {}
assert seen_slots == {}
@ -2414,7 +2413,7 @@ class TestSlotInput:
assert seen_slots == {}
header_slot = Slot(lambda *a, **kw: "HEADER_SLOT")
header_slot: Slot = Slot(lambda *a, **kw: "HEADER_SLOT")
main_slot_str = "MAIN_SLOT"
footer_slot_fn = lambda *a, **kw: "FOOTER_SLOT" # noqa: E731