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:
Juro Oravec 2024-08-23 18:15:28 +02:00 committed by GitHub
parent 83dcc3fe80
commit 899b9a2738
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 448 additions and 371 deletions

View file

@ -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):

View file

@ -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,

View file

@ -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}

View file

@ -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

View file

@ -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

View file

@ -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:

View file

@ -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)

View file

@ -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]

View file

@ -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(":")

View file

@ -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
View 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": ""},
)

View file

@ -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(

View file

@ -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())