feat: Slot.extra and Slot.source metadata (#1221)

This commit is contained in:
Juro Oravec 2025-05-31 11:22:45 +02:00 committed by GitHub
parent bb129aefab
commit fa9ae9892f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 356 additions and 49 deletions

View file

@ -875,17 +875,27 @@ Summary:
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:
- `Slot` class now has 3 new metadata fields:
- 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.
1. `Slot.contents` attribute 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.
2. `Slot.extra` attribute where you can put arbitrary metadata about the slot.
3. `Slot.source` attribute tells where the slot comes from:
- `'template'` if the slot was created from `{% fill %}` tag.
- `'python'` if the slot was created from string, function, or `Slot` instance.
See [Slot metadata](https://django-components.github.io/django-components/0.140/concepts/fundamentals/slots/#slot-metadata).
- `{% fill %}` tag now accepts `body` kwarg to pass a Slot instance to fill.
First pass a [`Slot`](../api#django_components.Slot) instance to the template
with the [`get_template_data()`](../api#django_components.Component.get_template_data)
method:
First pass a `Slot` instance to the template
with the `get_template_data()` method:
```python
from django_components import component, Slot

View file

@ -453,6 +453,45 @@ class ColorLoggerExtension(ComponentExtension):
ComponentConfig = ColorLoggerComponentConfig
```
### Pass slot metadata
When a slot is passed to a component, it is copied, so that the original slot is not modified
with rendering metadata.
Therefore, don't use slot's identity to associate metadata with the slot:
```py
# ❌ Don't do this:
slots_cache = {}
class LoggerExtension(ComponentExtension):
name = "logger"
def on_component_input(self, ctx: OnComponentInputContext):
for slot in ctx.component.slots.values():
slots_cache[id(slot)] = {"some": "metadata"}
```
Instead, use the [`Slot.extra`](../../../reference/api#django_components.Slot.extra) attribute,
which is copied from the original slot:
```python
# ✅ Do this:
class LoggerExtension(ComponentExtension):
name = "logger"
# Save component-level logger settings for each slot when a component is rendered.
def on_component_input(self, ctx: OnComponentInputContext):
for slot in ctx.component.slots.values():
slot.extra["logger"] = ctx.component.logger
# Then, when a fill is rendered with `{% slot %}`, we can access the logger settings
# from the slot's metadata.
def on_slot_rendered(self, ctx: OnSlotRenderedContext):
logger = ctx.slot.extra["logger"]
logger.log(...)
```
## Extension commands
Extensions in django-components can define custom commands that can be executed via the Django management command interface. This allows for powerful automation and customization capabilities.

View file

@ -723,25 +723,52 @@ html = slot()
When accessing slots from within [`Component`](../../../reference/api#django_components.Component) methods,
the [`Slot`](../../../reference/api#django_components.Slot) instances are populated
with extra metadata [`component_name`](../../../reference/api#django_components.Slot.component_name),
[`slot_name`](../../../reference/api#django_components.Slot.slot_name), and
[`nodelist`](../../../reference/api#django_components.Slot.nodelist).
with extra metadata:
These are used for debugging, such as printing the path to the slot in the component tree.
- [`component_name`](../../../reference/api#django_components.Slot.component_name)
- [`slot_name`](../../../reference/api#django_components.Slot.slot_name)
- [`nodelist`](../../../reference/api#django_components.Slot.nodelist)
- [`source`](../../../reference/api#django_components.Slot.source)
- [`extra`](../../../reference/api#django_components.Slot.extra)
When you create a slot, you can set these fields too:
These are populated the first time a slot is passed to a component.
So if you pass the same slot through multiple nested components, the metadata will
still point to the first component that received the slot.
You can use these for debugging, such as printing out the slot's component name and slot name.
Extensions can use [`Slot.source`](../../../reference/api#django_components.Slot.source)
to handle slots differently based on whether the slot
was defined in the template with [`{% fill %}`](../../../reference/template_tags#fill) tag
or in the component's Python code. See an example in [Pass slot metadata](../../advanced/extensions#pass-slot-metadata).
You can also pass any additional data along with the slot by setting it in [`Slot.extra`](../../../reference/api#django_components.Slot.extra):
```py
slot = Slot(
lambda ctx: f"Hello, {ctx.data['name']}!",
extra={"foo": "bar"},
)
```
When you create a slot, you can set any of these fields too:
```py
# Either at slot creation
slot = Slot(
lambda ctx: f"Hello, {ctx.data['name']}!",
# Optional
component_name="table",
slot_name="name",
source="python",
extra={},
)
# Or later
slot.component_name = "table"
slot.slot_name = "name"
slot.extra["foo"] = "bar"
```
### Slot contents

View file

@ -1,6 +1,7 @@
import difflib
import re
from dataclasses import dataclass
from dataclasses import dataclass, field
from dataclasses import replace as dataclass_replace
from typing import (
TYPE_CHECKING,
Any,
@ -33,7 +34,7 @@ from django_components.node import BaseNode
from django_components.perfutil.component import component_context_cache
from django_components.util.exception import add_slot_to_error_message
from django_components.util.logger import trace_component_msg
from django_components.util.misc import get_index, get_last_index, is_identifier
from django_components.util.misc import default, get_index, get_last_index, is_identifier
if TYPE_CHECKING:
from django_components.component import Component, ComponentNode
@ -264,6 +265,34 @@ class Slot(Generic[TSlotData]):
See [Slot metadata](../../concepts/fundamentals/slots#slot-metadata).
"""
source: Literal["template", "python"] = "python"
"""
Whether the slot was created from a [`{% fill %}`](../template_tags#fill) tag (`'template'`),
or Python (`'python'`).
Extensions can use this info to handle slots differently based on their source.
See [Slot metadata](../../concepts/fundamentals/slots#slot-metadata).
"""
extra: Dict[str, Any] = field(default_factory=dict)
"""
Dictionary that can be used to store arbitrary metadata about the slot.
See [Slot metadata](../../concepts/fundamentals/slots#slot-metadata).
See [Pass slot metadata](../../concepts/advanced/extensions#pass-slot-metadata)
for usage for extensions.
**Example:**
```python
# Either at slot creation
slot = Slot(lambda ctx: "Hello, world!", extra={"foo": "bar"})
# Or later
slot.extra["baz"] = "qux"
```
"""
def __post_init__(self) -> None:
# Raise if Slot received another Slot instance as `contents`,
@ -1366,6 +1395,7 @@ def resolve_fills(
contents=contents,
data_var=None,
fallback_var=None,
source="template",
)
# The content has fills
@ -1375,7 +1405,14 @@ def resolve_fills(
for fill in maybe_fills:
# Case: Slot fill was explicitly defined as `{% fill body=... / %}`
if fill.body is not None:
slot_fill = fill.body if isinstance(fill.body, Slot) else Slot(fill.body)
if isinstance(fill.body, Slot):
# Make a copy of the Slot instance and set it to `source="template"`,
# so it behaves the same as if the content was written inside the `{% fill %}` tag.
# This for example allows CSS scoping to work even on slots that are defined
# as `{% fill ... body=... / %}`
slot_fill = dataclass_replace(fill.body, source="template")
else:
slot_fill = Slot(fill.body)
# Case: Slot fill was defined as the body of `{% fill / %}...{% endfill %}`
else:
slot_fill = _nodelist_to_slot(
@ -1386,6 +1423,7 @@ def resolve_fills(
data_var=fill.data_var,
fallback_var=fill.fallback_var,
extra_context=fill.extra_context,
source="template",
)
slots[fill.name] = slot_fill
@ -1459,11 +1497,15 @@ def normalize_slot_fills(
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
used_source = content.source
used_extra = content.extra.copy()
else:
used_component_name = component_name
used_slot_name = slot_name
used_nodelist = None
used_contents = content_func
used_source = "python"
used_extra = {}
slot = Slot(
contents=used_contents,
@ -1471,6 +1513,8 @@ def normalize_slot_fills(
component_name=used_component_name,
slot_name=used_slot_name,
nodelist=used_nodelist,
source=used_source,
extra=used_extra,
)
return slot
@ -1500,6 +1544,8 @@ def _nodelist_to_slot(
data_var: Optional[str] = None,
fallback_var: Optional[str] = None,
extra_context: Optional[Dict[str, Any]] = None,
source: Optional[Literal["template", "python"]] = None,
extra: Optional[Dict[str, Any]] = None,
) -> Slot:
if data_var:
if not data_var.isidentifier():
@ -1591,7 +1637,9 @@ def _nodelist_to_slot(
# `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 "",
contents=default(contents, ""),
source=default(source, "python"),
extra=default(extra, {}),
)

View file

@ -210,7 +210,7 @@ class TestSlot:
# 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):
def test_slots_same_contents__string(self):
captured_slots = {}
class SimpleComponent(Component):
@ -229,13 +229,9 @@ class TestSlot:
)
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(
@ -243,19 +239,15 @@ class TestSlot:
)
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):
def test_slots_same_contents__func(self):
captured_slots = {}
class SimpleComponent(Component):
@ -279,7 +271,6 @@ class TestSlot:
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(
@ -290,13 +281,12 @@ class TestSlot:
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 is 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):
def test_slots_same_contents__slot(self):
captured_slots = {}
class SimpleComponent(Component):
@ -320,7 +310,6 @@ class TestSlot:
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(
@ -331,13 +320,12 @@ class TestSlot:
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):
def test_slots_same_contents__fill_tag_default(self):
captured_slots = {}
@register("test")
@ -363,31 +351,23 @@ class TestSlot:
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):
def test_slots_same_contents__fill_tag_named(self):
captured_slots = {}
@register("test")
@ -415,28 +395,231 @@ class TestSlot:
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
def test_slot_metadata__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"]
assert isinstance(first_slot_func, Slot)
assert first_slot_func.component_name == "SimpleComponent"
assert first_slot_func.slot_name == "first"
assert first_slot_func.source == "python"
assert first_slot_func.extra == {}
first_nodelist: NodeList = first_slot_func.nodelist
assert len(first_nodelist) == 1
assert isinstance(first_nodelist[0], TextNode)
assert first_nodelist[0].s == "FIRST_SLOT"
# Part of the slot caching feature - test that consistent functions passed as slots
# reuse the slot function.
def test_slot_metadata__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: "FROM_INSIDE_SLOT" # noqa: E731
SimpleComponent.render(
slots={"first": slot_func},
)
first_slot_func = captured_slots["first"]
assert isinstance(first_slot_func, Slot)
assert first_slot_func.component_name == "SimpleComponent"
assert first_slot_func.slot_name == "first"
assert first_slot_func.source == "python"
assert first_slot_func.extra == {}
assert first_slot_func.nodelist is None
# Part of the slot caching feature - test that `Slot` instances with identical function
# passed as slots reuse the slot function.
def test_slot_metadata__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: "FROM_INSIDE_SLOT" # noqa: E731
SimpleComponent.render(
slots={"first": Slot(slot_func, extra={"foo": "bar"}, slot_name="whoop")},
)
first_slot_func = captured_slots["first"]
assert isinstance(first_slot_func, Slot)
assert first_slot_func.component_name == "SimpleComponent"
assert first_slot_func.slot_name == "whoop"
assert first_slot_func.source == "python"
assert first_slot_func.extra == {"foo": "bar"}
assert first_slot_func.nodelist is None
# Part of the slot caching feature - test that identical slot fill content
# slots reuse the slot function.
def test_slot_metadata__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"]
assert isinstance(first_slot_func, Slot)
assert first_slot_func.component_name == "test"
assert first_slot_func.slot_name == "default"
assert first_slot_func.source == "template"
assert first_slot_func.extra == {}
first_nodelist: NodeList = first_slot_func.nodelist
assert len(first_nodelist) == 1
assert isinstance(first_nodelist[0], TextNode)
assert first_nodelist[0].s == "\n FROM_INSIDE_DEFAULT_SLOT\n "
# Part of the slot caching feature - test that identical slot fill content
# slots reuse the slot function.
def test_slot_metadata__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"]
assert isinstance(first_slot_func, Slot)
assert first_slot_func.component_name == "test"
assert first_slot_func.slot_name == "first"
assert first_slot_func.source == "template"
assert first_slot_func.extra == {}
first_nodelist: NodeList = first_slot_func.nodelist
assert len(first_nodelist) == 1
assert isinstance(first_nodelist[0], TextNode)
assert first_nodelist[0].s == "\n FROM_INSIDE_NAMED_SLOT\n "
# Part of the slot caching feature - test that identical slot fill content
# slots reuse the slot function.
def test_slot_metadata__fill_tag_body(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" body=my_slot / %}
{% endcomponent %}
"""
template = Template(template_str)
template.render(
Context(
{
"my_slot": Slot(lambda ctx: "FROM_INSIDE_NAMED_SLOT", extra={"foo": "bar"}, slot_name="whoop"),
}
)
)
first_slot_func = captured_slots["first"]
assert isinstance(first_slot_func, Slot)
assert first_slot_func.component_name == "test"
assert first_slot_func.slot_name == "whoop"
assert first_slot_func.source == "template"
assert first_slot_func.extra == {"foo": "bar"}
assert first_slot_func.nodelist is None
def test_pass_body_to_fill__slot(self):
@register("test")
class SimpleComponent(Component):