mirror of
https://github.com/django-components/django-components.git
synced 2025-08-01 12:42:16 +00:00
refactor: move kwargs resolution to render-time + cleanup (#594)
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
parent
83dcc3fe80
commit
899b9a2738
13 changed files with 448 additions and 371 deletions
24
README.md
24
README.md
|
@ -537,7 +537,7 @@ Component.render(
|
||||||
context: Mapping | django.template.Context | None = None,
|
context: Mapping | django.template.Context | None = None,
|
||||||
args: List[Any] | None = None,
|
args: List[Any] | None = None,
|
||||||
kwargs: Dict[str, Any] | None = None,
|
kwargs: Dict[str, Any] | None = None,
|
||||||
slots: Dict[str, str | SafeString | SlotRenderFunc] | None = None,
|
slots: Dict[str, str | SafeString | SlotFunc] | None = None,
|
||||||
escape_slots_content: bool = True
|
escape_slots_content: bool = True
|
||||||
) -> str:
|
) -> str:
|
||||||
```
|
```
|
||||||
|
@ -550,7 +550,7 @@ Component.render(
|
||||||
|
|
||||||
- _`slots`_ - Component slot fills. This is the same as pasing `{% fill %}` tags to the component.
|
- _`slots`_ - Component slot fills. This is the same as pasing `{% fill %}` tags to the component.
|
||||||
Accepts a dictionary of `{ slot_name: slot_content }` where `slot_content` can be a string
|
Accepts a dictionary of `{ slot_name: slot_content }` where `slot_content` can be a string
|
||||||
or [`SlotRenderFunc`](#slotrenderfunc).
|
or [`SlotFunc`](#slotfunc).
|
||||||
|
|
||||||
- _`escape_slots_content`_ - Whether the content from `slots` should be escaped. `True` by default to prevent XSS attacks. If you disable escaping, you should make sure that any content you pass to the slots is safe, especially if it comes from user input.
|
- _`escape_slots_content`_ - Whether the content from `slots` should be escaped. `True` by default to prevent XSS attacks. If you disable escaping, you should make sure that any content you pass to the slots is safe, especially if it comes from user input.
|
||||||
|
|
||||||
|
@ -559,7 +559,7 @@ Component.render(
|
||||||
- NOTE: In "isolated" mode, context is NOT accessible, and data MUST be passed via
|
- NOTE: In "isolated" mode, context is NOT accessible, and data MUST be passed via
|
||||||
component's args and kwargs.
|
component's args and kwargs.
|
||||||
|
|
||||||
#### `SlotRenderFunc`
|
#### `SlotFunc`
|
||||||
|
|
||||||
When rendering components with slots in `render` or `render_to_response`, you can pass either a string or a function.
|
When rendering components with slots in `render` or `render_to_response`, you can pass either a string or a function.
|
||||||
|
|
||||||
|
@ -602,24 +602,30 @@ that allow you to specify the types of args, kwargs, slots, and
|
||||||
data.
|
data.
|
||||||
|
|
||||||
```py
|
```py
|
||||||
from typing import NotRequired, Tuple, TypedDict
|
from typing import NotRequired, Tuple, TypedDict, SlotFunc
|
||||||
|
|
||||||
# Tuple
|
# Positional inputs - Tuple
|
||||||
Args = Tuple[int, str]
|
Args = Tuple[int, str]
|
||||||
|
|
||||||
# Mapping
|
# Kwargs inputs - Mapping
|
||||||
class Kwargs(TypedDict):
|
class Kwargs(TypedDict):
|
||||||
variable: str
|
variable: str
|
||||||
another: int
|
another: int
|
||||||
maybe_var: NotRequired[int]
|
maybe_var: NotRequired[int]
|
||||||
|
|
||||||
# Mapping
|
# Data returned from `get_context_data` - Mapping
|
||||||
class Data(TypedDict):
|
class Data(TypedDict):
|
||||||
variable: str
|
variable: str
|
||||||
|
|
||||||
# Mapping
|
# The data available to the `my_slot` scoped slot
|
||||||
|
class MySlotData(TypedDict):
|
||||||
|
value: int
|
||||||
|
|
||||||
|
# Slot functions - Mapping
|
||||||
class Slots(TypedDict):
|
class Slots(TypedDict):
|
||||||
my_slot: NotRequired[str]
|
# Use SlotFunc for slot functions.
|
||||||
|
# The generic specifies the `data` dictionary
|
||||||
|
my_slot: NotRequired[SlotFunc[MySlotData]]
|
||||||
|
|
||||||
class Button(Component[Args, Kwargs, Data, Slots]):
|
class Button(Component[Args, Kwargs, Data, Slots]):
|
||||||
def get_context_data(self, variable, another):
|
def get_context_data(self, variable, another):
|
||||||
|
|
|
@ -19,6 +19,10 @@ from django_components.component_registry import (
|
||||||
registry as registry,
|
registry as registry,
|
||||||
)
|
)
|
||||||
from django_components.library import TagProtectedError as TagProtectedError
|
from django_components.library import TagProtectedError as TagProtectedError
|
||||||
|
from django_components.slots import (
|
||||||
|
SlotContent as SlotContent,
|
||||||
|
SlotFunc as SlotFunc,
|
||||||
|
)
|
||||||
from django_components.tag_formatter import (
|
from django_components.tag_formatter import (
|
||||||
ComponentFormatter as ComponentFormatter,
|
ComponentFormatter as ComponentFormatter,
|
||||||
ShorthandComponentFormatter as ShorthandComponentFormatter,
|
ShorthandComponentFormatter as ShorthandComponentFormatter,
|
||||||
|
|
|
@ -4,37 +4,46 @@
|
||||||
|
|
||||||
from typing import Any, Dict, List, Mapping, Optional, Tuple
|
from typing import Any, Dict, List, Mapping, Optional, Tuple
|
||||||
|
|
||||||
from django.template import Context, Node
|
from django.template import Context
|
||||||
from django.utils.html import conditional_escape, format_html
|
from django.utils.html import conditional_escape, format_html
|
||||||
from django.utils.safestring import SafeString, mark_safe
|
from django.utils.safestring import SafeString, mark_safe
|
||||||
|
|
||||||
from django_components.expression import Expression, safe_resolve
|
from django_components.expression import RuntimeKwargPairs, RuntimeKwargs
|
||||||
|
from django_components.node import BaseNode
|
||||||
|
|
||||||
HTML_ATTRS_DEFAULTS_KEY = "defaults"
|
HTML_ATTRS_DEFAULTS_KEY = "defaults"
|
||||||
HTML_ATTRS_ATTRS_KEY = "attrs"
|
HTML_ATTRS_ATTRS_KEY = "attrs"
|
||||||
|
|
||||||
|
|
||||||
class HtmlAttrsNode(Node):
|
class HtmlAttrsNode(BaseNode):
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
attributes: Optional[Expression],
|
kwargs: RuntimeKwargs,
|
||||||
defaults: Optional[Expression],
|
kwarg_pairs: RuntimeKwargPairs,
|
||||||
kwargs: List[Tuple[str, Expression]],
|
node_id: Optional[str] = None,
|
||||||
):
|
):
|
||||||
self.attributes = attributes
|
super().__init__(nodelist=None, args=None, kwargs=kwargs, node_id=node_id)
|
||||||
self.defaults = defaults
|
self.kwarg_pairs = kwarg_pairs
|
||||||
self.kwargs = kwargs
|
|
||||||
|
|
||||||
def render(self, context: Context) -> str:
|
def render(self, context: Context) -> str:
|
||||||
append_attrs: List[Tuple[str, Any]] = []
|
append_attrs: List[Tuple[str, Any]] = []
|
||||||
|
|
||||||
# Resolve all data
|
# Resolve all data
|
||||||
for key, value in self.kwargs:
|
kwargs = self.kwargs.resolve(context)
|
||||||
resolved_value = safe_resolve(value, context)
|
attrs = kwargs.pop(HTML_ATTRS_ATTRS_KEY, None) or {}
|
||||||
append_attrs.append((key, resolved_value))
|
defaults = kwargs.pop(HTML_ATTRS_DEFAULTS_KEY, None) or {}
|
||||||
|
|
||||||
defaults = safe_resolve(self.defaults, context) or {} if self.defaults else {}
|
kwarg_pairs = self.kwarg_pairs.resolve(context)
|
||||||
attrs = safe_resolve(self.attributes, context) or {} if self.attributes else {}
|
|
||||||
|
for key, value in kwarg_pairs:
|
||||||
|
if (
|
||||||
|
key in [HTML_ATTRS_ATTRS_KEY, HTML_ATTRS_DEFAULTS_KEY]
|
||||||
|
or key.startswith(f"{HTML_ATTRS_ATTRS_KEY}:")
|
||||||
|
or key.startswith(f"{HTML_ATTRS_DEFAULTS_KEY}:")
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
|
append_attrs.append((key, value))
|
||||||
|
|
||||||
# Merge it
|
# Merge it
|
||||||
final_attrs = {**defaults, **attrs}
|
final_attrs = {**defaults, **attrs}
|
||||||
|
|
|
@ -23,7 +23,7 @@ from typing import (
|
||||||
from django.core.exceptions import ImproperlyConfigured
|
from django.core.exceptions import ImproperlyConfigured
|
||||||
from django.forms.widgets import Media
|
from django.forms.widgets import Media
|
||||||
from django.http import HttpRequest, HttpResponse
|
from django.http import HttpRequest, HttpResponse
|
||||||
from django.template.base import FilterExpression, Node, NodeList, Template, TextNode
|
from django.template.base import NodeList, Template, TextNode
|
||||||
from django.template.context import Context
|
from django.template.context import Context
|
||||||
from django.template.exceptions import TemplateSyntaxError
|
from django.template.exceptions import TemplateSyntaxError
|
||||||
from django.template.loader import get_template
|
from django.template.loader import get_template
|
||||||
|
@ -42,21 +42,23 @@ from django_components.context import (
|
||||||
make_isolated_context_copy,
|
make_isolated_context_copy,
|
||||||
prepare_context,
|
prepare_context,
|
||||||
)
|
)
|
||||||
from django_components.expression import safe_resolve_dict, safe_resolve_list
|
from django_components.expression import Expression, RuntimeKwargs, safe_resolve_list
|
||||||
from django_components.logger import trace_msg
|
from django_components.logger import trace_msg
|
||||||
from django_components.middleware import is_dependency_middleware_active
|
from django_components.middleware import is_dependency_middleware_active
|
||||||
|
from django_components.node import BaseNode
|
||||||
from django_components.slots import (
|
from django_components.slots import (
|
||||||
DEFAULT_SLOT_KEY,
|
DEFAULT_SLOT_KEY,
|
||||||
|
SLOT_DATA_KWARG,
|
||||||
|
SLOT_DEFAULT_KWARG,
|
||||||
FillContent,
|
FillContent,
|
||||||
FillNode,
|
FillNode,
|
||||||
SlotContent,
|
SlotContent,
|
||||||
SlotName,
|
SlotName,
|
||||||
SlotRef,
|
SlotRef,
|
||||||
SlotRenderedContent,
|
SlotResult,
|
||||||
_nodelist_to_slot_render_func,
|
_nodelist_to_slot_render_func,
|
||||||
resolve_slots,
|
resolve_slots,
|
||||||
)
|
)
|
||||||
from django_components.template_parser import process_aggregate_kwargs
|
|
||||||
from django_components.utils import gen_id
|
from django_components.utils import gen_id
|
||||||
|
|
||||||
# TODO_DEPRECATE_V1 - REMOVE IN V1, users should use top-level import instead
|
# TODO_DEPRECATE_V1 - REMOVE IN V1, users should use top-level import instead
|
||||||
|
@ -571,7 +573,11 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
|
||||||
def content_func(ctx: Context, kwargs: Dict[str, Any], slot_ref: SlotRef) -> SlotRenderedContent:
|
def content_func( # type: ignore[misc]
|
||||||
|
ctx: Context,
|
||||||
|
kwargs: Dict[str, Any],
|
||||||
|
slot_ref: SlotRef,
|
||||||
|
) -> SlotResult:
|
||||||
rendered = content(ctx, kwargs, slot_ref)
|
rendered = content(ctx, kwargs, slot_ref)
|
||||||
return conditional_escape(rendered) if escape_content else rendered
|
return conditional_escape(rendered) if escape_content else rendered
|
||||||
|
|
||||||
|
@ -583,25 +589,23 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co
|
||||||
return slot_fills
|
return slot_fills
|
||||||
|
|
||||||
|
|
||||||
class ComponentNode(Node):
|
class ComponentNode(BaseNode):
|
||||||
"""Django.template.Node subclass that renders a django-components component"""
|
"""Django.template.Node subclass that renders a django-components component"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
name: str,
|
name: str,
|
||||||
context_args: List[FilterExpression],
|
args: List[Expression],
|
||||||
context_kwargs: Mapping[str, FilterExpression],
|
kwargs: RuntimeKwargs,
|
||||||
isolated_context: bool = False,
|
isolated_context: bool = False,
|
||||||
fill_nodes: Optional[List[FillNode]] = None,
|
fill_nodes: Optional[List[FillNode]] = None,
|
||||||
component_id: Optional[str] = None,
|
node_id: Optional[str] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.component_id = component_id or gen_id()
|
super().__init__(nodelist=NodeList(fill_nodes), args=args, kwargs=kwargs, node_id=node_id)
|
||||||
|
|
||||||
self.name = name
|
self.name = name
|
||||||
self.context_args = context_args or []
|
|
||||||
self.context_kwargs = context_kwargs or {}
|
|
||||||
self.isolated_context = isolated_context
|
self.isolated_context = isolated_context
|
||||||
self.fill_nodes = fill_nodes or []
|
self.fill_nodes = fill_nodes or []
|
||||||
self.nodelist = NodeList(fill_nodes)
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return "<ComponentNode: {}. Contents: {!r}>".format(
|
return "<ComponentNode: {}. Contents: {!r}>".format(
|
||||||
|
@ -610,16 +614,15 @@ class ComponentNode(Node):
|
||||||
)
|
)
|
||||||
|
|
||||||
def render(self, context: Context) -> str:
|
def render(self, context: Context) -> str:
|
||||||
trace_msg("RENDR", "COMP", self.name, self.component_id)
|
trace_msg("RENDR", "COMP", self.name, self.node_id)
|
||||||
|
|
||||||
component_cls: Type[Component] = registry.get(self.name)
|
component_cls: Type[Component] = registry.get(self.name)
|
||||||
|
|
||||||
# Resolve FilterExpressions and Variables that were passed as args to the
|
# Resolve FilterExpressions and Variables that were passed as args to the
|
||||||
# component, then call component's context method
|
# component, then call component's context method
|
||||||
# to get values to insert into the context
|
# to get values to insert into the context
|
||||||
resolved_context_args = safe_resolve_list(self.context_args, context)
|
args = safe_resolve_list(context, self.args)
|
||||||
resolved_context_kwargs = safe_resolve_dict(self.context_kwargs, context)
|
kwargs = self.kwargs.resolve(context)
|
||||||
resolved_context_kwargs = process_aggregate_kwargs(resolved_context_kwargs)
|
|
||||||
|
|
||||||
is_default_slot = len(self.fill_nodes) == 1 and self.fill_nodes[0].is_implicit
|
is_default_slot = len(self.fill_nodes) == 1 and self.fill_nodes[0].is_implicit
|
||||||
if is_default_slot:
|
if is_default_slot:
|
||||||
|
@ -635,26 +638,25 @@ class ComponentNode(Node):
|
||||||
for fill_node in self.fill_nodes:
|
for fill_node in self.fill_nodes:
|
||||||
# Note that outer component context is used to resolve variables in
|
# Note that outer component context is used to resolve variables in
|
||||||
# fill tag.
|
# fill tag.
|
||||||
resolved_name = fill_node.name_fexp.resolve(context)
|
resolved_name = fill_node.name.resolve(context)
|
||||||
if resolved_name in fill_content:
|
if resolved_name in fill_content:
|
||||||
raise TemplateSyntaxError(
|
raise TemplateSyntaxError(
|
||||||
f"Multiple fill tags cannot target the same slot name: "
|
f"Multiple fill tags cannot target the same slot name: "
|
||||||
f"Detected duplicate fill tag name '{resolved_name}'."
|
f"Detected duplicate fill tag name '{resolved_name}'."
|
||||||
)
|
)
|
||||||
|
|
||||||
resolved_slot_default_var = fill_node.resolve_slot_default(context, self.name)
|
fill_kwargs = fill_node.resolve_kwargs(context, self.name)
|
||||||
resolved_slot_data_var = fill_node.resolve_slot_data(context, self.name)
|
|
||||||
fill_content[resolved_name] = FillContent(
|
fill_content[resolved_name] = FillContent(
|
||||||
content_func=_nodelist_to_slot_render_func(fill_node.nodelist),
|
content_func=_nodelist_to_slot_render_func(fill_node.nodelist),
|
||||||
slot_default_var=resolved_slot_default_var,
|
slot_default_var=fill_kwargs[SLOT_DEFAULT_KWARG],
|
||||||
slot_data_var=resolved_slot_data_var,
|
slot_data_var=fill_kwargs[SLOT_DATA_KWARG],
|
||||||
)
|
)
|
||||||
|
|
||||||
component: Component = component_cls(
|
component: Component = component_cls(
|
||||||
registered_name=self.name,
|
registered_name=self.name,
|
||||||
outer_context=context,
|
outer_context=context,
|
||||||
fill_content=fill_content,
|
fill_content=fill_content,
|
||||||
component_id=self.component_id,
|
component_id=self.node_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Prevent outer context from leaking into the template of the component
|
# Prevent outer context from leaking into the template of the component
|
||||||
|
@ -663,11 +665,11 @@ class ComponentNode(Node):
|
||||||
|
|
||||||
output = component._render(
|
output = component._render(
|
||||||
context=context,
|
context=context,
|
||||||
args=resolved_context_args,
|
args=args,
|
||||||
kwargs=resolved_context_kwargs,
|
kwargs=kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
trace_msg("RENDR", "COMP", self.name, self.component_id, "...Done!")
|
trace_msg("RENDR", "COMP", self.name, self.node_id, "...Done!")
|
||||||
return output
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1,50 +1,55 @@
|
||||||
from typing import Any, Dict, List, Mapping, Optional, Union
|
from typing import Any, Dict, List, Mapping, Optional, Tuple, Union
|
||||||
|
|
||||||
from django.template import Context
|
from django.template import Context, TemplateSyntaxError
|
||||||
from django.template.base import FilterExpression, Parser
|
from django.template.base import FilterExpression, Parser
|
||||||
|
|
||||||
|
Expression = Union[FilterExpression]
|
||||||
class AggregateFilterExpression:
|
RuntimeKwargsInput = Dict[str, Expression]
|
||||||
def __init__(self, dict: Dict[str, FilterExpression]) -> None:
|
RuntimeKwargPairsInput = List[Tuple[str, Expression]]
|
||||||
self.dict = dict
|
|
||||||
|
|
||||||
|
|
||||||
Expression = Union[FilterExpression, AggregateFilterExpression]
|
class RuntimeKwargs:
|
||||||
|
def __init__(self, kwargs: RuntimeKwargsInput) -> None:
|
||||||
|
self.kwargs = kwargs
|
||||||
|
|
||||||
|
def resolve(self, context: Context) -> Dict[str, Any]:
|
||||||
|
resolved_kwargs = safe_resolve_dict(context, self.kwargs)
|
||||||
|
return process_aggregate_kwargs(resolved_kwargs)
|
||||||
|
|
||||||
|
|
||||||
def resolve_expression_as_identifier(
|
class RuntimeKwargPairs:
|
||||||
context: Context,
|
def __init__(self, kwarg_pairs: RuntimeKwargPairsInput) -> None:
|
||||||
fexp: FilterExpression,
|
self.kwarg_pairs = kwarg_pairs
|
||||||
) -> str:
|
|
||||||
resolved = fexp.resolve(context)
|
def resolve(self, context: Context) -> List[Tuple[str, Any]]:
|
||||||
if not isinstance(resolved, str):
|
resolved_kwarg_pairs: List[Tuple[str, Any]] = []
|
||||||
raise ValueError(
|
for key, kwarg in self.kwarg_pairs:
|
||||||
f"FilterExpression '{fexp}' was expected to resolve to string, instead got '{type(resolved)}'"
|
resolved_kwarg_pairs.append((key, kwarg.resolve(context)))
|
||||||
)
|
|
||||||
if not resolved.isidentifier():
|
return resolved_kwarg_pairs
|
||||||
raise ValueError(
|
|
||||||
f"FilterExpression '{fexp}' was expected to resolve to valid identifier, instead got '{resolved}'"
|
|
||||||
)
|
|
||||||
return resolved
|
|
||||||
|
|
||||||
|
|
||||||
def safe_resolve_list(args: List[Expression], context: Context) -> List:
|
def is_identifier(value: Any) -> bool:
|
||||||
return [safe_resolve(arg, context) for arg in args]
|
if not isinstance(value, str):
|
||||||
|
return False
|
||||||
|
if not value.isidentifier():
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def safe_resolve_list(context: Context, args: List[Expression]) -> List:
|
||||||
|
return [arg.resolve(context) for arg in args]
|
||||||
|
|
||||||
|
|
||||||
def safe_resolve_dict(
|
def safe_resolve_dict(
|
||||||
kwargs: Union[Mapping[str, Expression], Dict[str, Expression]],
|
|
||||||
context: Context,
|
context: Context,
|
||||||
) -> Dict:
|
kwargs: Dict[str, Expression],
|
||||||
return {key: safe_resolve(kwarg, context) for key, kwarg in kwargs.items()}
|
) -> Dict[str, Any]:
|
||||||
|
result = {}
|
||||||
|
|
||||||
|
for key, kwarg in kwargs.items():
|
||||||
def safe_resolve(context_item: Expression, context: Context) -> Any:
|
result[key] = kwarg.resolve(context)
|
||||||
"""Resolve FilterExpressions and Variables in context if possible. Return other items unchanged."""
|
return result
|
||||||
if isinstance(context_item, AggregateFilterExpression):
|
|
||||||
return safe_resolve_dict(context_item.dict, context)
|
|
||||||
|
|
||||||
return context_item.resolve(context) if hasattr(context_item, "resolve") else context_item
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_string(
|
def resolve_string(
|
||||||
|
@ -57,7 +62,90 @@ def resolve_string(
|
||||||
return parser.compile_filter(s).resolve(context)
|
return parser.compile_filter(s).resolve(context)
|
||||||
|
|
||||||
|
|
||||||
|
def is_kwarg(key: str) -> bool:
|
||||||
|
return "=" in key
|
||||||
|
|
||||||
|
|
||||||
def is_aggregate_key(key: str) -> bool:
|
def is_aggregate_key(key: str) -> bool:
|
||||||
# NOTE: If we get a key that starts with `:`, like `:class`, we do not split it.
|
# NOTE: If we get a key that starts with `:`, like `:class`, we do not split it.
|
||||||
# This syntax is used by Vue and AlpineJS.
|
# This syntax is used by Vue and AlpineJS.
|
||||||
return ":" in key and not key.startswith(":")
|
return ":" in key and not key.startswith(":")
|
||||||
|
|
||||||
|
|
||||||
|
def process_aggregate_kwargs(kwargs: Mapping[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
This function aggregates "prefixed" kwargs into dicts. "Prefixed" kwargs
|
||||||
|
start with some prefix delimited with `:` (e.g. `attrs:`).
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```py
|
||||||
|
process_component_kwargs({"abc:one": 1, "abc:two": 2, "def:three": 3, "four": 4})
|
||||||
|
# {"abc": {"one": 1, "two": 2}, "def": {"three": 3}, "four": 4}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
We want to support a use case similar to Vue's fallthrough attributes.
|
||||||
|
In other words, where a component author can designate a prop (input)
|
||||||
|
which is a dict and which will be rendered as HTML attributes.
|
||||||
|
|
||||||
|
This is useful for allowing component users to tweak styling or add
|
||||||
|
event handling to the underlying HTML. E.g.:
|
||||||
|
|
||||||
|
`class="pa-4 d-flex text-black"` or `@click.stop="alert('clicked!')"`
|
||||||
|
|
||||||
|
So if the prop is `attrs`, and the component is called like so:
|
||||||
|
```django
|
||||||
|
{% component "my_comp" attrs=attrs %}
|
||||||
|
```
|
||||||
|
|
||||||
|
then, if `attrs` is:
|
||||||
|
```py
|
||||||
|
{"class": "text-red pa-4", "@click": "dispatch('my_event', 123)"}
|
||||||
|
```
|
||||||
|
|
||||||
|
and the component template is:
|
||||||
|
```django
|
||||||
|
<div {% html_attrs attrs add:class="extra-class" %}></div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Then this renders:
|
||||||
|
```html
|
||||||
|
<div class="text-red pa-4 extra-class" @click="dispatch('my_event', 123)" ></div>
|
||||||
|
```
|
||||||
|
|
||||||
|
However, this way it is difficult for the component user to define the `attrs`
|
||||||
|
variable, especially if they want to combine static and dynamic values. Because
|
||||||
|
they will need to pre-process the `attrs` dict.
|
||||||
|
|
||||||
|
So, instead, we allow to "aggregate" props into a dict. So all props that start
|
||||||
|
with `attrs:`, like `attrs:class="text-red"`, will be collected into a dict
|
||||||
|
at key `attrs`.
|
||||||
|
|
||||||
|
This provides sufficient flexiblity to make it easy for component users to provide
|
||||||
|
"fallthrough attributes", and sufficiently easy for component authors to process
|
||||||
|
that input while still being able to provide their own keys.
|
||||||
|
"""
|
||||||
|
processed_kwargs = {}
|
||||||
|
nested_kwargs: Dict[str, Dict[str, Any]] = {}
|
||||||
|
for key, val in kwargs.items():
|
||||||
|
if not is_aggregate_key(key):
|
||||||
|
processed_kwargs[key] = val
|
||||||
|
continue
|
||||||
|
|
||||||
|
# NOTE: Trim off the prefix from keys
|
||||||
|
prefix, sub_key = key.split(":", 1)
|
||||||
|
if prefix not in nested_kwargs:
|
||||||
|
nested_kwargs[prefix] = {}
|
||||||
|
nested_kwargs[prefix][sub_key] = val
|
||||||
|
|
||||||
|
# Assign aggregated values into normal input
|
||||||
|
for key, val in nested_kwargs.items():
|
||||||
|
if key in processed_kwargs:
|
||||||
|
raise TemplateSyntaxError(
|
||||||
|
f"Received argument '{key}' both as a regular input ({key}=...)"
|
||||||
|
f" and as an aggregate dict ('{key}:key=...'). Must be only one of the two"
|
||||||
|
)
|
||||||
|
processed_kwargs[key] = val
|
||||||
|
|
||||||
|
return processed_kwargs
|
||||||
|
|
|
@ -5,6 +5,25 @@ from django.template.base import Node, NodeList, TextNode
|
||||||
from django.template.defaulttags import CommentNode
|
from django.template.defaulttags import CommentNode
|
||||||
from django.template.loader_tags import ExtendsNode, IncludeNode, construct_relative_path
|
from django.template.loader_tags import ExtendsNode, IncludeNode, construct_relative_path
|
||||||
|
|
||||||
|
from django_components.expression import Expression, RuntimeKwargs
|
||||||
|
from django_components.utils import gen_id
|
||||||
|
|
||||||
|
|
||||||
|
class BaseNode(Node):
|
||||||
|
"""Shared behavior for our subclasses of Django's `Node`"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
nodelist: Optional[NodeList] = None,
|
||||||
|
node_id: Optional[str] = None,
|
||||||
|
args: Optional[List[Expression]] = None,
|
||||||
|
kwargs: Optional[RuntimeKwargs] = None,
|
||||||
|
):
|
||||||
|
self.nodelist = nodelist or NodeList()
|
||||||
|
self.node_id = node_id or gen_id()
|
||||||
|
self.args = args or []
|
||||||
|
self.kwargs = kwargs or RuntimeKwargs({})
|
||||||
|
|
||||||
|
|
||||||
def nodelist_has_content(nodelist: NodeList) -> bool:
|
def nodelist_has_content(nodelist: NodeList) -> bool:
|
||||||
for node in nodelist:
|
for node in nodelist:
|
||||||
|
|
|
@ -1,17 +1,17 @@
|
||||||
from typing import Dict, Optional
|
from typing import Optional
|
||||||
|
|
||||||
from django.template import Context
|
from django.template import Context
|
||||||
from django.template.base import FilterExpression, Node, NodeList
|
from django.template.base import NodeList
|
||||||
from django.utils.safestring import SafeString
|
from django.utils.safestring import SafeString
|
||||||
|
|
||||||
from django_components.context import set_provided_context_var
|
from django_components.context import set_provided_context_var
|
||||||
from django_components.expression import safe_resolve_dict
|
from django_components.expression import RuntimeKwargs
|
||||||
from django_components.logger import trace_msg
|
from django_components.logger import trace_msg
|
||||||
from django_components.template_parser import process_aggregate_kwargs
|
from django_components.node import BaseNode
|
||||||
from django_components.utils import gen_id
|
from django_components.utils import gen_id
|
||||||
|
|
||||||
|
|
||||||
class ProvideNode(Node):
|
class ProvideNode(BaseNode):
|
||||||
"""
|
"""
|
||||||
Implementation of the `{% provide %}` tag.
|
Implementation of the `{% provide %}` tag.
|
||||||
For more info see `Component.inject`.
|
For more info see `Component.inject`.
|
||||||
|
@ -22,29 +22,29 @@ class ProvideNode(Node):
|
||||||
name: str,
|
name: str,
|
||||||
nodelist: NodeList,
|
nodelist: NodeList,
|
||||||
node_id: Optional[str] = None,
|
node_id: Optional[str] = None,
|
||||||
provide_kwargs: Optional[Dict[str, FilterExpression]] = None,
|
kwargs: Optional[RuntimeKwargs] = None,
|
||||||
):
|
):
|
||||||
|
super().__init__(nodelist=nodelist, args=None, kwargs=kwargs, node_id=node_id)
|
||||||
|
|
||||||
self.name = name
|
self.name = name
|
||||||
self.nodelist = nodelist
|
self.nodelist = nodelist
|
||||||
self.node_id = node_id or gen_id()
|
self.node_id = node_id or gen_id()
|
||||||
self.provide_kwargs = provide_kwargs or {}
|
self.kwargs = kwargs or RuntimeKwargs({})
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return f"<Provide Node: {self.name}. Contents: {repr(self.nodelist)}. Data: {self.provide_kwargs}>"
|
return f"<Provide Node: {self.name}. Contents: {repr(self.nodelist)}. Data: {self.provide_kwargs.kwargs}>"
|
||||||
|
|
||||||
def render(self, context: Context) -> SafeString:
|
def render(self, context: Context) -> SafeString:
|
||||||
trace_msg("RENDR", "PROVIDE", self.name, self.node_id)
|
trace_msg("RENDR", "PROVIDE", self.name, self.node_id)
|
||||||
|
|
||||||
data = safe_resolve_dict(self.provide_kwargs, context)
|
kwargs = self.kwargs.resolve(context)
|
||||||
# Allow user to use the var:key=value syntax
|
|
||||||
data = process_aggregate_kwargs(data)
|
|
||||||
|
|
||||||
# NOTE: The "provided" kwargs are meant to be shared privately, meaning that components
|
# NOTE: The "provided" kwargs are meant to be shared privately, meaning that components
|
||||||
# have to explicitly opt in by using the `Component.inject()` method. That's why we don't
|
# have to explicitly opt in by using the `Component.inject()` method. That's why we don't
|
||||||
# add the provided kwargs into the Context.
|
# add the provided kwargs into the Context.
|
||||||
with context.update({}):
|
with context.update({}):
|
||||||
# "Provide" the data to child nodes
|
# "Provide" the data to child nodes
|
||||||
set_provided_context_var(context, self.name, data)
|
set_provided_context_var(context, self.name, kwargs)
|
||||||
|
|
||||||
output = self.nodelist.render(context)
|
output = self.nodelist.render(context)
|
||||||
|
|
||||||
|
|
|
@ -2,7 +2,8 @@ import difflib
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from typing import Any, Callable, Dict, List, Mapping, NamedTuple, Optional, Set, Tuple, Type, Union
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Dict, Generic, List, Mapping, NamedTuple, Optional, Protocol, Set, Tuple, Type, TypeVar, Union
|
||||||
|
|
||||||
from django.template import Context, Template
|
from django.template import Context, Template
|
||||||
from django.template.base import FilterExpression, Node, NodeList, Parser, TextNode
|
from django.template.base import FilterExpression, Node, NodeList, Parser, TextNode
|
||||||
|
@ -16,26 +17,38 @@ from django_components.context import (
|
||||||
_INJECT_CONTEXT_KEY_PREFIX,
|
_INJECT_CONTEXT_KEY_PREFIX,
|
||||||
_ROOT_CTX_CONTEXT_KEY,
|
_ROOT_CTX_CONTEXT_KEY,
|
||||||
)
|
)
|
||||||
from django_components.expression import Expression, resolve_expression_as_identifier, safe_resolve_dict
|
from django_components.expression import RuntimeKwargs, is_identifier
|
||||||
from django_components.logger import trace_msg
|
from django_components.logger import trace_msg
|
||||||
from django_components.node import NodeTraverse, nodelist_has_content, walk_nodelist
|
from django_components.node import BaseNode, NodeTraverse, nodelist_has_content, walk_nodelist
|
||||||
from django_components.utils import gen_id
|
|
||||||
|
TSlotData = TypeVar("TSlotData", bound=Mapping, contravariant=True)
|
||||||
|
|
||||||
DEFAULT_SLOT_KEY = "_DJANGO_COMPONENTS_DEFAULT_SLOT"
|
DEFAULT_SLOT_KEY = "_DJANGO_COMPONENTS_DEFAULT_SLOT"
|
||||||
|
SLOT_DATA_KWARG = "data"
|
||||||
|
SLOT_DEFAULT_KWARG = "default"
|
||||||
|
SLOT_REQUIRED_KEYWORD = "required"
|
||||||
|
SLOT_DEFAULT_KEYWORD = "default"
|
||||||
|
|
||||||
SlotRenderedContent = Union[str, SafeString]
|
|
||||||
SlotRenderFunc = Callable[[Context, Dict[str, Any], "SlotRef"], SlotRenderedContent]
|
|
||||||
|
|
||||||
# Type aliases
|
# Public types
|
||||||
|
SlotResult = Union[str, SafeString]
|
||||||
|
|
||||||
|
|
||||||
|
class SlotFunc(Protocol, Generic[TSlotData]):
|
||||||
|
def __call__(self, ctx: Context, slot_data: TSlotData, slot_ref: "SlotRef") -> SlotResult: ... # noqa E704
|
||||||
|
|
||||||
|
|
||||||
|
SlotContent = Union[SlotResult, SlotFunc[TSlotData]]
|
||||||
|
|
||||||
|
# Internal type aliases
|
||||||
SlotId = str
|
SlotId = str
|
||||||
SlotName = str
|
SlotName = str
|
||||||
SlotDefaultName = str
|
SlotDefaultName = str
|
||||||
SlotDataName = str
|
SlotDataName = str
|
||||||
SlotContent = Union[str, SafeString, SlotRenderFunc]
|
|
||||||
|
|
||||||
|
|
||||||
class FillContent(NamedTuple):
|
@dataclass(frozen=True)
|
||||||
|
class FillContent(Generic[TSlotData]):
|
||||||
"""
|
"""
|
||||||
This represents content set with the `{% fill %}` tag, e.g.:
|
This represents content set with the `{% fill %}` tag, e.g.:
|
||||||
|
|
||||||
|
@ -50,7 +63,7 @@ class FillContent(NamedTuple):
|
||||||
```
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
content_func: SlotRenderFunc
|
content_func: SlotFunc[TSlotData]
|
||||||
slot_default_var: Optional[SlotDefaultName]
|
slot_default_var: Optional[SlotDefaultName]
|
||||||
slot_data_var: Optional[SlotDataName]
|
slot_data_var: Optional[SlotDataName]
|
||||||
|
|
||||||
|
@ -75,7 +88,8 @@ class Slot(NamedTuple):
|
||||||
nodelist: NodeList
|
nodelist: NodeList
|
||||||
|
|
||||||
|
|
||||||
class SlotFill(NamedTuple):
|
@dataclass(frozen=True)
|
||||||
|
class SlotFill(Generic[TSlotData]):
|
||||||
"""
|
"""
|
||||||
SlotFill describes what WILL be rendered.
|
SlotFill describes what WILL be rendered.
|
||||||
|
|
||||||
|
@ -85,7 +99,7 @@ class SlotFill(NamedTuple):
|
||||||
name: str
|
name: str
|
||||||
escaped_name: str
|
escaped_name: str
|
||||||
is_filled: bool
|
is_filled: bool
|
||||||
content_func: SlotRenderFunc
|
content_func: SlotFunc[TSlotData]
|
||||||
context_data: Mapping
|
context_data: Mapping
|
||||||
slot_default_var: Optional[SlotDefaultName]
|
slot_default_var: Optional[SlotDefaultName]
|
||||||
slot_data_var: Optional[SlotDataName]
|
slot_data_var: Optional[SlotDataName]
|
||||||
|
@ -110,22 +124,21 @@ class SlotRef:
|
||||||
return mark_safe(self._slot.nodelist.render(self._context))
|
return mark_safe(self._slot.nodelist.render(self._context))
|
||||||
|
|
||||||
|
|
||||||
class SlotNode(Node):
|
class SlotNode(BaseNode):
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
name: str,
|
name: str,
|
||||||
nodelist: NodeList,
|
nodelist: NodeList,
|
||||||
|
node_id: Optional[str] = None,
|
||||||
|
kwargs: Optional[RuntimeKwargs] = None,
|
||||||
is_required: bool = False,
|
is_required: bool = False,
|
||||||
is_default: bool = False,
|
is_default: bool = False,
|
||||||
node_id: Optional[str] = None,
|
|
||||||
slot_kwargs: Optional[Dict[str, Expression]] = None,
|
|
||||||
):
|
):
|
||||||
|
super().__init__(nodelist=nodelist, args=None, kwargs=kwargs, node_id=node_id)
|
||||||
|
|
||||||
self.name = name
|
self.name = name
|
||||||
self.nodelist = nodelist
|
|
||||||
self.is_required = is_required
|
self.is_required = is_required
|
||||||
self.is_default = is_default
|
self.is_default = is_default
|
||||||
self.node_id = node_id or gen_id()
|
|
||||||
self.slot_kwargs = slot_kwargs or {}
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def active_flags(self) -> List[str]:
|
def active_flags(self) -> List[str]:
|
||||||
|
@ -171,14 +184,14 @@ class SlotNode(Node):
|
||||||
# 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.
|
||||||
slot_kwargs = safe_resolve_dict(self.slot_kwargs, context)
|
kwargs = self.kwargs.resolve(context)
|
||||||
data_var = slot_fill.slot_data_var
|
data_var = slot_fill.slot_data_var
|
||||||
if data_var:
|
if data_var:
|
||||||
if not data_var.isidentifier():
|
if not data_var.isidentifier():
|
||||||
raise TemplateSyntaxError(
|
raise TemplateSyntaxError(
|
||||||
f"Slot data alias in fill '{self.name}' must be a valid identifier. Got '{data_var}'"
|
f"Slot data alias in fill '{self.name}' must be a valid identifier. Got '{data_var}'"
|
||||||
)
|
)
|
||||||
extra_context[data_var] = slot_kwargs
|
extra_context[data_var] = kwargs
|
||||||
|
|
||||||
# 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)
|
||||||
|
@ -187,7 +200,7 @@ class SlotNode(Node):
|
||||||
# Render slot as a function
|
# Render slot as a function
|
||||||
# NOTE: While `{% fill %}` tag has to opt in for the `default` and `data` variables,
|
# NOTE: While `{% fill %}` tag has to opt in for the `default` and `data` variables,
|
||||||
# the render function ALWAYS receives them.
|
# the render function ALWAYS receives them.
|
||||||
output = slot_fill.content_func(used_ctx, slot_kwargs, slot_ref)
|
output = slot_fill.content_func(used_ctx, kwargs, slot_ref)
|
||||||
|
|
||||||
trace_msg("RENDR", "SLOT", self.name, self.node_id, msg="...Done!")
|
trace_msg("RENDR", "SLOT", self.name, self.node_id, msg="...Done!")
|
||||||
return output
|
return output
|
||||||
|
@ -208,7 +221,7 @@ class SlotNode(Node):
|
||||||
raise ValueError(f"Unknown value for CONTEXT_BEHAVIOR: '{app_settings.CONTEXT_BEHAVIOR}'")
|
raise ValueError(f"Unknown value for CONTEXT_BEHAVIOR: '{app_settings.CONTEXT_BEHAVIOR}'")
|
||||||
|
|
||||||
|
|
||||||
class FillNode(Node):
|
class FillNode(BaseNode):
|
||||||
"""
|
"""
|
||||||
Set when a `component` tag pair is passed template content that
|
Set when a `component` tag pair is passed template content that
|
||||||
excludes `fill` tags. Nodes of this type contribute their nodelists to slots marked
|
excludes `fill` tags. Nodes of this type contribute their nodelists to slots marked
|
||||||
|
@ -217,19 +230,16 @@ class FillNode(Node):
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
name: FilterExpression,
|
||||||
nodelist: NodeList,
|
nodelist: NodeList,
|
||||||
name_fexp: FilterExpression,
|
kwargs: RuntimeKwargs,
|
||||||
slot_default_var_fexp: Optional[FilterExpression] = None,
|
|
||||||
slot_data_var_fexp: Optional[FilterExpression] = None,
|
|
||||||
is_implicit: bool = False,
|
|
||||||
node_id: Optional[str] = None,
|
node_id: Optional[str] = None,
|
||||||
|
is_implicit: bool = False,
|
||||||
):
|
):
|
||||||
self.node_id = node_id or gen_id()
|
super().__init__(nodelist=nodelist, args=None, kwargs=kwargs, node_id=node_id)
|
||||||
self.nodelist = nodelist
|
|
||||||
self.name_fexp = name_fexp
|
self.name = name
|
||||||
self.slot_default_var_fexp = slot_default_var_fexp
|
|
||||||
self.is_implicit = is_implicit
|
self.is_implicit = is_implicit
|
||||||
self.slot_data_var_fexp = slot_data_var_fexp
|
|
||||||
self.component_id: Optional[str] = None
|
self.component_id: Optional[str] = None
|
||||||
|
|
||||||
def render(self, context: Context) -> str:
|
def render(self, context: Context) -> str:
|
||||||
|
@ -240,33 +250,44 @@ class FillNode(Node):
|
||||||
)
|
)
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return f"<{type(self)} Name: {self.name_fexp}. Contents: {repr(self.nodelist)}.>"
|
return f"<{type(self)} Name: {self.name}. Contents: {repr(self.nodelist)}.>"
|
||||||
|
|
||||||
def resolve_slot_default(self, context: Context, component_name: Optional[str] = None) -> Optional[str]:
|
def resolve_kwargs(self, context: Context, component_name: Optional[str] = None) -> Dict[str, Optional[str]]:
|
||||||
return self.resolve_fexp("slot default", self.slot_default_var_fexp, context, component_name)
|
kwargs = self.kwargs.resolve(context)
|
||||||
|
|
||||||
def resolve_slot_data(self, context: Context, component_name: Optional[str] = None) -> Optional[str]:
|
default_key = self._resolve_kwarg(kwargs, SLOT_DEFAULT_KWARG, "slot default", component_name)
|
||||||
return self.resolve_fexp("slot data", self.slot_data_var_fexp, context, component_name)
|
data_key = self._resolve_kwarg(kwargs, SLOT_DATA_KWARG, "slot data", component_name)
|
||||||
|
|
||||||
def resolve_fexp(
|
# data and default cannot be bound to the same variable
|
||||||
|
if data_key and default_key and data_key == default_key:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Fill {self.name} received the same string for slot default ({SLOT_DEFAULT_KWARG}=...)"
|
||||||
|
f" and slot data ({SLOT_DATA_KWARG}=...)"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
SLOT_DEFAULT_KWARG: default_key,
|
||||||
|
SLOT_DATA_KWARG: data_key,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _resolve_kwarg(
|
||||||
self,
|
self,
|
||||||
|
kwargs: Dict[str, Any],
|
||||||
|
key: str,
|
||||||
name: str,
|
name: str,
|
||||||
fexp: Optional[FilterExpression],
|
|
||||||
context: Context,
|
|
||||||
component_name: Optional[str] = None,
|
component_name: Optional[str] = None,
|
||||||
) -> Optional[str]:
|
) -> Optional[str]:
|
||||||
if not fexp:
|
if key not in kwargs:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
value = kwargs[key]
|
||||||
resolved_name = resolve_expression_as_identifier(context, fexp)
|
if not is_identifier(value):
|
||||||
except ValueError as err:
|
raise RuntimeError(
|
||||||
raise TemplateSyntaxError(
|
f"Fill tag {name} in component {component_name}"
|
||||||
f"Fill tag {name} '{fexp.var}' in component {component_name}"
|
f"does not resolve to a valid Python identifier, got '{value}'"
|
||||||
f"does not resolve to a valid Python identifier."
|
)
|
||||||
) from err
|
|
||||||
|
|
||||||
return resolved_name
|
return value
|
||||||
|
|
||||||
|
|
||||||
def parse_slot_fill_nodes_from_component_nodelist(
|
def parse_slot_fill_nodes_from_component_nodelist(
|
||||||
|
@ -316,18 +337,18 @@ def _try_parse_as_named_fill_tag_set(
|
||||||
ComponentNodeCls: Type[Node],
|
ComponentNodeCls: Type[Node],
|
||||||
) -> List[FillNode]:
|
) -> List[FillNode]:
|
||||||
result = []
|
result = []
|
||||||
seen_name_fexps: Set[str] = set()
|
seen_names: Set[str] = set()
|
||||||
for node in nodelist:
|
for node in nodelist:
|
||||||
if isinstance(node, FillNode):
|
if isinstance(node, FillNode):
|
||||||
# Check that, after we've resolved the names, that there's still no duplicates.
|
# Check that, after we've resolved the names, that there's still no duplicates.
|
||||||
# This makes sure that if two different variables refer to same string, we detect
|
# This makes sure that if two different variables refer to same string, we detect
|
||||||
# them.
|
# them.
|
||||||
if node.name_fexp.token in seen_name_fexps:
|
if node.name.token in seen_names:
|
||||||
raise TemplateSyntaxError(
|
raise TemplateSyntaxError(
|
||||||
f"Multiple fill tags cannot target the same slot name: "
|
f"Multiple fill tags cannot target the same slot name: "
|
||||||
f"Detected duplicate fill tag name '{node.name_fexp}'."
|
f"Detected duplicate fill tag name '{node.name}'."
|
||||||
)
|
)
|
||||||
seen_name_fexps.add(node.name_fexp.token)
|
seen_names.add(node.name.token)
|
||||||
result.append(node)
|
result.append(node)
|
||||||
elif isinstance(node, CommentNode):
|
elif isinstance(node, CommentNode):
|
||||||
pass
|
pass
|
||||||
|
@ -357,7 +378,8 @@ def _try_parse_as_default_fill(
|
||||||
return [
|
return [
|
||||||
FillNode(
|
FillNode(
|
||||||
nodelist=nodelist,
|
nodelist=nodelist,
|
||||||
name_fexp=FilterExpression(json.dumps(DEFAULT_SLOT_KEY), Parser("")),
|
name=FilterExpression(json.dumps(DEFAULT_SLOT_KEY), Parser("")),
|
||||||
|
kwargs=RuntimeKwargs({}),
|
||||||
is_implicit=True,
|
is_implicit=True,
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
@ -609,8 +631,8 @@ def _escape_slot_name(name: str) -> str:
|
||||||
return escaped_name
|
return escaped_name
|
||||||
|
|
||||||
|
|
||||||
def _nodelist_to_slot_render_func(nodelist: NodeList) -> SlotRenderFunc:
|
def _nodelist_to_slot_render_func(nodelist: NodeList) -> SlotFunc:
|
||||||
def render_func(ctx: Context, slot_kwargs: Dict[str, Any], slot_ref: SlotRef) -> SlotRenderedContent:
|
def render_func(ctx: Context, kwargs: Dict[str, Any], slot_ref: SlotRef) -> SlotResult:
|
||||||
return nodelist.render(ctx)
|
return nodelist.render(ctx)
|
||||||
|
|
||||||
return render_func
|
return render_func # type: ignore[return-value]
|
||||||
|
|
|
@ -5,7 +5,7 @@ Based on Django Slippers v0.6.2 - https://github.com/mixxorz/slippers/blob/main/
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import re
|
import re
|
||||||
from typing import Any, Dict, List, Mapping, Tuple
|
from typing import Any, Dict, List, Tuple
|
||||||
|
|
||||||
from django.template.base import (
|
from django.template.base import (
|
||||||
FILTER_ARGUMENT_SEPARATOR,
|
FILTER_ARGUMENT_SEPARATOR,
|
||||||
|
@ -205,88 +205,3 @@ def parse_bits(
|
||||||
% (name, ", ".join("'%s'" % p for p in unhandled_params))
|
% (name, ", ".join("'%s'" % p for p in unhandled_params))
|
||||||
)
|
)
|
||||||
return args, kwargs
|
return args, kwargs
|
||||||
|
|
||||||
|
|
||||||
def process_aggregate_kwargs(kwargs: Mapping[str, Any]) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
This function aggregates "prefixed" kwargs into dicts. "Prefixed" kwargs
|
|
||||||
start with some prefix delimited with `:` (e.g. `attrs:`).
|
|
||||||
|
|
||||||
Example:
|
|
||||||
```py
|
|
||||||
process_component_kwargs({"abc:one": 1, "abc:two": 2, "def:three": 3, "four": 4})
|
|
||||||
# {"abc": {"one": 1, "two": 2}, "def": {"three": 3}, "four": 4}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
We want to support a use case similar to Vue's fallthrough attributes.
|
|
||||||
In other words, where a component author can designate a prop (input)
|
|
||||||
which is a dict and which will be rendered as HTML attributes.
|
|
||||||
|
|
||||||
This is useful for allowing component users to tweak styling or add
|
|
||||||
event handling to the underlying HTML. E.g.:
|
|
||||||
|
|
||||||
`class="pa-4 d-flex text-black"` or `@click.stop="alert('clicked!')"`
|
|
||||||
|
|
||||||
So if the prop is `attrs`, and the component is called like so:
|
|
||||||
```django
|
|
||||||
{% component "my_comp" attrs=attrs %}
|
|
||||||
```
|
|
||||||
|
|
||||||
then, if `attrs` is:
|
|
||||||
```py
|
|
||||||
{"class": "text-red pa-4", "@click": "dispatch('my_event', 123)"}
|
|
||||||
```
|
|
||||||
|
|
||||||
and the component template is:
|
|
||||||
```django
|
|
||||||
<div {% html_attrs attrs add:class="extra-class" %}></div>
|
|
||||||
```
|
|
||||||
|
|
||||||
Then this renders:
|
|
||||||
```html
|
|
||||||
<div class="text-red pa-4 extra-class" @click="dispatch('my_event', 123)" ></div>
|
|
||||||
```
|
|
||||||
|
|
||||||
However, this way it is difficult for the component user to define the `attrs`
|
|
||||||
variable, especially if they want to combine static and dynamic values. Because
|
|
||||||
they will need to pre-process the `attrs` dict.
|
|
||||||
|
|
||||||
So, instead, we allow to "aggregate" props into a dict. So all props that start
|
|
||||||
with `attrs:`, like `attrs:class="text-red"`, will be collected into a dict
|
|
||||||
at key `attrs`.
|
|
||||||
|
|
||||||
This provides sufficient flexiblity to make it easy for component users to provide
|
|
||||||
"fallthrough attributes", and sufficiently easy for component authors to process
|
|
||||||
that input while still being able to provide their own keys.
|
|
||||||
"""
|
|
||||||
processed_kwargs = {}
|
|
||||||
nested_kwargs: Dict[str, Dict[str, Any]] = {}
|
|
||||||
for key, val in kwargs.items():
|
|
||||||
if not is_aggregate_key(key):
|
|
||||||
processed_kwargs[key] = val
|
|
||||||
continue
|
|
||||||
|
|
||||||
# NOTE: Trim off the prefix from keys
|
|
||||||
prefix, sub_key = key.split(":", 1)
|
|
||||||
if prefix not in nested_kwargs:
|
|
||||||
nested_kwargs[prefix] = {}
|
|
||||||
nested_kwargs[prefix][sub_key] = val
|
|
||||||
|
|
||||||
# Assign aggregated values into normal input
|
|
||||||
for key, val in nested_kwargs.items():
|
|
||||||
if key in processed_kwargs:
|
|
||||||
raise TemplateSyntaxError(
|
|
||||||
f"Received argument '{key}' both as a regular input ({key}=...)"
|
|
||||||
f" and as an aggregate dict ('{key}:key=...'). Must be only one of the two"
|
|
||||||
)
|
|
||||||
processed_kwargs[key] = val
|
|
||||||
|
|
||||||
return processed_kwargs
|
|
||||||
|
|
||||||
|
|
||||||
def is_aggregate_key(key: str) -> bool:
|
|
||||||
# NOTE: If we get a key that starts with `:`, like `:class`, we do not split it.
|
|
||||||
# This syntax is used by Vue and AlpineJS.
|
|
||||||
return ":" in key and not key.startswith(":")
|
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
from typing import TYPE_CHECKING, Callable, Dict, List, NamedTuple, Optional, Set, Tuple, Union
|
from typing import TYPE_CHECKING, Callable, Dict, List, NamedTuple, Optional, Set, Union
|
||||||
|
|
||||||
import django.template
|
import django.template
|
||||||
from django.template.base import FilterExpression, NodeList, Parser, Token
|
from django.template.base import FilterExpression, NodeList, Parser, Token
|
||||||
|
@ -6,11 +6,19 @@ from django.template.exceptions import TemplateSyntaxError
|
||||||
from django.utils.safestring import SafeString, mark_safe
|
from django.utils.safestring import SafeString, mark_safe
|
||||||
|
|
||||||
from django_components.app_settings import ContextBehavior, app_settings
|
from django_components.app_settings import ContextBehavior, app_settings
|
||||||
from django_components.attributes import HtmlAttrsNode
|
from django_components.attributes import HTML_ATTRS_ATTRS_KEY, HTML_ATTRS_DEFAULTS_KEY, HtmlAttrsNode
|
||||||
from django_components.component import RENDERED_COMMENT_TEMPLATE, ComponentNode
|
from django_components.component import RENDERED_COMMENT_TEMPLATE, ComponentNode
|
||||||
from django_components.component_registry import ComponentRegistry
|
from django_components.component_registry import ComponentRegistry
|
||||||
from django_components.component_registry import registry as component_registry
|
from django_components.component_registry import registry as component_registry
|
||||||
from django_components.expression import AggregateFilterExpression, Expression, resolve_string
|
from django_components.expression import (
|
||||||
|
Expression,
|
||||||
|
RuntimeKwargPairs,
|
||||||
|
RuntimeKwargs,
|
||||||
|
RuntimeKwargsInput,
|
||||||
|
is_aggregate_key,
|
||||||
|
is_kwarg,
|
||||||
|
resolve_string,
|
||||||
|
)
|
||||||
from django_components.logger import trace_msg
|
from django_components.logger import trace_msg
|
||||||
from django_components.middleware import (
|
from django_components.middleware import (
|
||||||
CSS_DEPENDENCY_PLACEHOLDER,
|
CSS_DEPENDENCY_PLACEHOLDER,
|
||||||
|
@ -18,9 +26,17 @@ from django_components.middleware import (
|
||||||
is_dependency_middleware_active,
|
is_dependency_middleware_active,
|
||||||
)
|
)
|
||||||
from django_components.provide import ProvideNode
|
from django_components.provide import ProvideNode
|
||||||
from django_components.slots import FillNode, SlotNode, parse_slot_fill_nodes_from_component_nodelist
|
from django_components.slots import (
|
||||||
|
SLOT_DATA_KWARG,
|
||||||
|
SLOT_DEFAULT_KEYWORD,
|
||||||
|
SLOT_DEFAULT_KWARG,
|
||||||
|
SLOT_REQUIRED_KEYWORD,
|
||||||
|
FillNode,
|
||||||
|
SlotNode,
|
||||||
|
parse_slot_fill_nodes_from_component_nodelist,
|
||||||
|
)
|
||||||
from django_components.tag_formatter import get_tag_formatter
|
from django_components.tag_formatter import get_tag_formatter
|
||||||
from django_components.template_parser import is_aggregate_key, parse_bits, process_aggregate_kwargs
|
from django_components.template_parser import parse_bits
|
||||||
from django_components.utils import gen_id, is_str_wrapped_in_quotes
|
from django_components.utils import gen_id, is_str_wrapped_in_quotes
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
@ -32,12 +48,6 @@ if TYPE_CHECKING:
|
||||||
register = django.template.Library()
|
register = django.template.Library()
|
||||||
|
|
||||||
|
|
||||||
SLOT_REQUIRED_OPTION_KEYWORD = "required"
|
|
||||||
SLOT_DEFAULT_OPTION_KEYWORD = "default"
|
|
||||||
SLOT_DATA_ATTR = "data"
|
|
||||||
SLOT_DEFAULT_ATTR = "default"
|
|
||||||
|
|
||||||
|
|
||||||
def _get_components_from_registry(registry: ComponentRegistry) -> List["Component"]:
|
def _get_components_from_registry(registry: ComponentRegistry) -> List["Component"]:
|
||||||
"""Returns a list unique components from the registry."""
|
"""Returns a list unique components from the registry."""
|
||||||
|
|
||||||
|
@ -123,7 +133,7 @@ def slot(parser: Parser, token: Token) -> SlotNode:
|
||||||
parser,
|
parser,
|
||||||
bits,
|
bits,
|
||||||
params=["name"],
|
params=["name"],
|
||||||
flags=[SLOT_DEFAULT_OPTION_KEYWORD, SLOT_REQUIRED_OPTION_KEYWORD],
|
flags=[SLOT_DEFAULT_KEYWORD, SLOT_REQUIRED_KEYWORD],
|
||||||
keywordonly_kwargs=True,
|
keywordonly_kwargs=True,
|
||||||
repeatable_kwargs=False,
|
repeatable_kwargs=False,
|
||||||
end_tag="endslot",
|
end_tag="endslot",
|
||||||
|
@ -136,10 +146,10 @@ def slot(parser: Parser, token: Token) -> SlotNode:
|
||||||
slot_node = SlotNode(
|
slot_node = SlotNode(
|
||||||
name=data.name,
|
name=data.name,
|
||||||
nodelist=body,
|
nodelist=body,
|
||||||
is_required=tag.flags[SLOT_REQUIRED_OPTION_KEYWORD],
|
|
||||||
is_default=tag.flags[SLOT_DEFAULT_OPTION_KEYWORD],
|
|
||||||
node_id=tag.id,
|
node_id=tag.id,
|
||||||
slot_kwargs=tag.kwargs,
|
kwargs=tag.kwargs,
|
||||||
|
is_required=tag.flags[SLOT_REQUIRED_KEYWORD],
|
||||||
|
is_default=tag.flags[SLOT_DEFAULT_KEYWORD],
|
||||||
)
|
)
|
||||||
|
|
||||||
trace_msg("PARSE", "SLOT", data.name, tag.id, "...Done!")
|
trace_msg("PARSE", "SLOT", data.name, tag.id, "...Done!")
|
||||||
|
@ -162,24 +172,23 @@ def fill(parser: Parser, token: Token) -> FillNode:
|
||||||
parser,
|
parser,
|
||||||
bits,
|
bits,
|
||||||
params=["name"],
|
params=["name"],
|
||||||
keywordonly_kwargs=[SLOT_DATA_ATTR, SLOT_DEFAULT_ATTR],
|
keywordonly_kwargs=[SLOT_DATA_KWARG, SLOT_DEFAULT_KWARG],
|
||||||
repeatable_kwargs=False,
|
repeatable_kwargs=False,
|
||||||
end_tag="endfill",
|
end_tag="endfill",
|
||||||
)
|
)
|
||||||
data = _parse_fill_args(parser, tag)
|
slot_name = tag.named_args["name"]
|
||||||
|
|
||||||
trace_msg("PARSE", "FILL", str(data.slot_name), tag.id)
|
trace_msg("PARSE", "FILL", str(slot_name), tag.id)
|
||||||
|
|
||||||
body = tag.parse_body()
|
body = tag.parse_body()
|
||||||
fill_node = FillNode(
|
fill_node = FillNode(
|
||||||
nodelist=body,
|
nodelist=body,
|
||||||
name_fexp=data.slot_name,
|
name=slot_name,
|
||||||
slot_default_var_fexp=data.slot_default_var,
|
|
||||||
slot_data_var_fexp=data.slot_data_var,
|
|
||||||
node_id=tag.id,
|
node_id=tag.id,
|
||||||
|
kwargs=tag.kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
trace_msg("PARSE", "FILL", str(data.slot_name), tag.id, "...Done!")
|
trace_msg("PARSE", "FILL", str(slot_name), tag.id, "...Done!")
|
||||||
return fill_node
|
return fill_node
|
||||||
|
|
||||||
|
|
||||||
|
@ -216,7 +225,9 @@ def component(parser: Parser, token: Token, tag_name: str) -> ComponentNode:
|
||||||
repeatable_kwargs=False,
|
repeatable_kwargs=False,
|
||||||
end_tag=end_tag,
|
end_tag=end_tag,
|
||||||
)
|
)
|
||||||
data = _parse_component_args(parser, tag)
|
|
||||||
|
# Check for isolated context keyword
|
||||||
|
isolated_context = tag.flags["only"] or app_settings.CONTEXT_BEHAVIOR == ContextBehavior.ISOLATED
|
||||||
|
|
||||||
trace_msg("PARSE", "COMP", result.component_name, tag.id)
|
trace_msg("PARSE", "COMP", result.component_name, tag.id)
|
||||||
|
|
||||||
|
@ -225,16 +236,16 @@ def component(parser: Parser, token: Token, tag_name: str) -> ComponentNode:
|
||||||
|
|
||||||
# Tag all fill nodes as children of this particular component instance
|
# Tag all fill nodes as children of this particular component instance
|
||||||
for node in fill_nodes:
|
for node in fill_nodes:
|
||||||
trace_msg("ASSOC", "FILL", node.name_fexp, node.node_id, component_id=tag.id)
|
trace_msg("ASSOC", "FILL", node.name, node.node_id, component_id=tag.id)
|
||||||
node.component_id = tag.id
|
node.component_id = tag.id
|
||||||
|
|
||||||
component_node = ComponentNode(
|
component_node = ComponentNode(
|
||||||
name=result.component_name,
|
name=result.component_name,
|
||||||
context_args=tag.args,
|
args=tag.args,
|
||||||
context_kwargs=tag.kwargs,
|
kwargs=tag.kwargs,
|
||||||
isolated_context=data.isolated_context,
|
isolated_context=isolated_context,
|
||||||
fill_nodes=fill_nodes,
|
fill_nodes=fill_nodes,
|
||||||
component_id=tag.id,
|
node_id=tag.id,
|
||||||
)
|
)
|
||||||
|
|
||||||
trace_msg("PARSE", "COMP", result.component_name, tag.id, "...Done!")
|
trace_msg("PARSE", "COMP", result.component_name, tag.id, "...Done!")
|
||||||
|
@ -264,7 +275,7 @@ def provide(parser: Parser, token: Token) -> ProvideNode:
|
||||||
name=data.key,
|
name=data.key,
|
||||||
nodelist=body,
|
nodelist=body,
|
||||||
node_id=tag.id,
|
node_id=tag.id,
|
||||||
provide_kwargs=tag.kwargs,
|
kwargs=tag.kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
trace_msg("PARSE", "PROVIDE", data.key, tag.id, "...Done!")
|
trace_msg("PARSE", "PROVIDE", data.key, tag.id, "...Done!")
|
||||||
|
@ -305,17 +316,16 @@ def html_attrs(parser: Parser, token: Token) -> HtmlAttrsNode:
|
||||||
"html_attrs",
|
"html_attrs",
|
||||||
parser,
|
parser,
|
||||||
bits,
|
bits,
|
||||||
params=["attrs", "defaults"],
|
params=[HTML_ATTRS_ATTRS_KEY, HTML_ATTRS_DEFAULTS_KEY],
|
||||||
optional_params=["attrs", "defaults"],
|
optional_params=[HTML_ATTRS_ATTRS_KEY, HTML_ATTRS_DEFAULTS_KEY],
|
||||||
flags=[],
|
flags=[],
|
||||||
keywordonly_kwargs=True,
|
keywordonly_kwargs=True,
|
||||||
repeatable_kwargs=True,
|
repeatable_kwargs=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
return HtmlAttrsNode(
|
return HtmlAttrsNode(
|
||||||
attributes=tag.kwargs.get("attrs"),
|
kwargs=tag.kwargs,
|
||||||
defaults=tag.kwargs.get("defaults"),
|
kwarg_pairs=tag.kwarg_pairs,
|
||||||
kwargs=[(key, val) for key, val in tag.kwarg_pairs if key != "attrs" and key != "defaults"],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -324,10 +334,10 @@ class ParsedTag(NamedTuple):
|
||||||
name: str
|
name: str
|
||||||
bits: List[str]
|
bits: List[str]
|
||||||
flags: Dict[str, bool]
|
flags: Dict[str, bool]
|
||||||
args: List[FilterExpression]
|
args: List[Expression]
|
||||||
named_args: Dict[str, FilterExpression]
|
named_args: Dict[str, Expression]
|
||||||
kwargs: Dict[str, Expression]
|
kwargs: RuntimeKwargs
|
||||||
kwarg_pairs: List[Tuple[str, Expression]]
|
kwarg_pairs: RuntimeKwargPairs
|
||||||
is_inline: bool
|
is_inline: bool
|
||||||
parse_body: Callable[[], NodeList]
|
parse_body: Callable[[], NodeList]
|
||||||
|
|
||||||
|
@ -380,17 +390,13 @@ def _parse_tag(
|
||||||
seen_kwargs.add(key)
|
seen_kwargs.add(key)
|
||||||
|
|
||||||
for bit in bits:
|
for bit in bits:
|
||||||
# Extract flags, which are like keywords but without the value part
|
value = bit
|
||||||
if bit in parsed_flags:
|
bit_is_kwarg = is_kwarg(bit)
|
||||||
parsed_flags[bit] = True
|
|
||||||
continue
|
|
||||||
else:
|
|
||||||
bits_without_flags.append(bit)
|
|
||||||
|
|
||||||
# Record which kwargs we've seen, to detect if kwargs were passed in
|
# Record which kwargs we've seen, to detect if kwargs were passed in
|
||||||
# as both aggregate and regular kwargs
|
# as both aggregate and regular kwargs
|
||||||
if "=" in bit:
|
if bit_is_kwarg:
|
||||||
key = bit.split("=")[0]
|
key, value = bit.split("=", 1)
|
||||||
|
|
||||||
# Also pick up on aggregate keys like `attr:key=val`
|
# Also pick up on aggregate keys like `attr:key=val`
|
||||||
if is_aggregate_key(key):
|
if is_aggregate_key(key):
|
||||||
|
@ -399,6 +405,14 @@ def _parse_tag(
|
||||||
else:
|
else:
|
||||||
mark_kwarg_key(key, False)
|
mark_kwarg_key(key, False)
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Extract flags, which are like keywords but without the value part
|
||||||
|
if value in parsed_flags:
|
||||||
|
parsed_flags[value] = True
|
||||||
|
continue
|
||||||
|
|
||||||
|
bits_without_flags.append(bit)
|
||||||
|
|
||||||
bits = bits_without_flags
|
bits = bits_without_flags
|
||||||
|
|
||||||
# To support optional args, we need to convert these to kwargs, so `parse_bits`
|
# To support optional args, we need to convert these to kwargs, so `parse_bits`
|
||||||
|
@ -415,7 +429,7 @@ def _parse_tag(
|
||||||
new_params = []
|
new_params = []
|
||||||
new_kwargs = []
|
new_kwargs = []
|
||||||
for index, bit in enumerate(bits):
|
for index, bit in enumerate(bits):
|
||||||
if "=" in bit or not len(params_to_sort):
|
if is_kwarg(bit) or not len(params_to_sort):
|
||||||
# Pass all remaining bits (including current one) as kwargs
|
# Pass all remaining bits (including current one) as kwargs
|
||||||
new_kwargs.extend(bits[index:])
|
new_kwargs.extend(bits[index:])
|
||||||
break
|
break
|
||||||
|
@ -436,31 +450,13 @@ def _parse_tag(
|
||||||
params = [param for param in params_to_sort if param not in optional_params]
|
params = [param for param in params_to_sort if param not in optional_params]
|
||||||
|
|
||||||
# Parse args/kwargs that will be passed to the fill
|
# Parse args/kwargs that will be passed to the fill
|
||||||
args, raw_kwarg_pairs = parse_bits(
|
args, kwarg_pairs = parse_bits(
|
||||||
parser=parser,
|
parser=parser,
|
||||||
bits=bits,
|
bits=bits,
|
||||||
params=[] if isinstance(params, bool) else params,
|
params=[] if isinstance(params, bool) else params,
|
||||||
name=tag_name,
|
name=tag_name,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Post-process args/kwargs - Mark special cases like aggregate dicts
|
|
||||||
# or dynamic expressions
|
|
||||||
pre_aggregate_kwargs: Dict[str, FilterExpression] = {}
|
|
||||||
kwarg_pairs: List[Tuple[str, Expression]] = []
|
|
||||||
for key, val in raw_kwarg_pairs:
|
|
||||||
# NOTE: If a tag allows mutliple kwargs, and we provide a same aggregate key
|
|
||||||
# multiple times (e.g. `attr:class="hidden" and `attr:class="another"`), then
|
|
||||||
# we take only the last instance.
|
|
||||||
if is_aggregate_key(key):
|
|
||||||
pre_aggregate_kwargs[key] = val
|
|
||||||
else:
|
|
||||||
kwarg_pairs.append((key, val))
|
|
||||||
aggregate_kwargs: Dict[str, Dict[str, FilterExpression]] = process_aggregate_kwargs(pre_aggregate_kwargs)
|
|
||||||
|
|
||||||
for key, agg_dict in aggregate_kwargs.items():
|
|
||||||
entry = (key, AggregateFilterExpression(agg_dict))
|
|
||||||
kwarg_pairs.append(entry)
|
|
||||||
|
|
||||||
# Allow only as many positional args as given
|
# Allow only as many positional args as given
|
||||||
if params != True and len(args) > len(params): # noqa F712
|
if params != True and len(args) > len(params): # noqa F712
|
||||||
raise TemplateSyntaxError(f"Tag '{tag_name}' received unexpected positional arguments: {args[len(params):]}")
|
raise TemplateSyntaxError(f"Tag '{tag_name}' received unexpected positional arguments: {args[len(params):]}")
|
||||||
|
@ -472,7 +468,7 @@ def _parse_tag(
|
||||||
named_args = {}
|
named_args = {}
|
||||||
|
|
||||||
# Validate kwargs
|
# Validate kwargs
|
||||||
kwargs: Dict[str, Expression] = {}
|
kwargs: RuntimeKwargsInput = {}
|
||||||
extra_keywords: Set[str] = set()
|
extra_keywords: Set[str] = set()
|
||||||
for key, val in kwarg_pairs:
|
for key, val in kwarg_pairs:
|
||||||
# Check if key allowed
|
# Check if key allowed
|
||||||
|
@ -506,8 +502,8 @@ def _parse_tag(
|
||||||
flags=parsed_flags,
|
flags=parsed_flags,
|
||||||
args=args,
|
args=args,
|
||||||
named_args=named_args,
|
named_args=named_args,
|
||||||
kwargs=kwargs,
|
kwargs=RuntimeKwargs(kwargs),
|
||||||
kwarg_pairs=kwarg_pairs,
|
kwarg_pairs=RuntimeKwargPairs(kwarg_pairs),
|
||||||
# NOTE: We defer parsing of the body, so we have the chance to call the tracing
|
# NOTE: We defer parsing of the body, so we have the chance to call the tracing
|
||||||
# loggers before the parsing. This is because, if the body contains any other
|
# loggers before the parsing. This is because, if the body contains any other
|
||||||
# tags, it will trigger their tag handlers. So the code called AFTER
|
# tags, it will trigger their tag handlers. So the code called AFTER
|
||||||
|
@ -526,20 +522,6 @@ def _parse_tag_body(parser: Parser, end_tag: str, inline: bool) -> NodeList:
|
||||||
return body
|
return body
|
||||||
|
|
||||||
|
|
||||||
class ParsedComponentTag(NamedTuple):
|
|
||||||
isolated_context: bool
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_component_args(
|
|
||||||
parser: Parser,
|
|
||||||
tag: ParsedTag,
|
|
||||||
) -> ParsedComponentTag:
|
|
||||||
# Check for isolated context keyword
|
|
||||||
isolated_context = tag.flags["only"] or app_settings.CONTEXT_BEHAVIOR == ContextBehavior.ISOLATED
|
|
||||||
|
|
||||||
return ParsedComponentTag(isolated_context=isolated_context)
|
|
||||||
|
|
||||||
|
|
||||||
class ParsedSlotTag(NamedTuple):
|
class ParsedSlotTag(NamedTuple):
|
||||||
name: str
|
name: str
|
||||||
|
|
||||||
|
@ -548,7 +530,10 @@ def _parse_slot_args(
|
||||||
parser: Parser,
|
parser: Parser,
|
||||||
tag: ParsedTag,
|
tag: ParsedTag,
|
||||||
) -> ParsedSlotTag:
|
) -> ParsedSlotTag:
|
||||||
slot_name = tag.named_args["name"].token
|
slot_name_expr = tag.named_args["name"]
|
||||||
|
if not isinstance(slot_name_expr, FilterExpression):
|
||||||
|
raise TemplateSyntaxError(f"Slot name must be string literal, got {slot_name_expr}")
|
||||||
|
slot_name = slot_name_expr.token
|
||||||
if not is_str_wrapped_in_quotes(slot_name):
|
if not is_str_wrapped_in_quotes(slot_name):
|
||||||
raise TemplateSyntaxError(f"'{tag.name}' name must be a string 'literal', got {slot_name}.")
|
raise TemplateSyntaxError(f"'{tag.name}' name must be a string 'literal', got {slot_name}.")
|
||||||
|
|
||||||
|
@ -557,46 +542,6 @@ def _parse_slot_args(
|
||||||
return ParsedSlotTag(name=slot_name)
|
return ParsedSlotTag(name=slot_name)
|
||||||
|
|
||||||
|
|
||||||
class ParsedFillTag(NamedTuple):
|
|
||||||
slot_name: FilterExpression
|
|
||||||
slot_default_var: Optional[FilterExpression]
|
|
||||||
slot_data_var: Optional[FilterExpression]
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_fill_args(
|
|
||||||
parser: Parser,
|
|
||||||
tag: ParsedTag,
|
|
||||||
) -> ParsedFillTag:
|
|
||||||
slot_name_fexp = tag.named_args["name"]
|
|
||||||
|
|
||||||
# Extract known kwargs
|
|
||||||
slot_data_var_fexp: Optional[FilterExpression] = tag.kwargs.get(SLOT_DATA_ATTR)
|
|
||||||
if slot_data_var_fexp and not is_str_wrapped_in_quotes(slot_data_var_fexp.token):
|
|
||||||
raise TemplateSyntaxError(
|
|
||||||
f"Value of '{SLOT_DATA_ATTR}' in '{tag.name}' tag must be a string literal, got '{slot_data_var_fexp}'"
|
|
||||||
)
|
|
||||||
|
|
||||||
slot_default_var_fexp: Optional[FilterExpression] = tag.kwargs.get(SLOT_DEFAULT_ATTR)
|
|
||||||
if slot_default_var_fexp and not is_str_wrapped_in_quotes(slot_default_var_fexp.token):
|
|
||||||
raise TemplateSyntaxError(
|
|
||||||
f"Value of '{SLOT_DEFAULT_ATTR}' in '{tag.name}' tag must be a string literal,"
|
|
||||||
f" got '{slot_default_var_fexp}'"
|
|
||||||
)
|
|
||||||
|
|
||||||
# data and default cannot be bound to the same variable
|
|
||||||
if slot_data_var_fexp and slot_default_var_fexp and slot_data_var_fexp.token == slot_default_var_fexp.token:
|
|
||||||
raise TemplateSyntaxError(
|
|
||||||
f"'{tag.name}' received the same string for slot default ({SLOT_DEFAULT_ATTR}=...)"
|
|
||||||
f" and slot data ({SLOT_DATA_ATTR}=...)"
|
|
||||||
)
|
|
||||||
|
|
||||||
return ParsedFillTag(
|
|
||||||
slot_name=slot_name_fexp,
|
|
||||||
slot_default_var=slot_default_var_fexp,
|
|
||||||
slot_data_var=slot_data_var_fexp,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ParsedProvideTag(NamedTuple):
|
class ParsedProvideTag(NamedTuple):
|
||||||
key: str
|
key: str
|
||||||
|
|
||||||
|
@ -605,9 +550,12 @@ def _parse_provide_args(
|
||||||
parser: Parser,
|
parser: Parser,
|
||||||
tag: ParsedTag,
|
tag: ParsedTag,
|
||||||
) -> ParsedProvideTag:
|
) -> ParsedProvideTag:
|
||||||
provide_key = tag.named_args["name"].token
|
provide_key_expr = tag.named_args["name"]
|
||||||
|
if not isinstance(provide_key_expr, FilterExpression):
|
||||||
|
raise TemplateSyntaxError(f"Provide key must be string literal, got {provide_key_expr}")
|
||||||
|
provide_key = provide_key_expr.token
|
||||||
if not is_str_wrapped_in_quotes(provide_key):
|
if not is_str_wrapped_in_quotes(provide_key):
|
||||||
raise TemplateSyntaxError(f"'{tag.name}' key must be a string 'literal'.")
|
raise TemplateSyntaxError(f"'{tag.name}' key must be a string 'literal', got {provide_key}")
|
||||||
|
|
||||||
provide_key = resolve_string(provide_key, parser)
|
provide_key = resolve_string(provide_key, parser)
|
||||||
|
|
||||||
|
|
60
tests/test_expression.py
Normal file
60
tests/test_expression.py
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
"""Catch-all for tests that use template tags and don't fit other files"""
|
||||||
|
|
||||||
|
from typing import Dict
|
||||||
|
|
||||||
|
from django.template import Context, Template
|
||||||
|
from django.template.base import Parser
|
||||||
|
|
||||||
|
from django_components.expression import safe_resolve_dict, safe_resolve_list
|
||||||
|
|
||||||
|
from .django_test_setup import setup_test_config
|
||||||
|
from .testutils import BaseTestCase
|
||||||
|
|
||||||
|
setup_test_config({"autodiscover": False})
|
||||||
|
|
||||||
|
|
||||||
|
engine = Template("").engine
|
||||||
|
default_parser = Parser("", engine.template_libraries, engine.template_builtins)
|
||||||
|
|
||||||
|
|
||||||
|
def make_context(d: Dict):
|
||||||
|
ctx = Context(d)
|
||||||
|
ctx.template = Template("")
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
|
#######################
|
||||||
|
# TESTS
|
||||||
|
#######################
|
||||||
|
|
||||||
|
|
||||||
|
class ResolveTests(BaseTestCase):
|
||||||
|
def test_safe_resolve(self):
|
||||||
|
expr = default_parser.compile_filter("var_abc")
|
||||||
|
|
||||||
|
ctx = make_context({"var_abc": 123})
|
||||||
|
self.assertEqual(
|
||||||
|
expr.resolve(ctx),
|
||||||
|
123,
|
||||||
|
)
|
||||||
|
|
||||||
|
ctx2 = make_context({"var_xyz": 123})
|
||||||
|
self.assertEqual(expr.resolve(ctx2), "")
|
||||||
|
|
||||||
|
def test_safe_resolve_list(self):
|
||||||
|
exprs = [default_parser.compile_filter(f"var_{char}") for char in "abc"]
|
||||||
|
|
||||||
|
ctx = make_context({"var_a": 123, "var_b": [{}, {}]})
|
||||||
|
self.assertEqual(
|
||||||
|
safe_resolve_list(ctx, exprs),
|
||||||
|
[123, [{}, {}], ""],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_safe_resolve_dict(self):
|
||||||
|
exprs = {char: default_parser.compile_filter(f"var_{char}") for char in "abc"}
|
||||||
|
|
||||||
|
ctx = make_context({"var_a": 123, "var_b": [{}, {}]})
|
||||||
|
self.assertEqual(
|
||||||
|
safe_resolve_dict(ctx, exprs),
|
||||||
|
{"a": 123, "b": [{}, {}], "c": ""},
|
||||||
|
)
|
|
@ -2,8 +2,12 @@ from django.template import Context, Template
|
||||||
from django.template.base import Parser
|
from django.template.base import Parser
|
||||||
|
|
||||||
from django_components import Component, registry, types
|
from django_components import Component, registry, types
|
||||||
from django_components.component import safe_resolve_dict, safe_resolve_list
|
from django_components.expression import (
|
||||||
from django_components.template_parser import is_aggregate_key, process_aggregate_kwargs
|
is_aggregate_key,
|
||||||
|
process_aggregate_kwargs,
|
||||||
|
safe_resolve_dict,
|
||||||
|
safe_resolve_list,
|
||||||
|
)
|
||||||
from django_components.templatetags.component_tags import _parse_tag
|
from django_components.templatetags.component_tags import _parse_tag
|
||||||
|
|
||||||
from .django_test_setup import setup_test_config
|
from .django_test_setup import setup_test_config
|
||||||
|
@ -18,9 +22,9 @@ class ParserTest(BaseTestCase):
|
||||||
tag = _parse_tag("component", Parser(""), bits, params=["num", "var"], keywordonly_kwargs=True)
|
tag = _parse_tag("component", Parser(""), bits, params=["num", "var"], keywordonly_kwargs=True)
|
||||||
|
|
||||||
ctx = {"myvar": {"a": "b"}, "val2": 1}
|
ctx = {"myvar": {"a": "b"}, "val2": 1}
|
||||||
args = safe_resolve_list(tag.args, ctx)
|
args = safe_resolve_list(ctx, tag.args)
|
||||||
named_args = safe_resolve_dict(tag.named_args, ctx)
|
named_args = safe_resolve_dict(ctx, tag.named_args)
|
||||||
kwargs = safe_resolve_dict(tag.kwargs, ctx)
|
kwargs = tag.kwargs.resolve(ctx)
|
||||||
|
|
||||||
self.assertListEqual(args, [42, {"a": "b"}])
|
self.assertListEqual(args, [42, {"a": "b"}])
|
||||||
self.assertDictEqual(named_args, {"num": 42, "var": {"a": "b"}})
|
self.assertDictEqual(named_args, {"num": 42, "var": {"a": "b"}})
|
||||||
|
@ -38,8 +42,8 @@ class ParserTest(BaseTestCase):
|
||||||
tag = _parse_tag("component", Parser(""), bits, keywordonly_kwargs=True)
|
tag = _parse_tag("component", Parser(""), bits, keywordonly_kwargs=True)
|
||||||
|
|
||||||
ctx = Context({"date": 2024, "bzz": "fzz"})
|
ctx = Context({"date": 2024, "bzz": "fzz"})
|
||||||
args = safe_resolve_list(tag.args, ctx)
|
args = safe_resolve_list(ctx, tag.args)
|
||||||
kwargs = safe_resolve_dict(tag.kwargs, ctx)
|
kwargs = tag.kwargs.resolve(ctx)
|
||||||
|
|
||||||
self.assertListEqual(args, [])
|
self.assertListEqual(args, [])
|
||||||
self.assertDictEqual(
|
self.assertDictEqual(
|
||||||
|
|
|
@ -822,8 +822,8 @@ class ScopedSlotTest(BaseTestCase):
|
||||||
{% endcomponent %}
|
{% endcomponent %}
|
||||||
"""
|
"""
|
||||||
with self.assertRaisesMessage(
|
with self.assertRaisesMessage(
|
||||||
TemplateSyntaxError,
|
RuntimeError,
|
||||||
"'fill' received the same string for slot default (default=...) and slot data (data=...)",
|
'Fill "my_slot" received the same string for slot default (default=...) and slot data (data=...)',
|
||||||
):
|
):
|
||||||
Template(template).render(Context())
|
Template(template).render(Context())
|
||||||
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue