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

@ -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