mirror of
https://github.com/django-components/django-components.git
synced 2025-07-24 16:53:47 +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,
|
||||
args: List[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
|
||||
) -> str:
|
||||
```
|
||||
|
@ -550,7 +550,7 @@ Component.render(
|
|||
|
||||
- _`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
|
||||
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.
|
||||
|
||||
|
@ -559,7 +559,7 @@ Component.render(
|
|||
- NOTE: In "isolated" mode, context is NOT accessible, and data MUST be passed via
|
||||
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.
|
||||
|
||||
|
@ -602,24 +602,30 @@ that allow you to specify the types of args, kwargs, slots, and
|
|||
data.
|
||||
|
||||
```py
|
||||
from typing import NotRequired, Tuple, TypedDict
|
||||
from typing import NotRequired, Tuple, TypedDict, SlotFunc
|
||||
|
||||
# Tuple
|
||||
# Positional inputs - Tuple
|
||||
Args = Tuple[int, str]
|
||||
|
||||
# Mapping
|
||||
# Kwargs inputs - Mapping
|
||||
class Kwargs(TypedDict):
|
||||
variable: str
|
||||
another: int
|
||||
maybe_var: NotRequired[int]
|
||||
|
||||
# Mapping
|
||||
# Data returned from `get_context_data` - Mapping
|
||||
class Data(TypedDict):
|
||||
variable: str
|
||||
|
||||
# Mapping
|
||||
# The data available to the `my_slot` scoped slot
|
||||
class MySlotData(TypedDict):
|
||||
value: int
|
||||
|
||||
# Slot functions - Mapping
|
||||
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]):
|
||||
def get_context_data(self, variable, another):
|
||||
|
|
|
@ -19,6 +19,10 @@ from django_components.component_registry import (
|
|||
registry as registry,
|
||||
)
|
||||
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 (
|
||||
ComponentFormatter as ComponentFormatter,
|
||||
ShorthandComponentFormatter as ShorthandComponentFormatter,
|
||||
|
|
|
@ -4,37 +4,46 @@
|
|||
|
||||
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.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_ATTRS_KEY = "attrs"
|
||||
|
||||
|
||||
class HtmlAttrsNode(Node):
|
||||
class HtmlAttrsNode(BaseNode):
|
||||
def __init__(
|
||||
self,
|
||||
attributes: Optional[Expression],
|
||||
defaults: Optional[Expression],
|
||||
kwargs: List[Tuple[str, Expression]],
|
||||
kwargs: RuntimeKwargs,
|
||||
kwarg_pairs: RuntimeKwargPairs,
|
||||
node_id: Optional[str] = None,
|
||||
):
|
||||
self.attributes = attributes
|
||||
self.defaults = defaults
|
||||
self.kwargs = kwargs
|
||||
super().__init__(nodelist=None, args=None, kwargs=kwargs, node_id=node_id)
|
||||
self.kwarg_pairs = kwarg_pairs
|
||||
|
||||
def render(self, context: Context) -> str:
|
||||
append_attrs: List[Tuple[str, Any]] = []
|
||||
|
||||
# Resolve all data
|
||||
for key, value in self.kwargs:
|
||||
resolved_value = safe_resolve(value, context)
|
||||
append_attrs.append((key, resolved_value))
|
||||
kwargs = self.kwargs.resolve(context)
|
||||
attrs = kwargs.pop(HTML_ATTRS_ATTRS_KEY, None) or {}
|
||||
defaults = kwargs.pop(HTML_ATTRS_DEFAULTS_KEY, None) or {}
|
||||
|
||||
defaults = safe_resolve(self.defaults, context) or {} if self.defaults else {}
|
||||
attrs = safe_resolve(self.attributes, context) or {} if self.attributes else {}
|
||||
kwarg_pairs = self.kwarg_pairs.resolve(context)
|
||||
|
||||
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
|
||||
final_attrs = {**defaults, **attrs}
|
||||
|
|
|
@ -23,7 +23,7 @@ from typing import (
|
|||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.forms.widgets import Media
|
||||
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.exceptions import TemplateSyntaxError
|
||||
from django.template.loader import get_template
|
||||
|
@ -42,21 +42,23 @@ from django_components.context import (
|
|||
make_isolated_context_copy,
|
||||
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.middleware import is_dependency_middleware_active
|
||||
from django_components.node import BaseNode
|
||||
from django_components.slots import (
|
||||
DEFAULT_SLOT_KEY,
|
||||
SLOT_DATA_KWARG,
|
||||
SLOT_DEFAULT_KWARG,
|
||||
FillContent,
|
||||
FillNode,
|
||||
SlotContent,
|
||||
SlotName,
|
||||
SlotRef,
|
||||
SlotRenderedContent,
|
||||
SlotResult,
|
||||
_nodelist_to_slot_render_func,
|
||||
resolve_slots,
|
||||
)
|
||||
from django_components.template_parser import process_aggregate_kwargs
|
||||
from django_components.utils import gen_id
|
||||
|
||||
# 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:
|
||||
|
||||
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)
|
||||
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
|
||||
|
||||
|
||||
class ComponentNode(Node):
|
||||
class ComponentNode(BaseNode):
|
||||
"""Django.template.Node subclass that renders a django-components component"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
context_args: List[FilterExpression],
|
||||
context_kwargs: Mapping[str, FilterExpression],
|
||||
args: List[Expression],
|
||||
kwargs: RuntimeKwargs,
|
||||
isolated_context: bool = False,
|
||||
fill_nodes: Optional[List[FillNode]] = None,
|
||||
component_id: Optional[str] = None,
|
||||
node_id: Optional[str] = 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.context_args = context_args or []
|
||||
self.context_kwargs = context_kwargs or {}
|
||||
self.isolated_context = isolated_context
|
||||
self.fill_nodes = fill_nodes or []
|
||||
self.nodelist = NodeList(fill_nodes)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return "<ComponentNode: {}. Contents: {!r}>".format(
|
||||
|
@ -610,16 +614,15 @@ class ComponentNode(Node):
|
|||
)
|
||||
|
||||
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)
|
||||
|
||||
# Resolve FilterExpressions and Variables that were passed as args to the
|
||||
# component, then call component's context method
|
||||
# to get values to insert into the context
|
||||
resolved_context_args = safe_resolve_list(self.context_args, context)
|
||||
resolved_context_kwargs = safe_resolve_dict(self.context_kwargs, context)
|
||||
resolved_context_kwargs = process_aggregate_kwargs(resolved_context_kwargs)
|
||||
args = safe_resolve_list(context, self.args)
|
||||
kwargs = self.kwargs.resolve(context)
|
||||
|
||||
is_default_slot = len(self.fill_nodes) == 1 and self.fill_nodes[0].is_implicit
|
||||
if is_default_slot:
|
||||
|
@ -635,26 +638,25 @@ class ComponentNode(Node):
|
|||
for fill_node in self.fill_nodes:
|
||||
# Note that outer component context is used to resolve variables in
|
||||
# fill tag.
|
||||
resolved_name = fill_node.name_fexp.resolve(context)
|
||||
resolved_name = fill_node.name.resolve(context)
|
||||
if resolved_name in fill_content:
|
||||
raise TemplateSyntaxError(
|
||||
f"Multiple fill tags cannot target the same slot name: "
|
||||
f"Detected duplicate fill tag name '{resolved_name}'."
|
||||
)
|
||||
|
||||
resolved_slot_default_var = fill_node.resolve_slot_default(context, self.name)
|
||||
resolved_slot_data_var = fill_node.resolve_slot_data(context, self.name)
|
||||
fill_kwargs = fill_node.resolve_kwargs(context, self.name)
|
||||
fill_content[resolved_name] = FillContent(
|
||||
content_func=_nodelist_to_slot_render_func(fill_node.nodelist),
|
||||
slot_default_var=resolved_slot_default_var,
|
||||
slot_data_var=resolved_slot_data_var,
|
||||
slot_default_var=fill_kwargs[SLOT_DEFAULT_KWARG],
|
||||
slot_data_var=fill_kwargs[SLOT_DATA_KWARG],
|
||||
)
|
||||
|
||||
component: Component = component_cls(
|
||||
registered_name=self.name,
|
||||
outer_context=context,
|
||||
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
|
||||
|
@ -663,11 +665,11 @@ class ComponentNode(Node):
|
|||
|
||||
output = component._render(
|
||||
context=context,
|
||||
args=resolved_context_args,
|
||||
kwargs=resolved_context_kwargs,
|
||||
args=args,
|
||||
kwargs=kwargs,
|
||||
)
|
||||
|
||||
trace_msg("RENDR", "COMP", self.name, self.component_id, "...Done!")
|
||||
trace_msg("RENDR", "COMP", self.name, self.node_id, "...Done!")
|
||||
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
|
||||
|
||||
|
||||
class AggregateFilterExpression:
|
||||
def __init__(self, dict: Dict[str, FilterExpression]) -> None:
|
||||
self.dict = dict
|
||||
Expression = Union[FilterExpression]
|
||||
RuntimeKwargsInput = Dict[str, Expression]
|
||||
RuntimeKwargPairsInput = List[Tuple[str, Expression]]
|
||||
|
||||
|
||||
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(
|
||||
context: Context,
|
||||
fexp: FilterExpression,
|
||||
) -> str:
|
||||
resolved = fexp.resolve(context)
|
||||
if not isinstance(resolved, str):
|
||||
raise ValueError(
|
||||
f"FilterExpression '{fexp}' was expected to resolve to string, instead got '{type(resolved)}'"
|
||||
)
|
||||
if not resolved.isidentifier():
|
||||
raise ValueError(
|
||||
f"FilterExpression '{fexp}' was expected to resolve to valid identifier, instead got '{resolved}'"
|
||||
)
|
||||
return resolved
|
||||
class RuntimeKwargPairs:
|
||||
def __init__(self, kwarg_pairs: RuntimeKwargPairsInput) -> None:
|
||||
self.kwarg_pairs = kwarg_pairs
|
||||
|
||||
def resolve(self, context: Context) -> List[Tuple[str, Any]]:
|
||||
resolved_kwarg_pairs: List[Tuple[str, Any]] = []
|
||||
for key, kwarg in self.kwarg_pairs:
|
||||
resolved_kwarg_pairs.append((key, kwarg.resolve(context)))
|
||||
|
||||
return resolved_kwarg_pairs
|
||||
|
||||
|
||||
def safe_resolve_list(args: List[Expression], context: Context) -> List:
|
||||
return [safe_resolve(arg, context) for arg in args]
|
||||
def is_identifier(value: Any) -> bool:
|
||||
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(
|
||||
kwargs: Union[Mapping[str, Expression], Dict[str, Expression]],
|
||||
context: Context,
|
||||
) -> Dict:
|
||||
return {key: safe_resolve(kwarg, context) for key, kwarg in kwargs.items()}
|
||||
kwargs: Dict[str, Expression],
|
||||
) -> Dict[str, Any]:
|
||||
result = {}
|
||||
|
||||
|
||||
def safe_resolve(context_item: Expression, context: Context) -> Any:
|
||||
"""Resolve FilterExpressions and Variables in context if possible. Return other items unchanged."""
|
||||
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
|
||||
for key, kwarg in kwargs.items():
|
||||
result[key] = kwarg.resolve(context)
|
||||
return result
|
||||
|
||||
|
||||
def resolve_string(
|
||||
|
@ -57,7 +62,90 @@ def resolve_string(
|
|||
return parser.compile_filter(s).resolve(context)
|
||||
|
||||
|
||||
def is_kwarg(key: str) -> bool:
|
||||
return "=" in key
|
||||
|
||||
|
||||
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(":")
|
||||
|
||||
|
||||
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.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:
|
||||
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.base import FilterExpression, Node, NodeList
|
||||
from django.template.base import NodeList
|
||||
from django.utils.safestring import SafeString
|
||||
|
||||
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.template_parser import process_aggregate_kwargs
|
||||
from django_components.node import BaseNode
|
||||
from django_components.utils import gen_id
|
||||
|
||||
|
||||
class ProvideNode(Node):
|
||||
class ProvideNode(BaseNode):
|
||||
"""
|
||||
Implementation of the `{% provide %}` tag.
|
||||
For more info see `Component.inject`.
|
||||
|
@ -22,29 +22,29 @@ class ProvideNode(Node):
|
|||
name: str,
|
||||
nodelist: NodeList,
|
||||
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.nodelist = nodelist
|
||||
self.node_id = node_id or gen_id()
|
||||
self.provide_kwargs = provide_kwargs or {}
|
||||
self.kwargs = kwargs or RuntimeKwargs({})
|
||||
|
||||
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:
|
||||
trace_msg("RENDR", "PROVIDE", self.name, self.node_id)
|
||||
|
||||
data = safe_resolve_dict(self.provide_kwargs, context)
|
||||
# Allow user to use the var:key=value syntax
|
||||
data = process_aggregate_kwargs(data)
|
||||
kwargs = self.kwargs.resolve(context)
|
||||
|
||||
# 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
|
||||
# add the provided kwargs into the Context.
|
||||
with context.update({}):
|
||||
# "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)
|
||||
|
||||
|
|
|
@ -2,7 +2,8 @@ import difflib
|
|||
import json
|
||||
import re
|
||||
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.base import FilterExpression, Node, NodeList, Parser, TextNode
|
||||
|
@ -16,26 +17,38 @@ from django_components.context import (
|
|||
_INJECT_CONTEXT_KEY_PREFIX,
|
||||
_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.node import NodeTraverse, nodelist_has_content, walk_nodelist
|
||||
from django_components.utils import gen_id
|
||||
from django_components.node import BaseNode, NodeTraverse, nodelist_has_content, walk_nodelist
|
||||
|
||||
TSlotData = TypeVar("TSlotData", bound=Mapping, contravariant=True)
|
||||
|
||||
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
|
||||
SlotName = str
|
||||
SlotDefaultName = 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.:
|
||||
|
||||
|
@ -50,7 +63,7 @@ class FillContent(NamedTuple):
|
|||
```
|
||||
"""
|
||||
|
||||
content_func: SlotRenderFunc
|
||||
content_func: SlotFunc[TSlotData]
|
||||
slot_default_var: Optional[SlotDefaultName]
|
||||
slot_data_var: Optional[SlotDataName]
|
||||
|
||||
|
@ -75,7 +88,8 @@ class Slot(NamedTuple):
|
|||
nodelist: NodeList
|
||||
|
||||
|
||||
class SlotFill(NamedTuple):
|
||||
@dataclass(frozen=True)
|
||||
class SlotFill(Generic[TSlotData]):
|
||||
"""
|
||||
SlotFill describes what WILL be rendered.
|
||||
|
||||
|
@ -85,7 +99,7 @@ class SlotFill(NamedTuple):
|
|||
name: str
|
||||
escaped_name: str
|
||||
is_filled: bool
|
||||
content_func: SlotRenderFunc
|
||||
content_func: SlotFunc[TSlotData]
|
||||
context_data: Mapping
|
||||
slot_default_var: Optional[SlotDefaultName]
|
||||
slot_data_var: Optional[SlotDataName]
|
||||
|
@ -110,22 +124,21 @@ class SlotRef:
|
|||
return mark_safe(self._slot.nodelist.render(self._context))
|
||||
|
||||
|
||||
class SlotNode(Node):
|
||||
class SlotNode(BaseNode):
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
nodelist: NodeList,
|
||||
node_id: Optional[str] = None,
|
||||
kwargs: Optional[RuntimeKwargs] = None,
|
||||
is_required: 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.nodelist = nodelist
|
||||
self.is_required = is_required
|
||||
self.is_default = is_default
|
||||
self.node_id = node_id or gen_id()
|
||||
self.slot_kwargs = slot_kwargs or {}
|
||||
|
||||
@property
|
||||
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
|
||||
# are made available through a variable name that was set on the `{% fill %}`
|
||||
# tag.
|
||||
slot_kwargs = safe_resolve_dict(self.slot_kwargs, context)
|
||||
kwargs = self.kwargs.resolve(context)
|
||||
data_var = slot_fill.slot_data_var
|
||||
if data_var:
|
||||
if not data_var.isidentifier():
|
||||
raise TemplateSyntaxError(
|
||||
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
|
||||
# came from (or current context if configured so)
|
||||
|
@ -187,7 +200,7 @@ class SlotNode(Node):
|
|||
# Render slot as a function
|
||||
# NOTE: While `{% fill %}` tag has to opt in for the `default` and `data` variables,
|
||||
# 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!")
|
||||
return output
|
||||
|
@ -208,7 +221,7 @@ class SlotNode(Node):
|
|||
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
|
||||
excludes `fill` tags. Nodes of this type contribute their nodelists to slots marked
|
||||
|
@ -217,19 +230,16 @@ class FillNode(Node):
|
|||
|
||||
def __init__(
|
||||
self,
|
||||
name: FilterExpression,
|
||||
nodelist: NodeList,
|
||||
name_fexp: FilterExpression,
|
||||
slot_default_var_fexp: Optional[FilterExpression] = None,
|
||||
slot_data_var_fexp: Optional[FilterExpression] = None,
|
||||
is_implicit: bool = False,
|
||||
kwargs: RuntimeKwargs,
|
||||
node_id: Optional[str] = None,
|
||||
is_implicit: bool = False,
|
||||
):
|
||||
self.node_id = node_id or gen_id()
|
||||
self.nodelist = nodelist
|
||||
self.name_fexp = name_fexp
|
||||
self.slot_default_var_fexp = slot_default_var_fexp
|
||||
super().__init__(nodelist=nodelist, args=None, kwargs=kwargs, node_id=node_id)
|
||||
|
||||
self.name = name
|
||||
self.is_implicit = is_implicit
|
||||
self.slot_data_var_fexp = slot_data_var_fexp
|
||||
self.component_id: Optional[str] = None
|
||||
|
||||
def render(self, context: Context) -> str:
|
||||
|
@ -240,33 +250,44 @@ class FillNode(Node):
|
|||
)
|
||||
|
||||
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]:
|
||||
return self.resolve_fexp("slot default", self.slot_default_var_fexp, context, component_name)
|
||||
def resolve_kwargs(self, context: Context, component_name: Optional[str] = None) -> Dict[str, Optional[str]]:
|
||||
kwargs = self.kwargs.resolve(context)
|
||||
|
||||
def resolve_slot_data(self, context: Context, component_name: Optional[str] = None) -> Optional[str]:
|
||||
return self.resolve_fexp("slot data", self.slot_data_var_fexp, context, component_name)
|
||||
default_key = self._resolve_kwarg(kwargs, SLOT_DEFAULT_KWARG, "slot default", 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,
|
||||
kwargs: Dict[str, Any],
|
||||
key: str,
|
||||
name: str,
|
||||
fexp: Optional[FilterExpression],
|
||||
context: Context,
|
||||
component_name: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
if not fexp:
|
||||
if key not in kwargs:
|
||||
return None
|
||||
|
||||
try:
|
||||
resolved_name = resolve_expression_as_identifier(context, fexp)
|
||||
except ValueError as err:
|
||||
raise TemplateSyntaxError(
|
||||
f"Fill tag {name} '{fexp.var}' in component {component_name}"
|
||||
f"does not resolve to a valid Python identifier."
|
||||
) from err
|
||||
value = kwargs[key]
|
||||
if not is_identifier(value):
|
||||
raise RuntimeError(
|
||||
f"Fill tag {name} in component {component_name}"
|
||||
f"does not resolve to a valid Python identifier, got '{value}'"
|
||||
)
|
||||
|
||||
return resolved_name
|
||||
return value
|
||||
|
||||
|
||||
def parse_slot_fill_nodes_from_component_nodelist(
|
||||
|
@ -316,18 +337,18 @@ def _try_parse_as_named_fill_tag_set(
|
|||
ComponentNodeCls: Type[Node],
|
||||
) -> List[FillNode]:
|
||||
result = []
|
||||
seen_name_fexps: Set[str] = set()
|
||||
seen_names: Set[str] = set()
|
||||
for node in nodelist:
|
||||
if isinstance(node, FillNode):
|
||||
# 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
|
||||
# them.
|
||||
if node.name_fexp.token in seen_name_fexps:
|
||||
if node.name.token in seen_names:
|
||||
raise TemplateSyntaxError(
|
||||
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)
|
||||
elif isinstance(node, CommentNode):
|
||||
pass
|
||||
|
@ -357,7 +378,8 @@ def _try_parse_as_default_fill(
|
|||
return [
|
||||
FillNode(
|
||||
nodelist=nodelist,
|
||||
name_fexp=FilterExpression(json.dumps(DEFAULT_SLOT_KEY), Parser("")),
|
||||
name=FilterExpression(json.dumps(DEFAULT_SLOT_KEY), Parser("")),
|
||||
kwargs=RuntimeKwargs({}),
|
||||
is_implicit=True,
|
||||
)
|
||||
]
|
||||
|
@ -609,8 +631,8 @@ def _escape_slot_name(name: str) -> str:
|
|||
return escaped_name
|
||||
|
||||
|
||||
def _nodelist_to_slot_render_func(nodelist: NodeList) -> SlotRenderFunc:
|
||||
def render_func(ctx: Context, slot_kwargs: Dict[str, Any], slot_ref: SlotRef) -> SlotRenderedContent:
|
||||
def _nodelist_to_slot_render_func(nodelist: NodeList) -> SlotFunc:
|
||||
def render_func(ctx: Context, kwargs: Dict[str, Any], slot_ref: SlotRef) -> SlotResult:
|
||||
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
|
||||
from typing import Any, Dict, List, Mapping, Tuple
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
from django.template.base import (
|
||||
FILTER_ARGUMENT_SEPARATOR,
|
||||
|
@ -205,88 +205,3 @@ def parse_bits(
|
|||
% (name, ", ".join("'%s'" % p for p in unhandled_params))
|
||||
)
|
||||
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
|
||||
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_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_registry import ComponentRegistry
|
||||
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.middleware import (
|
||||
CSS_DEPENDENCY_PLACEHOLDER,
|
||||
|
@ -18,9 +26,17 @@ from django_components.middleware import (
|
|||
is_dependency_middleware_active,
|
||||
)
|
||||
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.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
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
@ -32,12 +48,6 @@ if TYPE_CHECKING:
|
|||
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"]:
|
||||
"""Returns a list unique components from the registry."""
|
||||
|
||||
|
@ -123,7 +133,7 @@ def slot(parser: Parser, token: Token) -> SlotNode:
|
|||
parser,
|
||||
bits,
|
||||
params=["name"],
|
||||
flags=[SLOT_DEFAULT_OPTION_KEYWORD, SLOT_REQUIRED_OPTION_KEYWORD],
|
||||
flags=[SLOT_DEFAULT_KEYWORD, SLOT_REQUIRED_KEYWORD],
|
||||
keywordonly_kwargs=True,
|
||||
repeatable_kwargs=False,
|
||||
end_tag="endslot",
|
||||
|
@ -136,10 +146,10 @@ def slot(parser: Parser, token: Token) -> SlotNode:
|
|||
slot_node = SlotNode(
|
||||
name=data.name,
|
||||
nodelist=body,
|
||||
is_required=tag.flags[SLOT_REQUIRED_OPTION_KEYWORD],
|
||||
is_default=tag.flags[SLOT_DEFAULT_OPTION_KEYWORD],
|
||||
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!")
|
||||
|
@ -162,24 +172,23 @@ def fill(parser: Parser, token: Token) -> FillNode:
|
|||
parser,
|
||||
bits,
|
||||
params=["name"],
|
||||
keywordonly_kwargs=[SLOT_DATA_ATTR, SLOT_DEFAULT_ATTR],
|
||||
keywordonly_kwargs=[SLOT_DATA_KWARG, SLOT_DEFAULT_KWARG],
|
||||
repeatable_kwargs=False,
|
||||
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()
|
||||
fill_node = FillNode(
|
||||
nodelist=body,
|
||||
name_fexp=data.slot_name,
|
||||
slot_default_var_fexp=data.slot_default_var,
|
||||
slot_data_var_fexp=data.slot_data_var,
|
||||
name=slot_name,
|
||||
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
|
||||
|
||||
|
||||
|
@ -216,7 +225,9 @@ def component(parser: Parser, token: Token, tag_name: str) -> ComponentNode:
|
|||
repeatable_kwargs=False,
|
||||
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)
|
||||
|
||||
|
@ -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
|
||||
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
|
||||
|
||||
component_node = ComponentNode(
|
||||
name=result.component_name,
|
||||
context_args=tag.args,
|
||||
context_kwargs=tag.kwargs,
|
||||
isolated_context=data.isolated_context,
|
||||
args=tag.args,
|
||||
kwargs=tag.kwargs,
|
||||
isolated_context=isolated_context,
|
||||
fill_nodes=fill_nodes,
|
||||
component_id=tag.id,
|
||||
node_id=tag.id,
|
||||
)
|
||||
|
||||
trace_msg("PARSE", "COMP", result.component_name, tag.id, "...Done!")
|
||||
|
@ -264,7 +275,7 @@ def provide(parser: Parser, token: Token) -> ProvideNode:
|
|||
name=data.key,
|
||||
nodelist=body,
|
||||
node_id=tag.id,
|
||||
provide_kwargs=tag.kwargs,
|
||||
kwargs=tag.kwargs,
|
||||
)
|
||||
|
||||
trace_msg("PARSE", "PROVIDE", data.key, tag.id, "...Done!")
|
||||
|
@ -305,17 +316,16 @@ def html_attrs(parser: Parser, token: Token) -> HtmlAttrsNode:
|
|||
"html_attrs",
|
||||
parser,
|
||||
bits,
|
||||
params=["attrs", "defaults"],
|
||||
optional_params=["attrs", "defaults"],
|
||||
params=[HTML_ATTRS_ATTRS_KEY, HTML_ATTRS_DEFAULTS_KEY],
|
||||
optional_params=[HTML_ATTRS_ATTRS_KEY, HTML_ATTRS_DEFAULTS_KEY],
|
||||
flags=[],
|
||||
keywordonly_kwargs=True,
|
||||
repeatable_kwargs=True,
|
||||
)
|
||||
|
||||
return HtmlAttrsNode(
|
||||
attributes=tag.kwargs.get("attrs"),
|
||||
defaults=tag.kwargs.get("defaults"),
|
||||
kwargs=[(key, val) for key, val in tag.kwarg_pairs if key != "attrs" and key != "defaults"],
|
||||
kwargs=tag.kwargs,
|
||||
kwarg_pairs=tag.kwarg_pairs,
|
||||
)
|
||||
|
||||
|
||||
|
@ -324,10 +334,10 @@ class ParsedTag(NamedTuple):
|
|||
name: str
|
||||
bits: List[str]
|
||||
flags: Dict[str, bool]
|
||||
args: List[FilterExpression]
|
||||
named_args: Dict[str, FilterExpression]
|
||||
kwargs: Dict[str, Expression]
|
||||
kwarg_pairs: List[Tuple[str, Expression]]
|
||||
args: List[Expression]
|
||||
named_args: Dict[str, Expression]
|
||||
kwargs: RuntimeKwargs
|
||||
kwarg_pairs: RuntimeKwargPairs
|
||||
is_inline: bool
|
||||
parse_body: Callable[[], NodeList]
|
||||
|
||||
|
@ -380,17 +390,13 @@ def _parse_tag(
|
|||
seen_kwargs.add(key)
|
||||
|
||||
for bit in bits:
|
||||
# Extract flags, which are like keywords but without the value part
|
||||
if bit in parsed_flags:
|
||||
parsed_flags[bit] = True
|
||||
continue
|
||||
else:
|
||||
bits_without_flags.append(bit)
|
||||
value = bit
|
||||
bit_is_kwarg = is_kwarg(bit)
|
||||
|
||||
# Record which kwargs we've seen, to detect if kwargs were passed in
|
||||
# as both aggregate and regular kwargs
|
||||
if "=" in bit:
|
||||
key = bit.split("=")[0]
|
||||
if bit_is_kwarg:
|
||||
key, value = bit.split("=", 1)
|
||||
|
||||
# Also pick up on aggregate keys like `attr:key=val`
|
||||
if is_aggregate_key(key):
|
||||
|
@ -399,6 +405,14 @@ def _parse_tag(
|
|||
else:
|
||||
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
|
||||
|
||||
# To support optional args, we need to convert these to kwargs, so `parse_bits`
|
||||
|
@ -415,7 +429,7 @@ def _parse_tag(
|
|||
new_params = []
|
||||
new_kwargs = []
|
||||
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
|
||||
new_kwargs.extend(bits[index:])
|
||||
break
|
||||
|
@ -436,31 +450,13 @@ def _parse_tag(
|
|||
params = [param for param in params_to_sort if param not in optional_params]
|
||||
|
||||
# Parse args/kwargs that will be passed to the fill
|
||||
args, raw_kwarg_pairs = parse_bits(
|
||||
args, kwarg_pairs = parse_bits(
|
||||
parser=parser,
|
||||
bits=bits,
|
||||
params=[] if isinstance(params, bool) else params,
|
||||
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
|
||||
if params != True and len(args) > len(params): # noqa F712
|
||||
raise TemplateSyntaxError(f"Tag '{tag_name}' received unexpected positional arguments: {args[len(params):]}")
|
||||
|
@ -472,7 +468,7 @@ def _parse_tag(
|
|||
named_args = {}
|
||||
|
||||
# Validate kwargs
|
||||
kwargs: Dict[str, Expression] = {}
|
||||
kwargs: RuntimeKwargsInput = {}
|
||||
extra_keywords: Set[str] = set()
|
||||
for key, val in kwarg_pairs:
|
||||
# Check if key allowed
|
||||
|
@ -506,8 +502,8 @@ def _parse_tag(
|
|||
flags=parsed_flags,
|
||||
args=args,
|
||||
named_args=named_args,
|
||||
kwargs=kwargs,
|
||||
kwarg_pairs=kwarg_pairs,
|
||||
kwargs=RuntimeKwargs(kwargs),
|
||||
kwarg_pairs=RuntimeKwargPairs(kwarg_pairs),
|
||||
# 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
|
||||
# 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
|
||||
|
||||
|
||||
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):
|
||||
name: str
|
||||
|
||||
|
@ -548,7 +530,10 @@ def _parse_slot_args(
|
|||
parser: Parser,
|
||||
tag: ParsedTag,
|
||||
) -> 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):
|
||||
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)
|
||||
|
||||
|
||||
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):
|
||||
key: str
|
||||
|
||||
|
@ -605,9 +550,12 @@ def _parse_provide_args(
|
|||
parser: Parser,
|
||||
tag: ParsedTag,
|
||||
) -> 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):
|
||||
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)
|
||||
|
||||
|
|
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_components import Component, registry, types
|
||||
from django_components.component import safe_resolve_dict, safe_resolve_list
|
||||
from django_components.template_parser import is_aggregate_key, process_aggregate_kwargs
|
||||
from django_components.expression import (
|
||||
is_aggregate_key,
|
||||
process_aggregate_kwargs,
|
||||
safe_resolve_dict,
|
||||
safe_resolve_list,
|
||||
)
|
||||
from django_components.templatetags.component_tags import _parse_tag
|
||||
|
||||
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)
|
||||
|
||||
ctx = {"myvar": {"a": "b"}, "val2": 1}
|
||||
args = safe_resolve_list(tag.args, ctx)
|
||||
named_args = safe_resolve_dict(tag.named_args, ctx)
|
||||
kwargs = safe_resolve_dict(tag.kwargs, ctx)
|
||||
args = safe_resolve_list(ctx, tag.args)
|
||||
named_args = safe_resolve_dict(ctx, tag.named_args)
|
||||
kwargs = tag.kwargs.resolve(ctx)
|
||||
|
||||
self.assertListEqual(args, [42, {"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)
|
||||
|
||||
ctx = Context({"date": 2024, "bzz": "fzz"})
|
||||
args = safe_resolve_list(tag.args, ctx)
|
||||
kwargs = safe_resolve_dict(tag.kwargs, ctx)
|
||||
args = safe_resolve_list(ctx, tag.args)
|
||||
kwargs = tag.kwargs.resolve(ctx)
|
||||
|
||||
self.assertListEqual(args, [])
|
||||
self.assertDictEqual(
|
||||
|
|
|
@ -822,8 +822,8 @@ class ScopedSlotTest(BaseTestCase):
|
|||
{% endcomponent %}
|
||||
"""
|
||||
with self.assertRaisesMessage(
|
||||
TemplateSyntaxError,
|
||||
"'fill' received the same string for slot default (default=...) and slot data (data=...)",
|
||||
RuntimeError,
|
||||
'Fill "my_slot" received the same string for slot default (default=...) and slot data (data=...)',
|
||||
):
|
||||
Template(template).render(Context())
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue