refecator: move defaults applying back to ext, raise on passing Slot to Slot, and docs cleanup (#1214)

* refecator: move defaults applying back to ext, raise on passing Slot to Slot, and docs cleanup

* docs: fix typo
This commit is contained in:
Juro Oravec 2025-05-26 11:59:17 +02:00 committed by GitHub
parent bae0f28813
commit 55b1c8bc62
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 317 additions and 147 deletions

View file

@ -56,7 +56,7 @@ from django_components.extension import (
extensions,
)
from django_components.extensions.cache import ComponentCache
from django_components.extensions.defaults import ComponentDefaults, apply_defaults, defaults_by_component
from django_components.extensions.defaults import ComponentDefaults
from django_components.extensions.view import ComponentView, ViewFn
from django_components.node import BaseNode
from django_components.perfutil.component import ComponentRenderer, component_context_cache, component_post_render
@ -2798,22 +2798,11 @@ class Component(metaclass=ComponentMeta):
render_id = _gen_component_id()
# Apply defaults to missing or `None` values in `kwargs`
defaults = defaults_by_component.get(comp_cls, None)
if defaults is not None:
apply_defaults(kwargs_dict, defaults)
# If user doesn't specify `Args`, `Kwargs`, `Slots` types, then we pass them in as plain
# dicts / lists.
args_inst = comp_cls.Args(*args_list) if comp_cls.Args is not None else args_list
kwargs_inst = comp_cls.Kwargs(**kwargs_dict) if comp_cls.Kwargs is not None else kwargs_dict
slots_inst = comp_cls.Slots(**slots_dict) if comp_cls.Slots is not None else slots_dict
component = comp_cls(
id=render_id,
args=args_inst,
kwargs=kwargs_inst,
slots=slots_inst,
args=args_list,
kwargs=kwargs_dict,
slots=slots_dict,
context=context,
request=request,
deps_strategy=deps_strategy,
@ -2841,6 +2830,12 @@ class Component(metaclass=ComponentMeta):
if result_override is not None:
return result_override
# If user doesn't specify `Args`, `Kwargs`, `Slots` types, then we pass them in as plain
# dicts / lists.
component.args = comp_cls.Args(*args_list) if comp_cls.Args is not None else args_list
component.kwargs = comp_cls.Kwargs(**kwargs_dict) if comp_cls.Kwargs is not None else kwargs_dict
component.slots = comp_cls.Slots(**slots_dict) if comp_cls.Slots is not None else slots_dict
######################################
# 2. Prepare component state
######################################

View file

@ -92,7 +92,7 @@ class OnComponentInputContext(NamedTuple):
"""List of positional arguments passed to the component"""
kwargs: Dict
"""Dictionary of keyword arguments passed to the component"""
slots: Dict
slots: Dict[str, "Slot"]
"""Dictionary of slot definitions"""
context: Context
"""The Django template Context object"""
@ -498,6 +498,19 @@ class ComponentExtension:
# Add extra kwarg to all components when they are rendered
ctx.kwargs["my_input"] = "my_value"
```
!!! warning
In this hook, the components' inputs are still mutable.
As such, if a component defines [`Args`](../api#django_components.Component.Args),
[`Kwargs`](../api#django_components.Component.Kwargs),
[`Slots`](../api#django_components.Component.Slots) types, these types are NOT yet instantiated.
Instead, component fields like [`Component.args`](../api#django_components.Component.args),
[`Component.kwargs`](../api#django_components.Component.kwargs),
[`Component.slots`](../api#django_components.Component.slots)
are plain `list` / `dict` objects.
"""
pass

View file

@ -3,7 +3,7 @@ from dataclasses import MISSING, Field, dataclass
from typing import TYPE_CHECKING, Any, Callable, Dict, List, NamedTuple, Optional, Type
from weakref import WeakKeyDictionary
from django_components.extension import ComponentExtension, OnComponentClassCreatedContext
from django_components.extension import ComponentExtension, OnComponentClassCreatedContext, OnComponentInputContext
if TYPE_CHECKING:
from django_components.component import Component
@ -99,7 +99,7 @@ def _extract_defaults(defaults: Optional[Type]) -> List[ComponentDefaultField]:
return defaults_fields
def apply_defaults(kwargs: Dict, defaults: List[ComponentDefaultField]) -> None:
def _apply_defaults(kwargs: Dict, defaults: List[ComponentDefaultField]) -> None:
"""
Apply the defaults from `Component.Defaults` to the given `kwargs`.
@ -171,3 +171,11 @@ class DefaultsExtension(ComponentExtension):
def on_component_class_created(self, ctx: OnComponentClassCreatedContext) -> None:
defaults_cls = getattr(ctx.component_cls, "Defaults", None)
defaults_by_component[ctx.component_cls] = _extract_defaults(defaults_cls)
# Apply defaults to missing or `None` values in `kwargs`
def on_component_input(self, ctx: OnComponentInputContext) -> None:
defaults = defaults_by_component.get(ctx.component_cls, None)
if defaults is None:
return
_apply_defaults(ctx.kwargs, defaults)

View file

@ -30,7 +30,7 @@ class ProvideNode(BaseNode):
Provide the "user_data" in parent component:
```python
```djc_py
@register("parent")
class Parent(Component):
template = \"\"\"
@ -50,7 +50,7 @@ class ProvideNode(BaseNode):
Since the "child" component is used within the `{% provide %} / {% endprovide %}` tags,
we can request the "user_data" using `Component.inject("user_data")`:
```python
```djc_py
@register("child")
class Child(Component):
template = \"\"\"

View file

@ -52,7 +52,23 @@ FILL_BODY_KWARG = "body"
# Public types
SlotResult = Union[str, SafeString]
"""Type representing the result of a slot render function."""
"""
Type representing the result of a slot render function.
**Example:**
```python
from django_components import SlotContext, SlotResult
def my_slot_fn(ctx: SlotContext) -> SlotResult:
return "Hello, world!"
my_slot = Slot(my_slot_fn)
html = my_slot() # Output: Hello, world!
```
Read more about [Slot functions](../../concepts/fundamentals/slots#slot-functions).
"""
@dataclass(frozen=True)
@ -65,9 +81,9 @@ class SlotContext(Generic[TSlotData]):
**Example:**
```python
from django_components import SlotContext
from django_components import SlotContext, SlotResult
def my_slot(ctx: SlotContext):
def my_slot(ctx: SlotContext) -> SlotResult:
return f"Hello, {ctx.data['name']}!"
```
@ -216,8 +232,6 @@ class Slot(Generic[TSlotData]):
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).
"""
@ -232,28 +246,30 @@ class Slot(Generic[TSlotData]):
# Following fields are only for debugging
component_name: Optional[str] = None
"""Name of the component that originally received this slot fill."""
"""
Name of the component that originally received this slot fill.
See [Slot metadata](../../concepts/fundamentals/slots#slot-metadata).
"""
slot_name: Optional[str] = None
"""Slot name to which this Slot was initially assigned."""
"""
Slot name to which this Slot was initially assigned.
See [Slot metadata](../../concepts/fundamentals/slots#slot-metadata).
"""
nodelist: Optional[NodeList] = None
"""
If the slot was defined with [`{% fill %}`](../template_tags#fill) tag,
this will be the Nodelist of the fill's content.
See [Slot metadata](../../concepts/fundamentals/slots#slot-metadata).
"""
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")
# Raise if Slot received another Slot instance as `contents`,
# because this leads to ambiguity about how to handle the metadata.
if isinstance(self.contents, Slot):
raise ValueError("Slot received another Slot instance as `contents`")
if self.content_func is None:
self.contents, new_nodelist, self.content_func = self._resolve_contents(self.contents)
@ -261,7 +277,7 @@ class Slot(Generic[TSlotData]):
self.nodelist = new_nodelist
if not callable(self.content_func):
raise ValueError(f"Slot content must be a callable, got: {self.content_func}")
raise ValueError(f"Slot 'content_func' must be a callable, got: {self.content_func}")
# Allow to treat the instances as functions
def __call__(
@ -374,20 +390,30 @@ SlotName = str
class SlotFallback:
"""
SlotFallback allows to treat a slot fallback as a variable. The slot is rendered only once
the instance is coerced to string.
The content between the `{% slot %}..{% endslot %}` tags is the *fallback* content that
will be rendered if no fill is given for the slot.
This is used to access slots as variables inside the templates. When a `SlotFallback`
is rendered in the template with `{{ my_lazy_slot }}`, it will output the contents
of the slot.
```django
{% slot "name" %}
Hello, my name is {{ name }} <!-- Fallback content -->
{% endslot %}
```
Usage in slot functions:
Because the fallback is defined as a piece of the template
([`NodeList`](https://github.com/django/django/blob/ddb85294159185c5bd5cae34c9ef735ff8409bfe/django/template/base.py#L1017)),
we want to lazily render it only when needed.
`SlotFallback` type allows to pass around the slot fallback as a variable.
To force the fallback to render, coerce it to string to trigger the `__str__()` method.
**Example:**
```py
def slot_function(self, ctx: SlotContext):
return f"Hello, {ctx.fallback}!"
```
"""
""" # noqa: E501
def __init__(self, slot: "SlotNode", context: Context):
self._slot = slot
@ -400,6 +426,9 @@ class SlotFallback:
# TODO_v1 - REMOVE - superseded by SlotFallback
SlotRef = SlotFallback
"""
DEPRECATED: Use [`SlotFallback`](../api#django_components.SlotFallback) instead. Will be removed in v1.
"""
name_escape_re = re.compile(r"[^\w]")
@ -457,7 +486,7 @@ class SlotNode(BaseNode):
**Example:**
```python
```djc_py
@register("child")
class Child(Component):
template = \"\"\"
@ -472,7 +501,7 @@ class SlotNode(BaseNode):
\"\"\"
```
```python
```djc_py
@register("parent")
class Parent(Component):
template = \"\"\"
@ -490,12 +519,14 @@ class SlotNode(BaseNode):
\"\"\"
```
### Passing data to slots
### Slot data
Any extra kwargs will be considered as slot data, and will be accessible in the [`{% fill %}`](#fill)
tag via fill's `data` kwarg:
```python
Read more about [Slot data](../../concepts/fundamentals/slots#slot-data).
```djc_py
@register("child")
class Child(Component):
template = \"\"\"
@ -508,7 +539,7 @@ class SlotNode(BaseNode):
\"\"\"
```
```python
```djc_py
@register("parent")
class Parent(Component):
template = \"\"\"
@ -523,7 +554,7 @@ class SlotNode(BaseNode):
\"\"\"
```
### Accessing fallback slot content
### Slot fallback
The content between the `{% slot %}..{% endslot %}` tags is the fallback content that
will be rendered if no fill is given for the slot.
@ -532,7 +563,7 @@ class SlotNode(BaseNode):
the fill's `fallback` kwarg.
This is useful if you need to wrap / prepend / append the original slot's content.
```python
```djc_py
@register("child")
class Child(Component):
template = \"\"\"
@ -544,7 +575,7 @@ class SlotNode(BaseNode):
\"\"\"
```
```python
```djc_py
@register("parent")
class Parent(Component):
template = \"\"\"
@ -910,21 +941,20 @@ class FillNode(BaseNode):
"""
Use this tag to insert content into component's slots.
`{% fill %}` tag may be used only within a `{% component %}..{% endcomponent %}` block.
Runtime checks should prohibit other usages.
`{% fill %}` tag may be used only within a `{% component %}..{% endcomponent %}` block,
and raises a `TemplateSyntaxError` if used outside of a component.
**Args:**
- `name` (str, required): Name of the slot to insert this content into. Use `"default"` for
the default slot.
- `fallback` (str, optional): This argument allows you to access the original content of the slot
under the specified variable name. See [Slot fallback](../../concepts/fundamentals/slots#slot-fallback).
- `data` (str, optional): This argument allows you to access the data passed to the slot
under the specified variable name. See [Slot data](../../concepts/fundamentals/slots#slot-data).
- `fallback` (str, optional): This argument allows you to access the original content of the slot
under the specified variable name. See [Slot fallback](../../concepts/fundamentals/slots#slot-fallback).
**Examples:**
**Example:**
Basic usage:
```django
{% component "my_table" %}
{% fill "pagination" %}
@ -933,7 +963,15 @@ class FillNode(BaseNode):
{% endcomponent %}
```
### Accessing slot's fallback content with the `fallback` kwarg
### Access slot fallback
Use the `fallback` kwarg to access the original content of the slot.
The `fallback` kwarg defines the name of the variable that will contain the slot's fallback content.
Read more about [Slot fallback](../../concepts/fundamentals/slots#slot-fallback).
Component template:
```django
{# my_table.html #}
@ -945,6 +983,8 @@ class FillNode(BaseNode):
</table>
```
Fill:
```django
{% component "my_table" %}
{% fill "pagination" fallback="fallback" %}
@ -955,7 +995,15 @@ class FillNode(BaseNode):
{% endcomponent %}
```
### Accessing slot's data with the `data` kwarg
### Access slot data
Use the `data` kwarg to access the data passed to the slot.
The `data` kwarg defines the name of the variable that will contain the slot's data.
Read more about [Slot data](../../concepts/fundamentals/slots#slot-data).
Component template:
```django
{# my_table.html #}
@ -967,6 +1015,8 @@ class FillNode(BaseNode):
</table>
```
Fill:
```django
{% component "my_table" %}
{% fill "pagination" data="slot_data" %}
@ -979,7 +1029,7 @@ class FillNode(BaseNode):
{% endcomponent %}
```
### Accessing slot data and fallback content on the default slot
### Using default slot
To access slot data and the fallback slot content on the default slot,
use `{% fill %}` with `name` set to `"default"`:
@ -993,7 +1043,7 @@ class FillNode(BaseNode):
{% endcomponent %}
```
### Passing slot fill from Python
### Slot fills from Python
You can pass a slot fill from Python to a component by setting the `body` kwarg
on the `{% fill %}` tag.
@ -1189,6 +1239,7 @@ class FillNode(BaseNode):
#######################################
# EXTRACTING {% fill %} FROM TEMPLATES
# (internal)
#######################################