refactor: simplify slot API (#1202)

Closes #1096
This commit is contained in:
Juro Oravec 2025-05-20 09:48:45 +02:00 committed by GitHub
parent 7a49a7806c
commit f069255b64
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 404 additions and 72 deletions

View file

@ -236,15 +236,103 @@
After: After:
```py ```py
slot = Slot(contents=lambda *a, **kw: "CONTENT") slot = Slot(contents=lambda ctx: "CONTENT")
``` ```
Alternatively, pass the function / content as first positional argument: Alternatively, pass the function / content as first positional argument:
```py ```py
slot = Slot(lambda *a, **kw: "CONTENT") slot = Slot(lambda ctx: "CONTENT")
``` ```
- Slot functions behavior has changed. See the new [Slots](https://django-components.github.io/django-components/latest/concepts/fundamentals/slots/) docs for more info.
- Function signature:
1. All parameters are now passed under a single `ctx` argument.
You can still access all the same parameters via `ctx.context`, `ctx.data`, and `ctx.fallback`.
2. `context` and `fallback` now may be `None` if the slot function was called outside of `{% slot %}` tag.
Before:
```py
def slot_fn(context: Context, data: Dict, slot_ref: SlotRef):
isinstance(context, Context)
isinstance(data, Dict)
isinstance(slot_ref, SlotRef)
return "CONTENT"
```
After:
```py
def slot_fn(ctx: SlotContext):
assert isinstance(ctx.context, Context) # May be None
assert isinstance(ctx.data, Dict)
assert isinstance(ctx.fallback, SlotFallback) # May be None
return "CONTENT"
```
- Calling slot functions:
1. Rather than calling the slot functions directly, you should now call the `Slot` instances.
2. All parameters are now optional.
3. The order of parameters has changed.
Before:
```py
def slot_fn(context: Context, data: Dict, slot_ref: SlotRef):
return "CONTENT"
html = slot_fn(context, data, slot_ref)
```
After:
```py
def slot_fn(ctx: SlotContext):
return "CONTENT"
slot = Slot(slot_fn)
html = slot()
html = slot({"data1": "abc", "data2": "hello"})
html = slot({"data1": "abc", "data2": "hello"}, fallback="FALLBACK")
```
- Usage in components:
Before:
```python
class MyComponent(Component):
def get_context_data(self, *args, **kwargs):
slots = self.input.slots
slot_fn = slots["my_slot"]
html = slot_fn(context, data, slot_ref)
return {
"html": html,
}
```
After:
```python
class MyComponent(Component):
def get_template_data(self, args, kwargs, slots, context):
slot_fn = slots["my_slot"]
html = slot_fn(data)
return {
"html": html,
}
```
**Miscellaneous** **Miscellaneous**
- The second argument to `render_dependencies()` is now `strategy` instead of `type`. - The second argument to `render_dependencies()` is now `strategy` instead of `type`.

View file

@ -131,7 +131,7 @@ MyComponent.render(
```py ```py
MyComponent.render( MyComponent.render(
kwargs={"position": "left"}, kwargs={"position": "left"},
slots={"content": lambda *a, **kwa: "Hello, Alice"} slots={"content": lambda ctx: "Hello, Alice"}
) )
``` ```

View file

@ -513,7 +513,7 @@ Button.render(
age=30, age=30,
), ),
slots=Button.Slots( slots=Button.Slots(
footer=Slot(lambda *a, **kwa: "Click me!"), footer=Slot(lambda ctx: "Click me!"),
), ),
) )
``` ```

View file

@ -128,7 +128,7 @@ Button.render(
age=30, age=30,
), ),
slots=Button.Slots( slots=Button.Slots(
footer=Slot(lambda *a, **kwa: "Click me!"), footer=Slot(lambda ctx: "Click me!"),
), ),
) )
``` ```
@ -601,7 +601,7 @@ Button.render(
}, },
slots={ slots={
"my_slot": "...", "my_slot": "...",
"another_slot": Slot(lambda: ...), "another_slot": Slot(lambda ctx: ...),
}, },
) )
``` ```
@ -662,7 +662,7 @@ Button.render(
), ),
slots=Button.Slots( slots=Button.Slots(
my_slot="...", my_slot="...",
another_slot=Slot(lambda: ...), another_slot=Slot(lambda ctx: ...),
), ),
) )
``` ```

View file

@ -50,7 +50,16 @@ from django_components.extensions.defaults import ComponentDefaults, Default
from django_components.extensions.view import ComponentView, get_component_url from django_components.extensions.view import ComponentView, get_component_url
from django_components.library import TagProtectedError from django_components.library import TagProtectedError
from django_components.node import BaseNode, template_tag from django_components.node import BaseNode, template_tag
from django_components.slots import Slot, SlotContent, SlotFallback, SlotFunc, SlotInput, SlotRef, SlotResult from django_components.slots import (
Slot,
SlotContent,
SlotContext,
SlotFallback,
SlotFunc,
SlotInput,
SlotRef,
SlotResult,
)
from django_components.tag_formatter import ( from django_components.tag_formatter import (
ComponentFormatter, ComponentFormatter,
ShorthandComponentFormatter, ShorthandComponentFormatter,
@ -125,6 +134,7 @@ __all__ = [
"ShorthandComponentFormatter", "ShorthandComponentFormatter",
"Slot", "Slot",
"SlotContent", "SlotContent",
"SlotContext",
"SlotFallback", "SlotFallback",
"SlotFunc", "SlotFunc",
"SlotInput", "SlotInput",

View file

@ -458,7 +458,7 @@ class Component(metaclass=ComponentMeta):
Table.render( Table.render(
slots=Table.Slots( slots=Table.Slots(
header="HELLO IM HEADER", header="HELLO IM HEADER",
footer=Slot(lambda: ...), footer=Slot(lambda ctx: ...),
), ),
) )
``` ```
@ -2132,8 +2132,8 @@ class Component(metaclass=ComponentMeta):
Button.render( Button.render(
slots={ slots={
"content": "Click me!" "content": "Click me!"
"content2": lambda *a, **kwa: "Click me!", "content2": lambda ctx: "Click me!",
"content3": Slot(lambda *a, **kwa: "Click me!"), "content3": Slot(lambda ctx: "Click me!"),
}, },
) )
``` ```
@ -2260,7 +2260,7 @@ class Component(metaclass=ComponentMeta):
age=30, age=30,
), ),
slots=Button.Slots( slots=Button.Slots(
footer=Slot(lambda *a, **kwa: "Click me!"), footer=Slot(lambda ctx: "Click me!"),
), ),
) )
``` ```

View file

@ -38,7 +38,7 @@ from django_components.util.misc import get_index, get_last_index, is_identifier
if TYPE_CHECKING: if TYPE_CHECKING:
from django_components.component import ComponentContext, ComponentNode from django_components.component import ComponentContext, ComponentNode
TSlotData = TypeVar("TSlotData", bound=Mapping, contravariant=True) TSlotData = TypeVar("TSlotData", bound=Mapping)
DEFAULT_SLOT_KEY = "default" DEFAULT_SLOT_KEY = "default"
FILL_GEN_CONTEXT_KEY = "_DJANGO_COMPONENTS_GEN_FILL" FILL_GEN_CONTEXT_KEY = "_DJANGO_COMPONENTS_GEN_FILL"
@ -54,32 +54,193 @@ SlotResult = Union[str, SafeString]
"""Type representing the result of a slot render function.""" """Type representing the result of a slot render function."""
@dataclass(frozen=True)
class SlotContext(Generic[TSlotData]):
"""
Metadata available inside slot functions.
Read more about [Slot functions](../../concepts/fundamentals/slots#slot-class).
**Example:**
```python
from django_components import SlotContext
def my_slot(ctx: SlotContext):
return f"Hello, {ctx.data['name']}!"
```
You can pass a type parameter to the `SlotContext` to specify the type of the data passed to the slot:
```python
class MySlotData(TypedDict):
name: str
def my_slot(ctx: SlotContext[MySlotData]):
return f"Hello, {ctx.data['name']}!"
```
"""
data: TSlotData
"""
Data passed to the slot.
Read more about [Slot data](../../concepts/fundamentals/slots#slot-data).
**Example:**
```python
def my_slot(ctx: SlotContext):
return f"Hello, {ctx.data['name']}!"
```
"""
fallback: Optional[Union[str, "SlotFallback"]] = None
"""
Slot's fallback content. Lazily-rendered - coerce this value to string to force it to render.
Read more about [Slot fallback](../../concepts/fundamentals/slots#slot-fallback).
**Example:**
```python
def my_slot(ctx: SlotContext):
return f"Hello, {ctx.fallback}!"
```
May be `None` if you call the slot fill directly, without using [`{% slot %}`](../template_tags#slot) tags.
"""
context: Optional[Context] = None
"""
Django template [`Context`](https://docs.djangoproject.com/en/5.1/ref/templates/api/#django.template.Context)
available inside the [`{% fill %}`](../template_tags#fill) tag.
May be `None` if you call the slot fill directly, without using [`{% slot %}`](../template_tags#slot) tags.
"""
@runtime_checkable @runtime_checkable
class SlotFunc(Protocol, Generic[TSlotData]): class SlotFunc(Protocol, Generic[TSlotData]):
def __call__(self, ctx: Context, slot_data: TSlotData, slot_ref: "SlotFallback") -> SlotResult: ... # noqa E704 """
When rendering components with
[`Component.render()`](../api#django_components.Component.render)
or
[`Component.render_to_response()`](../api#django_components.Component.render_to_response),
the slots can be given either as strings or as functions.
If a slot is given as a function, it will have the signature of `SlotFunc`.
Read more about [Slot functions](../../concepts/fundamentals/slots#slot-functions).
Args:
ctx (SlotContext): Single named tuple that holds the slot data and metadata.
Returns:
(str | SafeString): The rendered slot content.
**Example:**
```python
from django_components import SlotContext, SlotResult
def header(ctx: SlotContext) -> SlotResult:
if ctx.data.get("name"):
return f"Hello, {ctx.data['name']}!"
else:
return ctx.fallback
html = MyTable.render(
slots={
"header": header,
},
)
```
"""
def __call__(self, ctx: SlotContext[TSlotData]) -> SlotResult: ... # noqa E704
@dataclass @dataclass
class Slot(Generic[TSlotData]): class Slot(Generic[TSlotData]):
"""This class holds the slot content function along with related metadata.""" """
This class is the main way for defining and handling slots.
It holds the slot content function along with related metadata.
Read more about [Slot class](../../concepts/fundamentals/slots#slot-class).
**Example:**
Passing slots to components:
```python
from django_components import Slot
slot = Slot(lambda ctx: f"Hello, {ctx.data['name']}!")
MyComponent.render(
slots={
"my_slot": slot,
},
)
```
Accessing slots inside the components:
```python
from django_components import Component
class MyComponent(Component):
def get_template_data(self, args, kwargs, slots, context):
my_slot = slots["my_slot"]
return {
"my_slot": my_slot,
}
```
Rendering slots:
```python
from django_components import Slot
slot = Slot(lambda ctx: f"Hello, {ctx.data['name']}!")
html = slot({"name": "John"}) # Output: Hello, John!
```
"""
contents: Any contents: Any
""" """
The original value that was passed to the `Slot` constructor. 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. - If Slot was created from [`{% fill %}`](../template_tags#fill) tag, `Slot.contents` will contain
the body (string) of that `{% fill %}` tag.
- If Slot was created from string as `Slot("...")`, `Slot.contents` will contain that string.
- If Slot was created from a function, `Slot.contents` will contain that function.
- If Slot was created from another `Slot` as `Slot(slot)`, `Slot.contents` will contain the inner
slot's `Slot.contents`.
Read more about [Slot contents](../../concepts/fundamentals/slots#slot-contents).
""" """
content_func: SlotFunc[TSlotData] = cast(SlotFunc[TSlotData], None) content_func: SlotFunc[TSlotData] = cast(SlotFunc[TSlotData], None)
"""
The actual slot function.
Do NOT call this function directly, instead call the `Slot` instance as a function.
Read more about [Rendering slot functions](../../concepts/fundamentals/slots#rendering-slots).
"""
escaped: bool = False escaped: bool = False
"""Whether the slot content has been escaped.""" """Whether the slot content has been escaped."""
# Following fields are only for debugging # Following fields are only for debugging
component_name: Optional[str] = None component_name: Optional[str] = None
"""Name of the component that originally defined or accepted this slot fill.""" """Name of the component that originally received this slot fill."""
slot_name: Optional[str] = None slot_name: Optional[str] = None
"""Name of the slot that originally defined or accepted this slot fill.""" """Slot name to which this Slot was initially assigned."""
nodelist: Optional[NodeList] = None nodelist: Optional[NodeList] = None
"""If the slot was defined with `{% fill %} tag, this will be the Nodelist of the slot content.""" """
If the slot was defined with [`{% fill %}`](../template_tags#fill) tag,
this will be the Nodelist of the fill's content.
"""
def __post_init__(self) -> None: def __post_init__(self) -> None:
# Since the `Slot` instance is treated as a function, it may be passed as `contents` # Since the `Slot` instance is treated as a function, it may be passed as `contents`
@ -104,13 +265,23 @@ class Slot(Generic[TSlotData]):
raise ValueError(f"Slot content must be a callable, got: {self.content_func}") raise ValueError(f"Slot content must be a callable, got: {self.content_func}")
# Allow to treat the instances as functions # Allow to treat the instances as functions
def __call__(self, ctx: Context, slot_data: TSlotData, slot_ref: "SlotFallback") -> SlotResult: def __call__(
return self.content_func(ctx, slot_data, slot_ref) self,
data: Optional[TSlotData] = None,
fallback: Optional[Union[str, "SlotFallback"]] = None,
context: Optional[Context] = None,
) -> SlotResult:
slot_ctx: SlotContext = SlotContext(context=context, data=data or {}, fallback=fallback)
return self.content_func(slot_ctx)
# Make Django pass the instances of this class within the templates without calling # Make Django pass the instances of this class within the templates without calling
# the instances as a function. # the instances as a function.
@property @property
def do_not_call_in_templates(self) -> bool: def do_not_call_in_templates(self) -> bool:
"""
Django special property to prevent calling the instance as a function
inside Django templates.
"""
return True return True
def __repr__(self) -> str: def __repr__(self) -> str:
@ -177,10 +348,10 @@ html = Table.render(
"header": mark_safe("<i><am><safe>"), "header": mark_safe("<i><am><safe>"),
# Function # Function
"footer": lambda ctx, slot_data, slot_ref: f"Page: {slot_data['page_number']}!", "footer": lambda ctx: f"Page: {ctx.data['page_number']}!",
# Slot instance # Slot instance
"footer": Slot(lambda ctx, slot_data, slot_ref: f"Page: {slot_data['page_number']}!"), "footer": Slot(lambda ctx: f"Page: {ctx.data['page_number']}!"),
# None (Same as no slot) # None (Same as no slot)
"header": None, "header": None,
@ -211,8 +382,8 @@ class SlotFallback:
Usage in slot functions: Usage in slot functions:
```py ```py
def slot_function(self, ctx: Context, slot_data: TSlotData, fallback: SlotFallback): def slot_function(self, ctx: SlotContext):
return f"Hello, {fallback}!" return f"Hello, {ctx.fallback}!"
``` ```
""" """
@ -637,7 +808,7 @@ class SlotNode(BaseNode):
if key.startswith(_INJECT_CONTEXT_KEY_PREFIX): if key.startswith(_INJECT_CONTEXT_KEY_PREFIX):
extra_context[key] = value extra_context[key] = value
slot_ref = SlotFallback(self, context) fallback = SlotFallback(self, context)
# For the user-provided slot fill, we want to use the context of where the slot # For the user-provided slot fill, we want to use the context of where the slot
# came from (or current context if configured so) # came from (or current context if configured so)
@ -658,7 +829,7 @@ class SlotNode(BaseNode):
# Render slot as a function # Render slot as a function
# NOTE: While `{% fill %}` tag has to opt in for the `fallback` and `data` variables, # NOTE: While `{% fill %}` tag has to opt in for the `fallback` and `data` variables,
# the render function ALWAYS receives them. # the render function ALWAYS receives them.
output = slot(used_ctx, kwargs, slot_ref) output = slot(data=kwargs, fallback=fallback, context=used_ctx)
if app_settings.DEBUG_HIGHLIGHT_SLOTS: if app_settings.DEBUG_HIGHLIGHT_SLOTS:
output = apply_component_highlight("slot", output, f"{component_name} - {slot_name}") output = apply_component_highlight("slot", output, f"{component_name} - {slot_name}")
@ -1119,7 +1290,7 @@ def normalize_slot_fills(
# NOTE: `gen_escaped_content_func` is defined as a separate function, instead of being inlined within # 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. # 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: def gen_escaped_content_func(content: Union[SlotFunc, Slot], slot_name: str) -> Slot:
# Case: Already Slot, already escaped, and 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: if isinstance(content, Slot) and content.escaped and content.slot_name and content.component_name:
return content return content
@ -1128,8 +1299,8 @@ def normalize_slot_fills(
# so we can assign metadata to our internal copies. # so we can assign metadata to our internal copies.
if not isinstance(content, Slot) or not content.escaped: if not isinstance(content, Slot) or not content.escaped:
# We wrap the original function so we post-process it by escaping the result. # We wrap the original function so we post-process it by escaping the result.
def content_fn(ctx: Context, slot_data: Dict, fallback: SlotFallback) -> SlotResult: def content_fn(ctx: SlotContext) -> SlotResult:
rendered = content(ctx, slot_data, fallback) rendered = content(ctx)
return conditional_escape(rendered) if escape_content else rendered return conditional_escape(rendered) if escape_content else rendered
content_func = cast(SlotFunc, content_fn) content_func = cast(SlotFunc, content_fn)
@ -1227,17 +1398,19 @@ def _nodelist_to_slot(
# This allows the template to access current RenderContext layer. # This allows the template to access current RenderContext layer.
template._djc_is_component_nested = True template._djc_is_component_nested = True
def render_func(ctx: Context, slot_data: Dict[str, Any], slot_ref: SlotFallback) -> SlotResult: def render_func(ctx: SlotContext) -> SlotResult:
context = ctx.context or Context()
# Expose the kwargs that were passed to the `{% slot %}` tag. These kwargs # Expose the kwargs that were passed to the `{% slot %}` tag. These kwargs
# are made available through a variable name that was set on the `{% fill %}` # are made available through a variable name that was set on the `{% fill %}`
# tag. # tag.
if data_var: if data_var:
ctx[data_var] = slot_data context[data_var] = ctx.data
# If slot fill is using `{% fill "myslot" fallback="abc" %}`, then set the "abc" to # If slot fill is using `{% fill "myslot" fallback="abc" %}`, then set the "abc" to
# the context, so users can refer to the fallback slot from within the fill content. # the context, so users can refer to the fallback slot from within the fill content.
if fallback_var: if fallback_var:
ctx[fallback_var] = slot_ref context[fallback_var] = ctx.fallback
# NOTE: If a `{% fill %}` tag inside a `{% component %}` tag is inside a forloop, # NOTE: If a `{% fill %}` tag inside a `{% component %}` tag is inside a forloop,
# the `extra_context` contains the forloop variables. We want to make these available # the `extra_context` contains the forloop variables. We want to make these available
@ -1260,7 +1433,7 @@ def _nodelist_to_slot(
# HOWEVER, the layer with `_COMPONENT_CONTEXT_KEY` also contains user-defined data from `get_template_data()`. # HOWEVER, the layer with `_COMPONENT_CONTEXT_KEY` also contains user-defined data from `get_template_data()`.
# Data from `get_template_data()` should take precedence over `extra_context`. So we have to insert # Data from `get_template_data()` should take precedence over `extra_context`. So we have to insert
# the forloop variables BEFORE that. # the forloop variables BEFORE that.
index_of_last_component_layer = get_last_index(ctx.dicts, lambda d: _COMPONENT_CONTEXT_KEY in d) index_of_last_component_layer = get_last_index(context.dicts, lambda d: _COMPONENT_CONTEXT_KEY in d)
if index_of_last_component_layer is None: if index_of_last_component_layer is None:
index_of_last_component_layer = 0 index_of_last_component_layer = 0
@ -1272,18 +1445,18 @@ def _nodelist_to_slot(
# Insert the `extra_context` layer BEFORE the layer that defines the variables from get_template_data. # Insert the `extra_context` layer BEFORE the layer that defines the variables from get_template_data.
# Thus, get_template_data will overshadow these on conflict. # Thus, get_template_data will overshadow these on conflict.
ctx.dicts.insert(index_of_last_component_layer, extra_context or {}) context.dicts.insert(index_of_last_component_layer, extra_context or {})
trace_component_msg("RENDER_NODELIST", component_name, component_id=None, slot_name=slot_name) trace_component_msg("RENDER_NODELIST", component_name, component_id=None, slot_name=slot_name)
# We wrap the slot nodelist in Template. However, we also override Django's `Template.render()` # We wrap the slot nodelist in Template. However, we also override Django's `Template.render()`
# to call `render_dependencies()` on the results. So we need to set the strategy to `ignore` # to call `render_dependencies()` on the results. So we need to set the strategy to `ignore`
# so that the dependencies are processed only once the whole component tree is rendered. # so that the dependencies are processed only once the whole component tree is rendered.
with ctx.push({"DJC_DEPS_STRATEGY": "ignore"}): with context.push({"DJC_DEPS_STRATEGY": "ignore"}):
rendered = template.render(ctx) rendered = template.render(context)
# After the rendering is done, remove the `extra_context` from the context stack # After the rendering is done, remove the `extra_context` from the context stack
ctx.dicts.pop(index_of_last_component_layer) context.dicts.pop(index_of_last_component_layer)
return rendered return rendered

View file

@ -322,7 +322,8 @@ class TestComponentRenderAPI:
assert self.input.kwargs == {"variable": "test", "another": 1} assert self.input.kwargs == {"variable": "test", "another": 1}
assert isinstance(self.input.context, Context) assert isinstance(self.input.context, Context)
assert list(self.input.slots.keys()) == ["my_slot"] assert list(self.input.slots.keys()) == ["my_slot"]
assert self.input.slots["my_slot"](Context(), None, None) == "MY_SLOT" my_slot = self.input.slots["my_slot"]
assert my_slot() == "MY_SLOT"
return { return {
"variable": kwargs["variable"], "variable": kwargs["variable"],
@ -334,7 +335,8 @@ class TestComponentRenderAPI:
assert self.input.kwargs == {"variable": "test", "another": 1} assert self.input.kwargs == {"variable": "test", "another": 1}
assert isinstance(self.input.context, Context) assert isinstance(self.input.context, Context)
assert list(self.input.slots.keys()) == ["my_slot"] assert list(self.input.slots.keys()) == ["my_slot"]
assert self.input.slots["my_slot"](Context(), None, None) == "MY_SLOT" my_slot = self.input.slots["my_slot"]
assert my_slot() == "MY_SLOT"
template_str: types.django_html = """ template_str: types.django_html = """
{% load component_tags %} {% load component_tags %}

View file

@ -346,5 +346,5 @@ class TestComponentCache:
): ):
TestComponent.render( TestComponent.render(
kwargs={"input": "cake"}, kwargs={"input": "cake"},
slots={"content": lambda *a, **kwa: "ONE"}, slots={"content": lambda ctx: "ONE"},
) )

View file

@ -76,7 +76,7 @@ class TestComponentTyping:
kwargs=Button.Kwargs(name="name", age=123), kwargs=Button.Kwargs(name="name", age=123),
slots=Button.Slots( slots=Button.Slots(
header="HEADER", header="HEADER",
footer=Slot(lambda ctx, slot_data, slot_ref: "FOOTER"), footer=Slot(lambda ctx: "FOOTER"),
), ),
) )
@ -124,7 +124,7 @@ class TestComponentTyping:
kwargs={"name": "name", "age": 123}, kwargs={"name": "name", "age": 123},
slots={ slots={
"header": "HEADER", "header": "HEADER",
"footer": Slot(lambda ctx, slot_data, slot_ref: "FOOTER"), "footer": Slot(lambda ctx: "FOOTER"),
}, },
) )
@ -206,7 +206,7 @@ class TestComponentTyping:
kwargs={"name": "name", "age": 123}, kwargs={"name": "name", "age": 123},
slots={ slots={
"header": "HEADER", "header": "HEADER",
"footer": Slot(lambda ctx, slot_data, slot_ref: "FOOTER"), "footer": Slot(lambda ctx: "FOOTER"),
}, },
) )
@ -314,7 +314,7 @@ class TestComponentTyping:
kwargs={"name": "name", "age": 123}, kwargs={"name": "name", "age": 123},
slots={ slots={
"header": "HEADER", "header": "HEADER",
"footer": Slot(lambda ctx, slot_data, slot_ref: "FOOTER"), "footer": Slot(lambda ctx: "FOOTER"),
}, },
) )
@ -397,7 +397,7 @@ class TestComponentTyping:
kwargs=Button.Kwargs(name="name", age=123), kwargs=Button.Kwargs(name="name", age=123),
slots=Button.Slots( slots=Button.Slots(
header="HEADER", header="HEADER",
footer=Slot(lambda ctx, slot_data, slot_ref: "FOOTER"), footer=Slot(lambda ctx: "FOOTER"),
), ),
) )
@ -412,7 +412,7 @@ class TestComponentTyping:
kwargs=Button.Kwargs(name="name", age=123), kwargs=Button.Kwargs(name="name", age=123),
slots=Button.Slots( slots=Button.Slots(
header="HEADER", header="HEADER",
footer=Slot(lambda ctx, slot_data, slot_ref: "FOOTER"), footer=Slot(lambda ctx: "FOOTER"),
), ),
) )
@ -427,7 +427,7 @@ class TestComponentTyping:
kwargs=Button.Kwargs(age=123), # type: ignore[call-arg] kwargs=Button.Kwargs(age=123), # type: ignore[call-arg]
slots=Button.Slots( slots=Button.Slots(
header="HEADER", header="HEADER",
footer=Slot(lambda ctx, slot_data, slot_ref: "FOOTER"), footer=Slot(lambda ctx: "FOOTER"),
), ),
) )
@ -438,7 +438,7 @@ class TestComponentTyping:
args=Button.Args(arg1="arg1", arg2="arg2"), args=Button.Args(arg1="arg1", arg2="arg2"),
kwargs=Button.Kwargs(name="name", age=123), kwargs=Button.Kwargs(name="name", age=123),
slots=Button.Slots( # type: ignore[typeddict-item] slots=Button.Slots( # type: ignore[typeddict-item]
footer=Slot(lambda ctx, slot_data, slot_ref: "FOOTER"), # Missing header footer=Slot(lambda ctx: "FOOTER"), # Missing header
), ),
) )

View file

@ -4,7 +4,6 @@ For tests focusing on the `{% slot %}` tag, see `test_templatetags_slot_fill.py`
""" """
import re import re
from typing import Dict
import pytest import pytest
from django.template import Context, Template, TemplateSyntaxError from django.template import Context, Template, TemplateSyntaxError
@ -12,7 +11,7 @@ from django.template.base import NodeList, TextNode
from pytest_django.asserts import assertHTMLEqual from pytest_django.asserts import assertHTMLEqual
from django_components import Component, register, types from django_components import Component, register, types
from django_components.slots import Slot, SlotFallback from django_components.slots import Slot, SlotContext, SlotFallback
from django_components.testing import djc_test from django_components.testing import djc_test
from .testutils import PARAMETRIZE_CONTEXT_BEHAVIOR, setup_test_config from .testutils import PARAMETRIZE_CONTEXT_BEHAVIOR, setup_test_config
@ -49,43 +48,44 @@ class TestSlot:
"kwargs": kwargs, "kwargs": kwargs,
} }
def first_slot(ctx: Context, slot_data: Dict, slot_ref: SlotFallback): def slot_fn(ctx: SlotContext):
assert isinstance(ctx, Context) context = ctx.context
assert isinstance(context, Context)
# NOTE: Since the slot has access to the Context object, it should behave # 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 # 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 # slot fill has access only to the "root" context, but not to the data of
# get_template_data() of SimpleComponent. # get_template_data() of SimpleComponent.
if is_isolated: if is_isolated:
assert ctx.get("the_arg") is None assert context.get("the_arg") is None
assert ctx.get("the_kwarg") is None assert context.get("the_kwarg") is None
assert ctx.get("kwargs") is None assert context.get("kwargs") is None
assert ctx.get("abc") is None assert context.get("abc") is None
else: else:
assert ctx["the_arg"] == "1" assert context["the_arg"] == "1"
assert ctx["the_kwarg"] == 3 assert context["the_kwarg"] == 3
assert ctx["kwargs"] == {} assert context["kwargs"] == {}
assert ctx["abc"] == "def" assert context["abc"] == "def"
slot_data_expected = { slot_data_expected = {
"data1": "abc", "data1": "abc",
"data2": {"hello": "world", "one": 123}, "data2": {"hello": "world", "one": 123},
} }
assert slot_data_expected == slot_data assert slot_data_expected == ctx.data
assert isinstance(slot_ref, SlotFallback) assert isinstance(ctx.fallback, SlotFallback)
assert "SLOT_DEFAULT" == str(slot_ref).strip() assert "SLOT_DEFAULT" == str(ctx.fallback).strip()
return f"FROM_INSIDE_FIRST_SLOT | {slot_ref}" return f"FROM_INSIDE_SLOT_FN | {ctx.fallback}"
rendered = SimpleComponent.render( rendered = SimpleComponent.render(
context={"abc": "def"}, context={"abc": "def"},
args=["1"], args=["1"],
kwargs={"the_kwarg": 3}, kwargs={"the_kwarg": 3},
slots={"first": first_slot}, slots={"first": slot_fn},
) )
assertHTMLEqual( assertHTMLEqual(
rendered, rendered,
"FROM_INSIDE_FIRST_SLOT | SLOT_DEFAULT", "FROM_INSIDE_SLOT_FN | SLOT_DEFAULT",
) )
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
@ -116,7 +116,66 @@ class TestSlot:
) )
SimpleComponent.render( SimpleComponent.render(
slots={"first": "FIRST_SLOT"}, slots={"first": "SLOT_FN"},
)
def test_render_slot_in_python__minimal(self):
def slot_fn(ctx: SlotContext):
assert ctx.context is None
assert ctx.data == {}
assert ctx.fallback is None
return "FROM_INSIDE_SLOT_FN"
slot: Slot = Slot(slot_fn)
rendered = slot()
assertHTMLEqual(
rendered,
"FROM_INSIDE_SLOT_FN",
)
def test_render_slot_in_python__with_data(self):
def slot_fn(ctx: SlotContext):
assert ctx.context is not None
assert ctx.context["the_arg"] == "1"
assert ctx.context["the_kwarg"] == 3
assert ctx.context["kwargs"] == {}
assert ctx.context["abc"] == "def"
slot_data_expected = {
"data1": "abc",
"data2": {"hello": "world", "one": 123},
}
assert slot_data_expected == ctx.data
assert isinstance(ctx.fallback, str)
assert "SLOT_DEFAULT" == ctx.fallback
return f"FROM_INSIDE_SLOT_FN | {ctx.fallback}"
slot: Slot = Slot(slot_fn)
context = Context({"the_arg": "1", "the_kwarg": 3, "kwargs": {}, "abc": "def"})
# Test positional arguments
rendered = slot(
{"data1": "abc", "data2": {"hello": "world", "one": 123}},
"SLOT_DEFAULT",
context,
)
assertHTMLEqual(
rendered,
"FROM_INSIDE_SLOT_FN | SLOT_DEFAULT",
)
# Test keyword arguments
rendered2 = slot(
data={"data1": "abc", "data2": {"hello": "world", "one": 123}},
fallback="SLOT_DEFAULT",
context=context,
)
assertHTMLEqual(
rendered2,
"FROM_INSIDE_SLOT_FN | SLOT_DEFAULT",
) )
# Part of the slot caching feature - test that static content slots reuse the slot function. # Part of the slot caching feature - test that static content slots reuse the slot function.
@ -180,7 +239,7 @@ class TestSlot:
nonlocal captured_slots nonlocal captured_slots
captured_slots = slots captured_slots = slots
slot_func = lambda ctx, slot_data, slot_ref: "FROM_INSIDE_SLOT" # noqa: E731 slot_func = lambda ctx: "FROM_INSIDE_SLOT" # noqa: E731
SimpleComponent.render( SimpleComponent.render(
slots={"first": slot_func}, slots={"first": slot_func},
@ -223,7 +282,7 @@ class TestSlot:
nonlocal captured_slots nonlocal captured_slots
captured_slots = slots captured_slots = slots
slot_func = lambda ctx, slot_data, slot_ref: "FROM_INSIDE_SLOT" # noqa: E731 slot_func = lambda ctx: "FROM_INSIDE_SLOT" # noqa: E731
SimpleComponent.render( SimpleComponent.render(
slots={"first": Slot(slot_func)}, slots={"first": Slot(slot_func)},

View file

@ -2440,9 +2440,9 @@ class TestSlotInput:
assert seen_slots == {} assert seen_slots == {}
header_slot: Slot = Slot(lambda *a, **kw: "HEADER_SLOT") header_slot: Slot = Slot(lambda ctx: "HEADER_SLOT")
main_slot_str = "MAIN_SLOT" main_slot_str = "MAIN_SLOT"
footer_slot_fn = lambda *a, **kw: "FOOTER_SLOT" # noqa: E731 footer_slot_fn = lambda ctx: "FOOTER_SLOT" # noqa: E731
SlottedComponent.render( SlottedComponent.render(
slots={ slots={