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, context: Mapping | django.template.Context | None = None,
args: List[Any] | None = None, args: List[Any] | None = None,
kwargs: Dict[str, Any] | None = None, kwargs: Dict[str, Any] | None = None,
slots: Dict[str, str | SafeString | SlotRenderFunc] | None = None, slots: Dict[str, str | SafeString | SlotFunc] | None = None,
escape_slots_content: bool = True escape_slots_content: bool = True
) -> str: ) -> str:
``` ```
@ -550,7 +550,7 @@ Component.render(
- _`slots`_ - Component slot fills. This is the same as pasing `{% fill %}` tags to the component. - _`slots`_ - Component slot fills. This is the same as pasing `{% fill %}` tags to the component.
Accepts a dictionary of `{ slot_name: slot_content }` where `slot_content` can be a string Accepts a dictionary of `{ slot_name: slot_content }` where `slot_content` can be a string
or [`SlotRenderFunc`](#slotrenderfunc). or [`SlotFunc`](#slotfunc).
- _`escape_slots_content`_ - Whether the content from `slots` should be escaped. `True` by default to prevent XSS attacks. If you disable escaping, you should make sure that any content you pass to the slots is safe, especially if it comes from user input. - _`escape_slots_content`_ - Whether the content from `slots` should be escaped. `True` by default to prevent XSS attacks. If you disable escaping, you should make sure that any content you pass to the slots is safe, especially if it comes from user input.
@ -559,7 +559,7 @@ Component.render(
- NOTE: In "isolated" mode, context is NOT accessible, and data MUST be passed via - NOTE: In "isolated" mode, context is NOT accessible, and data MUST be passed via
component's args and kwargs. component's args and kwargs.
#### `SlotRenderFunc` #### `SlotFunc`
When rendering components with slots in `render` or `render_to_response`, you can pass either a string or a function. When rendering components with slots in `render` or `render_to_response`, you can pass either a string or a function.
@ -602,24 +602,30 @@ that allow you to specify the types of args, kwargs, slots, and
data. data.
```py ```py
from typing import NotRequired, Tuple, TypedDict from typing import NotRequired, Tuple, TypedDict, SlotFunc
# Tuple # Positional inputs - Tuple
Args = Tuple[int, str] Args = Tuple[int, str]
# Mapping # Kwargs inputs - Mapping
class Kwargs(TypedDict): class Kwargs(TypedDict):
variable: str variable: str
another: int another: int
maybe_var: NotRequired[int] maybe_var: NotRequired[int]
# Mapping # Data returned from `get_context_data` - Mapping
class Data(TypedDict): class Data(TypedDict):
variable: str variable: str
# Mapping # The data available to the `my_slot` scoped slot
class MySlotData(TypedDict):
value: int
# Slot functions - Mapping
class Slots(TypedDict): class Slots(TypedDict):
my_slot: NotRequired[str] # Use SlotFunc for slot functions.
# The generic specifies the `data` dictionary
my_slot: NotRequired[SlotFunc[MySlotData]]
class Button(Component[Args, Kwargs, Data, Slots]): class Button(Component[Args, Kwargs, Data, Slots]):
def get_context_data(self, variable, another): def get_context_data(self, variable, another):

View file

@ -19,6 +19,10 @@ from django_components.component_registry import (
registry as registry, registry as registry,
) )
from django_components.library import TagProtectedError as TagProtectedError from django_components.library import TagProtectedError as TagProtectedError
from django_components.slots import (
SlotContent as SlotContent,
SlotFunc as SlotFunc,
)
from django_components.tag_formatter import ( from django_components.tag_formatter import (
ComponentFormatter as ComponentFormatter, ComponentFormatter as ComponentFormatter,
ShorthandComponentFormatter as ShorthandComponentFormatter, ShorthandComponentFormatter as ShorthandComponentFormatter,

View file

@ -4,37 +4,46 @@
from typing import Any, Dict, List, Mapping, Optional, Tuple from typing import Any, Dict, List, Mapping, Optional, Tuple
from django.template import Context, Node from django.template import Context
from django.utils.html import conditional_escape, format_html from django.utils.html import conditional_escape, format_html
from django.utils.safestring import SafeString, mark_safe from django.utils.safestring import SafeString, mark_safe
from django_components.expression import Expression, safe_resolve from django_components.expression import RuntimeKwargPairs, RuntimeKwargs
from django_components.node import BaseNode
HTML_ATTRS_DEFAULTS_KEY = "defaults" HTML_ATTRS_DEFAULTS_KEY = "defaults"
HTML_ATTRS_ATTRS_KEY = "attrs" HTML_ATTRS_ATTRS_KEY = "attrs"
class HtmlAttrsNode(Node): class HtmlAttrsNode(BaseNode):
def __init__( def __init__(
self, self,
attributes: Optional[Expression], kwargs: RuntimeKwargs,
defaults: Optional[Expression], kwarg_pairs: RuntimeKwargPairs,
kwargs: List[Tuple[str, Expression]], node_id: Optional[str] = None,
): ):
self.attributes = attributes super().__init__(nodelist=None, args=None, kwargs=kwargs, node_id=node_id)
self.defaults = defaults self.kwarg_pairs = kwarg_pairs
self.kwargs = kwargs
def render(self, context: Context) -> str: def render(self, context: Context) -> str:
append_attrs: List[Tuple[str, Any]] = [] append_attrs: List[Tuple[str, Any]] = []
# Resolve all data # Resolve all data
for key, value in self.kwargs: kwargs = self.kwargs.resolve(context)
resolved_value = safe_resolve(value, context) attrs = kwargs.pop(HTML_ATTRS_ATTRS_KEY, None) or {}
append_attrs.append((key, resolved_value)) defaults = kwargs.pop(HTML_ATTRS_DEFAULTS_KEY, None) or {}
defaults = safe_resolve(self.defaults, context) or {} if self.defaults else {} kwarg_pairs = self.kwarg_pairs.resolve(context)
attrs = safe_resolve(self.attributes, context) or {} if self.attributes else {}
for key, value in kwarg_pairs:
if (
key in [HTML_ATTRS_ATTRS_KEY, HTML_ATTRS_DEFAULTS_KEY]
or key.startswith(f"{HTML_ATTRS_ATTRS_KEY}:")
or key.startswith(f"{HTML_ATTRS_DEFAULTS_KEY}:")
):
continue
append_attrs.append((key, value))
# Merge it # Merge it
final_attrs = {**defaults, **attrs} final_attrs = {**defaults, **attrs}

View file

@ -23,7 +23,7 @@ from typing import (
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
from django.forms.widgets import Media from django.forms.widgets import Media
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
from django.template.base import FilterExpression, Node, NodeList, Template, TextNode from django.template.base import NodeList, Template, TextNode
from django.template.context import Context from django.template.context import Context
from django.template.exceptions import TemplateSyntaxError from django.template.exceptions import TemplateSyntaxError
from django.template.loader import get_template from django.template.loader import get_template
@ -42,21 +42,23 @@ from django_components.context import (
make_isolated_context_copy, make_isolated_context_copy,
prepare_context, prepare_context,
) )
from django_components.expression import safe_resolve_dict, safe_resolve_list from django_components.expression import Expression, RuntimeKwargs, safe_resolve_list
from django_components.logger import trace_msg from django_components.logger import trace_msg
from django_components.middleware import is_dependency_middleware_active from django_components.middleware import is_dependency_middleware_active
from django_components.node import BaseNode
from django_components.slots import ( from django_components.slots import (
DEFAULT_SLOT_KEY, DEFAULT_SLOT_KEY,
SLOT_DATA_KWARG,
SLOT_DEFAULT_KWARG,
FillContent, FillContent,
FillNode, FillNode,
SlotContent, SlotContent,
SlotName, SlotName,
SlotRef, SlotRef,
SlotRenderedContent, SlotResult,
_nodelist_to_slot_render_func, _nodelist_to_slot_render_func,
resolve_slots, resolve_slots,
) )
from django_components.template_parser import process_aggregate_kwargs
from django_components.utils import gen_id from django_components.utils import gen_id
# TODO_DEPRECATE_V1 - REMOVE IN V1, users should use top-level import instead # TODO_DEPRECATE_V1 - REMOVE IN V1, users should use top-level import instead
@ -571,7 +573,11 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co
) )
else: else:
def content_func(ctx: Context, kwargs: Dict[str, Any], slot_ref: SlotRef) -> SlotRenderedContent: def content_func( # type: ignore[misc]
ctx: Context,
kwargs: Dict[str, Any],
slot_ref: SlotRef,
) -> SlotResult:
rendered = content(ctx, kwargs, slot_ref) rendered = content(ctx, kwargs, slot_ref)
return conditional_escape(rendered) if escape_content else rendered return conditional_escape(rendered) if escape_content else rendered
@ -583,25 +589,23 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co
return slot_fills return slot_fills
class ComponentNode(Node): class ComponentNode(BaseNode):
"""Django.template.Node subclass that renders a django-components component""" """Django.template.Node subclass that renders a django-components component"""
def __init__( def __init__(
self, self,
name: str, name: str,
context_args: List[FilterExpression], args: List[Expression],
context_kwargs: Mapping[str, FilterExpression], kwargs: RuntimeKwargs,
isolated_context: bool = False, isolated_context: bool = False,
fill_nodes: Optional[List[FillNode]] = None, fill_nodes: Optional[List[FillNode]] = None,
component_id: Optional[str] = None, node_id: Optional[str] = None,
) -> None: ) -> None:
self.component_id = component_id or gen_id() super().__init__(nodelist=NodeList(fill_nodes), args=args, kwargs=kwargs, node_id=node_id)
self.name = name self.name = name
self.context_args = context_args or []
self.context_kwargs = context_kwargs or {}
self.isolated_context = isolated_context self.isolated_context = isolated_context
self.fill_nodes = fill_nodes or [] self.fill_nodes = fill_nodes or []
self.nodelist = NodeList(fill_nodes)
def __repr__(self) -> str: def __repr__(self) -> str:
return "<ComponentNode: {}. Contents: {!r}>".format( return "<ComponentNode: {}. Contents: {!r}>".format(
@ -610,16 +614,15 @@ class ComponentNode(Node):
) )
def render(self, context: Context) -> str: def render(self, context: Context) -> str:
trace_msg("RENDR", "COMP", self.name, self.component_id) trace_msg("RENDR", "COMP", self.name, self.node_id)
component_cls: Type[Component] = registry.get(self.name) component_cls: Type[Component] = registry.get(self.name)
# Resolve FilterExpressions and Variables that were passed as args to the # Resolve FilterExpressions and Variables that were passed as args to the
# component, then call component's context method # component, then call component's context method
# to get values to insert into the context # to get values to insert into the context
resolved_context_args = safe_resolve_list(self.context_args, context) args = safe_resolve_list(context, self.args)
resolved_context_kwargs = safe_resolve_dict(self.context_kwargs, context) kwargs = self.kwargs.resolve(context)
resolved_context_kwargs = process_aggregate_kwargs(resolved_context_kwargs)
is_default_slot = len(self.fill_nodes) == 1 and self.fill_nodes[0].is_implicit is_default_slot = len(self.fill_nodes) == 1 and self.fill_nodes[0].is_implicit
if is_default_slot: if is_default_slot:
@ -635,26 +638,25 @@ class ComponentNode(Node):
for fill_node in self.fill_nodes: for fill_node in self.fill_nodes:
# Note that outer component context is used to resolve variables in # Note that outer component context is used to resolve variables in
# fill tag. # fill tag.
resolved_name = fill_node.name_fexp.resolve(context) resolved_name = fill_node.name.resolve(context)
if resolved_name in fill_content: if resolved_name in fill_content:
raise TemplateSyntaxError( raise TemplateSyntaxError(
f"Multiple fill tags cannot target the same slot name: " f"Multiple fill tags cannot target the same slot name: "
f"Detected duplicate fill tag name '{resolved_name}'." f"Detected duplicate fill tag name '{resolved_name}'."
) )
resolved_slot_default_var = fill_node.resolve_slot_default(context, self.name) fill_kwargs = fill_node.resolve_kwargs(context, self.name)
resolved_slot_data_var = fill_node.resolve_slot_data(context, self.name)
fill_content[resolved_name] = FillContent( fill_content[resolved_name] = FillContent(
content_func=_nodelist_to_slot_render_func(fill_node.nodelist), content_func=_nodelist_to_slot_render_func(fill_node.nodelist),
slot_default_var=resolved_slot_default_var, slot_default_var=fill_kwargs[SLOT_DEFAULT_KWARG],
slot_data_var=resolved_slot_data_var, slot_data_var=fill_kwargs[SLOT_DATA_KWARG],
) )
component: Component = component_cls( component: Component = component_cls(
registered_name=self.name, registered_name=self.name,
outer_context=context, outer_context=context,
fill_content=fill_content, fill_content=fill_content,
component_id=self.component_id, component_id=self.node_id,
) )
# Prevent outer context from leaking into the template of the component # Prevent outer context from leaking into the template of the component
@ -663,11 +665,11 @@ class ComponentNode(Node):
output = component._render( output = component._render(
context=context, context=context,
args=resolved_context_args, args=args,
kwargs=resolved_context_kwargs, kwargs=kwargs,
) )
trace_msg("RENDR", "COMP", self.name, self.component_id, "...Done!") trace_msg("RENDR", "COMP", self.name, self.node_id, "...Done!")
return output return output

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 from django.template.base import FilterExpression, Parser
Expression = Union[FilterExpression]
class AggregateFilterExpression: RuntimeKwargsInput = Dict[str, Expression]
def __init__(self, dict: Dict[str, FilterExpression]) -> None: RuntimeKwargPairsInput = List[Tuple[str, Expression]]
self.dict = dict
Expression = Union[FilterExpression, AggregateFilterExpression] class RuntimeKwargs:
def __init__(self, kwargs: RuntimeKwargsInput) -> None:
self.kwargs = kwargs
def resolve(self, context: Context) -> Dict[str, Any]:
resolved_kwargs = safe_resolve_dict(context, self.kwargs)
return process_aggregate_kwargs(resolved_kwargs)
def resolve_expression_as_identifier( class RuntimeKwargPairs:
context: Context, def __init__(self, kwarg_pairs: RuntimeKwargPairsInput) -> None:
fexp: FilterExpression, self.kwarg_pairs = kwarg_pairs
) -> str:
resolved = fexp.resolve(context) def resolve(self, context: Context) -> List[Tuple[str, Any]]:
if not isinstance(resolved, str): resolved_kwarg_pairs: List[Tuple[str, Any]] = []
raise ValueError( for key, kwarg in self.kwarg_pairs:
f"FilterExpression '{fexp}' was expected to resolve to string, instead got '{type(resolved)}'" resolved_kwarg_pairs.append((key, kwarg.resolve(context)))
)
if not resolved.isidentifier(): return resolved_kwarg_pairs
raise ValueError(
f"FilterExpression '{fexp}' was expected to resolve to valid identifier, instead got '{resolved}'"
)
return resolved
def safe_resolve_list(args: List[Expression], context: Context) -> List: def is_identifier(value: Any) -> bool:
return [safe_resolve(arg, context) for arg in args] if not isinstance(value, str):
return False
if not value.isidentifier():
return False
return True
def safe_resolve_list(context: Context, args: List[Expression]) -> List:
return [arg.resolve(context) for arg in args]
def safe_resolve_dict( def safe_resolve_dict(
kwargs: Union[Mapping[str, Expression], Dict[str, Expression]],
context: Context, context: Context,
) -> Dict: kwargs: Dict[str, Expression],
return {key: safe_resolve(kwarg, context) for key, kwarg in kwargs.items()} ) -> Dict[str, Any]:
result = {}
for key, kwarg in kwargs.items():
def safe_resolve(context_item: Expression, context: Context) -> Any: result[key] = kwarg.resolve(context)
"""Resolve FilterExpressions and Variables in context if possible. Return other items unchanged.""" return result
if isinstance(context_item, AggregateFilterExpression):
return safe_resolve_dict(context_item.dict, context)
return context_item.resolve(context) if hasattr(context_item, "resolve") else context_item
def resolve_string( def resolve_string(
@ -57,7 +62,90 @@ def resolve_string(
return parser.compile_filter(s).resolve(context) return parser.compile_filter(s).resolve(context)
def is_kwarg(key: str) -> bool:
return "=" in key
def is_aggregate_key(key: str) -> bool: def is_aggregate_key(key: str) -> bool:
# NOTE: If we get a key that starts with `:`, like `:class`, we do not split it. # NOTE: If we get a key that starts with `:`, like `:class`, we do not split it.
# This syntax is used by Vue and AlpineJS. # This syntax is used by Vue and AlpineJS.
return ":" in key and not key.startswith(":") return ":" in key and not key.startswith(":")
def process_aggregate_kwargs(kwargs: Mapping[str, Any]) -> Dict[str, Any]:
"""
This function aggregates "prefixed" kwargs into dicts. "Prefixed" kwargs
start with some prefix delimited with `:` (e.g. `attrs:`).
Example:
```py
process_component_kwargs({"abc:one": 1, "abc:two": 2, "def:three": 3, "four": 4})
# {"abc": {"one": 1, "two": 2}, "def": {"three": 3}, "four": 4}
```
---
We want to support a use case similar to Vue's fallthrough attributes.
In other words, where a component author can designate a prop (input)
which is a dict and which will be rendered as HTML attributes.
This is useful for allowing component users to tweak styling or add
event handling to the underlying HTML. E.g.:
`class="pa-4 d-flex text-black"` or `@click.stop="alert('clicked!')"`
So if the prop is `attrs`, and the component is called like so:
```django
{% component "my_comp" attrs=attrs %}
```
then, if `attrs` is:
```py
{"class": "text-red pa-4", "@click": "dispatch('my_event', 123)"}
```
and the component template is:
```django
<div {% html_attrs attrs add:class="extra-class" %}></div>
```
Then this renders:
```html
<div class="text-red pa-4 extra-class" @click="dispatch('my_event', 123)" ></div>
```
However, this way it is difficult for the component user to define the `attrs`
variable, especially if they want to combine static and dynamic values. Because
they will need to pre-process the `attrs` dict.
So, instead, we allow to "aggregate" props into a dict. So all props that start
with `attrs:`, like `attrs:class="text-red"`, will be collected into a dict
at key `attrs`.
This provides sufficient flexiblity to make it easy for component users to provide
"fallthrough attributes", and sufficiently easy for component authors to process
that input while still being able to provide their own keys.
"""
processed_kwargs = {}
nested_kwargs: Dict[str, Dict[str, Any]] = {}
for key, val in kwargs.items():
if not is_aggregate_key(key):
processed_kwargs[key] = val
continue
# NOTE: Trim off the prefix from keys
prefix, sub_key = key.split(":", 1)
if prefix not in nested_kwargs:
nested_kwargs[prefix] = {}
nested_kwargs[prefix][sub_key] = val
# Assign aggregated values into normal input
for key, val in nested_kwargs.items():
if key in processed_kwargs:
raise TemplateSyntaxError(
f"Received argument '{key}' both as a regular input ({key}=...)"
f" and as an aggregate dict ('{key}:key=...'). Must be only one of the two"
)
processed_kwargs[key] = val
return processed_kwargs

View file

@ -5,6 +5,25 @@ from django.template.base import Node, NodeList, TextNode
from django.template.defaulttags import CommentNode from django.template.defaulttags import CommentNode
from django.template.loader_tags import ExtendsNode, IncludeNode, construct_relative_path from django.template.loader_tags import ExtendsNode, IncludeNode, construct_relative_path
from django_components.expression import Expression, RuntimeKwargs
from django_components.utils import gen_id
class BaseNode(Node):
"""Shared behavior for our subclasses of Django's `Node`"""
def __init__(
self,
nodelist: Optional[NodeList] = None,
node_id: Optional[str] = None,
args: Optional[List[Expression]] = None,
kwargs: Optional[RuntimeKwargs] = None,
):
self.nodelist = nodelist or NodeList()
self.node_id = node_id or gen_id()
self.args = args or []
self.kwargs = kwargs or RuntimeKwargs({})
def nodelist_has_content(nodelist: NodeList) -> bool: def nodelist_has_content(nodelist: NodeList) -> bool:
for node in nodelist: for node in nodelist:

View file

@ -1,17 +1,17 @@
from typing import Dict, Optional from typing import Optional
from django.template import Context from django.template import Context
from django.template.base import FilterExpression, Node, NodeList from django.template.base import NodeList
from django.utils.safestring import SafeString from django.utils.safestring import SafeString
from django_components.context import set_provided_context_var from django_components.context import set_provided_context_var
from django_components.expression import safe_resolve_dict from django_components.expression import RuntimeKwargs
from django_components.logger import trace_msg from django_components.logger import trace_msg
from django_components.template_parser import process_aggregate_kwargs from django_components.node import BaseNode
from django_components.utils import gen_id from django_components.utils import gen_id
class ProvideNode(Node): class ProvideNode(BaseNode):
""" """
Implementation of the `{% provide %}` tag. Implementation of the `{% provide %}` tag.
For more info see `Component.inject`. For more info see `Component.inject`.
@ -22,29 +22,29 @@ class ProvideNode(Node):
name: str, name: str,
nodelist: NodeList, nodelist: NodeList,
node_id: Optional[str] = None, node_id: Optional[str] = None,
provide_kwargs: Optional[Dict[str, FilterExpression]] = None, kwargs: Optional[RuntimeKwargs] = None,
): ):
super().__init__(nodelist=nodelist, args=None, kwargs=kwargs, node_id=node_id)
self.name = name self.name = name
self.nodelist = nodelist self.nodelist = nodelist
self.node_id = node_id or gen_id() self.node_id = node_id or gen_id()
self.provide_kwargs = provide_kwargs or {} self.kwargs = kwargs or RuntimeKwargs({})
def __repr__(self) -> str: def __repr__(self) -> str:
return f"<Provide Node: {self.name}. Contents: {repr(self.nodelist)}. Data: {self.provide_kwargs}>" return f"<Provide Node: {self.name}. Contents: {repr(self.nodelist)}. Data: {self.provide_kwargs.kwargs}>"
def render(self, context: Context) -> SafeString: def render(self, context: Context) -> SafeString:
trace_msg("RENDR", "PROVIDE", self.name, self.node_id) trace_msg("RENDR", "PROVIDE", self.name, self.node_id)
data = safe_resolve_dict(self.provide_kwargs, context) kwargs = self.kwargs.resolve(context)
# Allow user to use the var:key=value syntax
data = process_aggregate_kwargs(data)
# NOTE: The "provided" kwargs are meant to be shared privately, meaning that components # NOTE: The "provided" kwargs are meant to be shared privately, meaning that components
# have to explicitly opt in by using the `Component.inject()` method. That's why we don't # have to explicitly opt in by using the `Component.inject()` method. That's why we don't
# add the provided kwargs into the Context. # add the provided kwargs into the Context.
with context.update({}): with context.update({}):
# "Provide" the data to child nodes # "Provide" the data to child nodes
set_provided_context_var(context, self.name, data) set_provided_context_var(context, self.name, kwargs)
output = self.nodelist.render(context) output = self.nodelist.render(context)

View file

@ -2,7 +2,8 @@ import difflib
import json import json
import re import re
from collections import deque from collections import deque
from typing import Any, Callable, Dict, List, Mapping, NamedTuple, Optional, Set, Tuple, Type, Union from dataclasses import dataclass
from typing import Any, Dict, Generic, List, Mapping, NamedTuple, Optional, Protocol, Set, Tuple, Type, TypeVar, Union
from django.template import Context, Template from django.template import Context, Template
from django.template.base import FilterExpression, Node, NodeList, Parser, TextNode from django.template.base import FilterExpression, Node, NodeList, Parser, TextNode
@ -16,26 +17,38 @@ from django_components.context import (
_INJECT_CONTEXT_KEY_PREFIX, _INJECT_CONTEXT_KEY_PREFIX,
_ROOT_CTX_CONTEXT_KEY, _ROOT_CTX_CONTEXT_KEY,
) )
from django_components.expression import Expression, resolve_expression_as_identifier, safe_resolve_dict from django_components.expression import RuntimeKwargs, is_identifier
from django_components.logger import trace_msg from django_components.logger import trace_msg
from django_components.node import NodeTraverse, nodelist_has_content, walk_nodelist from django_components.node import BaseNode, NodeTraverse, nodelist_has_content, walk_nodelist
from django_components.utils import gen_id
TSlotData = TypeVar("TSlotData", bound=Mapping, contravariant=True)
DEFAULT_SLOT_KEY = "_DJANGO_COMPONENTS_DEFAULT_SLOT" DEFAULT_SLOT_KEY = "_DJANGO_COMPONENTS_DEFAULT_SLOT"
SLOT_DATA_KWARG = "data"
SLOT_DEFAULT_KWARG = "default"
SLOT_REQUIRED_KEYWORD = "required"
SLOT_DEFAULT_KEYWORD = "default"
SlotRenderedContent = Union[str, SafeString]
SlotRenderFunc = Callable[[Context, Dict[str, Any], "SlotRef"], SlotRenderedContent]
# Type aliases # Public types
SlotResult = Union[str, SafeString]
class SlotFunc(Protocol, Generic[TSlotData]):
def __call__(self, ctx: Context, slot_data: TSlotData, slot_ref: "SlotRef") -> SlotResult: ... # noqa E704
SlotContent = Union[SlotResult, SlotFunc[TSlotData]]
# Internal type aliases
SlotId = str SlotId = str
SlotName = str SlotName = str
SlotDefaultName = str SlotDefaultName = str
SlotDataName = str SlotDataName = str
SlotContent = Union[str, SafeString, SlotRenderFunc]
class FillContent(NamedTuple): @dataclass(frozen=True)
class FillContent(Generic[TSlotData]):
""" """
This represents content set with the `{% fill %}` tag, e.g.: This represents content set with the `{% fill %}` tag, e.g.:
@ -50,7 +63,7 @@ class FillContent(NamedTuple):
``` ```
""" """
content_func: SlotRenderFunc content_func: SlotFunc[TSlotData]
slot_default_var: Optional[SlotDefaultName] slot_default_var: Optional[SlotDefaultName]
slot_data_var: Optional[SlotDataName] slot_data_var: Optional[SlotDataName]
@ -75,7 +88,8 @@ class Slot(NamedTuple):
nodelist: NodeList nodelist: NodeList
class SlotFill(NamedTuple): @dataclass(frozen=True)
class SlotFill(Generic[TSlotData]):
""" """
SlotFill describes what WILL be rendered. SlotFill describes what WILL be rendered.
@ -85,7 +99,7 @@ class SlotFill(NamedTuple):
name: str name: str
escaped_name: str escaped_name: str
is_filled: bool is_filled: bool
content_func: SlotRenderFunc content_func: SlotFunc[TSlotData]
context_data: Mapping context_data: Mapping
slot_default_var: Optional[SlotDefaultName] slot_default_var: Optional[SlotDefaultName]
slot_data_var: Optional[SlotDataName] slot_data_var: Optional[SlotDataName]
@ -110,22 +124,21 @@ class SlotRef:
return mark_safe(self._slot.nodelist.render(self._context)) return mark_safe(self._slot.nodelist.render(self._context))
class SlotNode(Node): class SlotNode(BaseNode):
def __init__( def __init__(
self, self,
name: str, name: str,
nodelist: NodeList, nodelist: NodeList,
node_id: Optional[str] = None,
kwargs: Optional[RuntimeKwargs] = None,
is_required: bool = False, is_required: bool = False,
is_default: bool = False, is_default: bool = False,
node_id: Optional[str] = None,
slot_kwargs: Optional[Dict[str, Expression]] = None,
): ):
super().__init__(nodelist=nodelist, args=None, kwargs=kwargs, node_id=node_id)
self.name = name self.name = name
self.nodelist = nodelist
self.is_required = is_required self.is_required = is_required
self.is_default = is_default self.is_default = is_default
self.node_id = node_id or gen_id()
self.slot_kwargs = slot_kwargs or {}
@property @property
def active_flags(self) -> List[str]: def active_flags(self) -> List[str]:
@ -171,14 +184,14 @@ class SlotNode(Node):
# Expose the kwargs that were passed to the `{% slot %}` tag. These kwargs # Expose the kwargs that were passed to the `{% slot %}` tag. These kwargs
# are made available through a variable name that was set on the `{% fill %}` # are made available through a variable name that was set on the `{% fill %}`
# tag. # tag.
slot_kwargs = safe_resolve_dict(self.slot_kwargs, context) kwargs = self.kwargs.resolve(context)
data_var = slot_fill.slot_data_var data_var = slot_fill.slot_data_var
if data_var: if data_var:
if not data_var.isidentifier(): if not data_var.isidentifier():
raise TemplateSyntaxError( raise TemplateSyntaxError(
f"Slot data alias in fill '{self.name}' must be a valid identifier. Got '{data_var}'" f"Slot data alias in fill '{self.name}' must be a valid identifier. Got '{data_var}'"
) )
extra_context[data_var] = slot_kwargs extra_context[data_var] = kwargs
# For the user-provided slot fill, we want to use the context of where the slot # For the user-provided slot fill, we want to use the context of where the slot
# came from (or current context if configured so) # came from (or current context if configured so)
@ -187,7 +200,7 @@ class SlotNode(Node):
# Render slot as a function # Render slot as a function
# NOTE: While `{% fill %}` tag has to opt in for the `default` and `data` variables, # NOTE: While `{% fill %}` tag has to opt in for the `default` and `data` variables,
# the render function ALWAYS receives them. # the render function ALWAYS receives them.
output = slot_fill.content_func(used_ctx, slot_kwargs, slot_ref) output = slot_fill.content_func(used_ctx, kwargs, slot_ref)
trace_msg("RENDR", "SLOT", self.name, self.node_id, msg="...Done!") trace_msg("RENDR", "SLOT", self.name, self.node_id, msg="...Done!")
return output return output
@ -208,7 +221,7 @@ class SlotNode(Node):
raise ValueError(f"Unknown value for CONTEXT_BEHAVIOR: '{app_settings.CONTEXT_BEHAVIOR}'") raise ValueError(f"Unknown value for CONTEXT_BEHAVIOR: '{app_settings.CONTEXT_BEHAVIOR}'")
class FillNode(Node): class FillNode(BaseNode):
""" """
Set when a `component` tag pair is passed template content that Set when a `component` tag pair is passed template content that
excludes `fill` tags. Nodes of this type contribute their nodelists to slots marked excludes `fill` tags. Nodes of this type contribute their nodelists to slots marked
@ -217,19 +230,16 @@ class FillNode(Node):
def __init__( def __init__(
self, self,
name: FilterExpression,
nodelist: NodeList, nodelist: NodeList,
name_fexp: FilterExpression, kwargs: RuntimeKwargs,
slot_default_var_fexp: Optional[FilterExpression] = None,
slot_data_var_fexp: Optional[FilterExpression] = None,
is_implicit: bool = False,
node_id: Optional[str] = None, node_id: Optional[str] = None,
is_implicit: bool = False,
): ):
self.node_id = node_id or gen_id() super().__init__(nodelist=nodelist, args=None, kwargs=kwargs, node_id=node_id)
self.nodelist = nodelist
self.name_fexp = name_fexp self.name = name
self.slot_default_var_fexp = slot_default_var_fexp
self.is_implicit = is_implicit self.is_implicit = is_implicit
self.slot_data_var_fexp = slot_data_var_fexp
self.component_id: Optional[str] = None self.component_id: Optional[str] = None
def render(self, context: Context) -> str: def render(self, context: Context) -> str:
@ -240,33 +250,44 @@ class FillNode(Node):
) )
def __repr__(self) -> str: def __repr__(self) -> str:
return f"<{type(self)} Name: {self.name_fexp}. Contents: {repr(self.nodelist)}.>" return f"<{type(self)} Name: {self.name}. Contents: {repr(self.nodelist)}.>"
def resolve_slot_default(self, context: Context, component_name: Optional[str] = None) -> Optional[str]: def resolve_kwargs(self, context: Context, component_name: Optional[str] = None) -> Dict[str, Optional[str]]:
return self.resolve_fexp("slot default", self.slot_default_var_fexp, context, component_name) kwargs = self.kwargs.resolve(context)
def resolve_slot_data(self, context: Context, component_name: Optional[str] = None) -> Optional[str]: default_key = self._resolve_kwarg(kwargs, SLOT_DEFAULT_KWARG, "slot default", component_name)
return self.resolve_fexp("slot data", self.slot_data_var_fexp, context, component_name) data_key = self._resolve_kwarg(kwargs, SLOT_DATA_KWARG, "slot data", component_name)
def resolve_fexp( # data and default cannot be bound to the same variable
if data_key and default_key and data_key == default_key:
raise RuntimeError(
f"Fill {self.name} received the same string for slot default ({SLOT_DEFAULT_KWARG}=...)"
f" and slot data ({SLOT_DATA_KWARG}=...)"
)
return {
SLOT_DEFAULT_KWARG: default_key,
SLOT_DATA_KWARG: data_key,
}
def _resolve_kwarg(
self, self,
kwargs: Dict[str, Any],
key: str,
name: str, name: str,
fexp: Optional[FilterExpression],
context: Context,
component_name: Optional[str] = None, component_name: Optional[str] = None,
) -> Optional[str]: ) -> Optional[str]:
if not fexp: if key not in kwargs:
return None return None
try: value = kwargs[key]
resolved_name = resolve_expression_as_identifier(context, fexp) if not is_identifier(value):
except ValueError as err: raise RuntimeError(
raise TemplateSyntaxError( f"Fill tag {name} in component {component_name}"
f"Fill tag {name} '{fexp.var}' in component {component_name}" f"does not resolve to a valid Python identifier, got '{value}'"
f"does not resolve to a valid Python identifier." )
) from err
return resolved_name return value
def parse_slot_fill_nodes_from_component_nodelist( def parse_slot_fill_nodes_from_component_nodelist(
@ -316,18 +337,18 @@ def _try_parse_as_named_fill_tag_set(
ComponentNodeCls: Type[Node], ComponentNodeCls: Type[Node],
) -> List[FillNode]: ) -> List[FillNode]:
result = [] result = []
seen_name_fexps: Set[str] = set() seen_names: Set[str] = set()
for node in nodelist: for node in nodelist:
if isinstance(node, FillNode): if isinstance(node, FillNode):
# Check that, after we've resolved the names, that there's still no duplicates. # Check that, after we've resolved the names, that there's still no duplicates.
# This makes sure that if two different variables refer to same string, we detect # This makes sure that if two different variables refer to same string, we detect
# them. # them.
if node.name_fexp.token in seen_name_fexps: if node.name.token in seen_names:
raise TemplateSyntaxError( raise TemplateSyntaxError(
f"Multiple fill tags cannot target the same slot name: " f"Multiple fill tags cannot target the same slot name: "
f"Detected duplicate fill tag name '{node.name_fexp}'." f"Detected duplicate fill tag name '{node.name}'."
) )
seen_name_fexps.add(node.name_fexp.token) seen_names.add(node.name.token)
result.append(node) result.append(node)
elif isinstance(node, CommentNode): elif isinstance(node, CommentNode):
pass pass
@ -357,7 +378,8 @@ def _try_parse_as_default_fill(
return [ return [
FillNode( FillNode(
nodelist=nodelist, nodelist=nodelist,
name_fexp=FilterExpression(json.dumps(DEFAULT_SLOT_KEY), Parser("")), name=FilterExpression(json.dumps(DEFAULT_SLOT_KEY), Parser("")),
kwargs=RuntimeKwargs({}),
is_implicit=True, is_implicit=True,
) )
] ]
@ -609,8 +631,8 @@ def _escape_slot_name(name: str) -> str:
return escaped_name return escaped_name
def _nodelist_to_slot_render_func(nodelist: NodeList) -> SlotRenderFunc: def _nodelist_to_slot_render_func(nodelist: NodeList) -> SlotFunc:
def render_func(ctx: Context, slot_kwargs: Dict[str, Any], slot_ref: SlotRef) -> SlotRenderedContent: def render_func(ctx: Context, kwargs: Dict[str, Any], slot_ref: SlotRef) -> SlotResult:
return nodelist.render(ctx) return nodelist.render(ctx)
return render_func return render_func # type: ignore[return-value]

View file

@ -5,7 +5,7 @@ Based on Django Slippers v0.6.2 - https://github.com/mixxorz/slippers/blob/main/
""" """
import re import re
from typing import Any, Dict, List, Mapping, Tuple from typing import Any, Dict, List, Tuple
from django.template.base import ( from django.template.base import (
FILTER_ARGUMENT_SEPARATOR, FILTER_ARGUMENT_SEPARATOR,
@ -205,88 +205,3 @@ def parse_bits(
% (name, ", ".join("'%s'" % p for p in unhandled_params)) % (name, ", ".join("'%s'" % p for p in unhandled_params))
) )
return args, kwargs return args, kwargs
def process_aggregate_kwargs(kwargs: Mapping[str, Any]) -> Dict[str, Any]:
"""
This function aggregates "prefixed" kwargs into dicts. "Prefixed" kwargs
start with some prefix delimited with `:` (e.g. `attrs:`).
Example:
```py
process_component_kwargs({"abc:one": 1, "abc:two": 2, "def:three": 3, "four": 4})
# {"abc": {"one": 1, "two": 2}, "def": {"three": 3}, "four": 4}
```
---
We want to support a use case similar to Vue's fallthrough attributes.
In other words, where a component author can designate a prop (input)
which is a dict and which will be rendered as HTML attributes.
This is useful for allowing component users to tweak styling or add
event handling to the underlying HTML. E.g.:
`class="pa-4 d-flex text-black"` or `@click.stop="alert('clicked!')"`
So if the prop is `attrs`, and the component is called like so:
```django
{% component "my_comp" attrs=attrs %}
```
then, if `attrs` is:
```py
{"class": "text-red pa-4", "@click": "dispatch('my_event', 123)"}
```
and the component template is:
```django
<div {% html_attrs attrs add:class="extra-class" %}></div>
```
Then this renders:
```html
<div class="text-red pa-4 extra-class" @click="dispatch('my_event', 123)" ></div>
```
However, this way it is difficult for the component user to define the `attrs`
variable, especially if they want to combine static and dynamic values. Because
they will need to pre-process the `attrs` dict.
So, instead, we allow to "aggregate" props into a dict. So all props that start
with `attrs:`, like `attrs:class="text-red"`, will be collected into a dict
at key `attrs`.
This provides sufficient flexiblity to make it easy for component users to provide
"fallthrough attributes", and sufficiently easy for component authors to process
that input while still being able to provide their own keys.
"""
processed_kwargs = {}
nested_kwargs: Dict[str, Dict[str, Any]] = {}
for key, val in kwargs.items():
if not is_aggregate_key(key):
processed_kwargs[key] = val
continue
# NOTE: Trim off the prefix from keys
prefix, sub_key = key.split(":", 1)
if prefix not in nested_kwargs:
nested_kwargs[prefix] = {}
nested_kwargs[prefix][sub_key] = val
# Assign aggregated values into normal input
for key, val in nested_kwargs.items():
if key in processed_kwargs:
raise TemplateSyntaxError(
f"Received argument '{key}' both as a regular input ({key}=...)"
f" and as an aggregate dict ('{key}:key=...'). Must be only one of the two"
)
processed_kwargs[key] = val
return processed_kwargs
def is_aggregate_key(key: str) -> bool:
# NOTE: If we get a key that starts with `:`, like `:class`, we do not split it.
# This syntax is used by Vue and AlpineJS.
return ":" in key and not key.startswith(":")

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 import django.template
from django.template.base import FilterExpression, NodeList, Parser, Token from django.template.base import FilterExpression, NodeList, Parser, Token
@ -6,11 +6,19 @@ from django.template.exceptions import TemplateSyntaxError
from django.utils.safestring import SafeString, mark_safe from django.utils.safestring import SafeString, mark_safe
from django_components.app_settings import ContextBehavior, app_settings from django_components.app_settings import ContextBehavior, app_settings
from django_components.attributes import HtmlAttrsNode from django_components.attributes import HTML_ATTRS_ATTRS_KEY, HTML_ATTRS_DEFAULTS_KEY, HtmlAttrsNode
from django_components.component import RENDERED_COMMENT_TEMPLATE, ComponentNode from django_components.component import RENDERED_COMMENT_TEMPLATE, ComponentNode
from django_components.component_registry import ComponentRegistry from django_components.component_registry import ComponentRegistry
from django_components.component_registry import registry as component_registry from django_components.component_registry import registry as component_registry
from django_components.expression import AggregateFilterExpression, Expression, resolve_string from django_components.expression import (
Expression,
RuntimeKwargPairs,
RuntimeKwargs,
RuntimeKwargsInput,
is_aggregate_key,
is_kwarg,
resolve_string,
)
from django_components.logger import trace_msg from django_components.logger import trace_msg
from django_components.middleware import ( from django_components.middleware import (
CSS_DEPENDENCY_PLACEHOLDER, CSS_DEPENDENCY_PLACEHOLDER,
@ -18,9 +26,17 @@ from django_components.middleware import (
is_dependency_middleware_active, is_dependency_middleware_active,
) )
from django_components.provide import ProvideNode from django_components.provide import ProvideNode
from django_components.slots import FillNode, SlotNode, parse_slot_fill_nodes_from_component_nodelist from django_components.slots import (
SLOT_DATA_KWARG,
SLOT_DEFAULT_KEYWORD,
SLOT_DEFAULT_KWARG,
SLOT_REQUIRED_KEYWORD,
FillNode,
SlotNode,
parse_slot_fill_nodes_from_component_nodelist,
)
from django_components.tag_formatter import get_tag_formatter from django_components.tag_formatter import get_tag_formatter
from django_components.template_parser import is_aggregate_key, parse_bits, process_aggregate_kwargs from django_components.template_parser import parse_bits
from django_components.utils import gen_id, is_str_wrapped_in_quotes from django_components.utils import gen_id, is_str_wrapped_in_quotes
if TYPE_CHECKING: if TYPE_CHECKING:
@ -32,12 +48,6 @@ if TYPE_CHECKING:
register = django.template.Library() register = django.template.Library()
SLOT_REQUIRED_OPTION_KEYWORD = "required"
SLOT_DEFAULT_OPTION_KEYWORD = "default"
SLOT_DATA_ATTR = "data"
SLOT_DEFAULT_ATTR = "default"
def _get_components_from_registry(registry: ComponentRegistry) -> List["Component"]: def _get_components_from_registry(registry: ComponentRegistry) -> List["Component"]:
"""Returns a list unique components from the registry.""" """Returns a list unique components from the registry."""
@ -123,7 +133,7 @@ def slot(parser: Parser, token: Token) -> SlotNode:
parser, parser,
bits, bits,
params=["name"], params=["name"],
flags=[SLOT_DEFAULT_OPTION_KEYWORD, SLOT_REQUIRED_OPTION_KEYWORD], flags=[SLOT_DEFAULT_KEYWORD, SLOT_REQUIRED_KEYWORD],
keywordonly_kwargs=True, keywordonly_kwargs=True,
repeatable_kwargs=False, repeatable_kwargs=False,
end_tag="endslot", end_tag="endslot",
@ -136,10 +146,10 @@ def slot(parser: Parser, token: Token) -> SlotNode:
slot_node = SlotNode( slot_node = SlotNode(
name=data.name, name=data.name,
nodelist=body, nodelist=body,
is_required=tag.flags[SLOT_REQUIRED_OPTION_KEYWORD],
is_default=tag.flags[SLOT_DEFAULT_OPTION_KEYWORD],
node_id=tag.id, node_id=tag.id,
slot_kwargs=tag.kwargs, kwargs=tag.kwargs,
is_required=tag.flags[SLOT_REQUIRED_KEYWORD],
is_default=tag.flags[SLOT_DEFAULT_KEYWORD],
) )
trace_msg("PARSE", "SLOT", data.name, tag.id, "...Done!") trace_msg("PARSE", "SLOT", data.name, tag.id, "...Done!")
@ -162,24 +172,23 @@ def fill(parser: Parser, token: Token) -> FillNode:
parser, parser,
bits, bits,
params=["name"], params=["name"],
keywordonly_kwargs=[SLOT_DATA_ATTR, SLOT_DEFAULT_ATTR], keywordonly_kwargs=[SLOT_DATA_KWARG, SLOT_DEFAULT_KWARG],
repeatable_kwargs=False, repeatable_kwargs=False,
end_tag="endfill", end_tag="endfill",
) )
data = _parse_fill_args(parser, tag) slot_name = tag.named_args["name"]
trace_msg("PARSE", "FILL", str(data.slot_name), tag.id) trace_msg("PARSE", "FILL", str(slot_name), tag.id)
body = tag.parse_body() body = tag.parse_body()
fill_node = FillNode( fill_node = FillNode(
nodelist=body, nodelist=body,
name_fexp=data.slot_name, name=slot_name,
slot_default_var_fexp=data.slot_default_var,
slot_data_var_fexp=data.slot_data_var,
node_id=tag.id, node_id=tag.id,
kwargs=tag.kwargs,
) )
trace_msg("PARSE", "FILL", str(data.slot_name), tag.id, "...Done!") trace_msg("PARSE", "FILL", str(slot_name), tag.id, "...Done!")
return fill_node return fill_node
@ -216,7 +225,9 @@ def component(parser: Parser, token: Token, tag_name: str) -> ComponentNode:
repeatable_kwargs=False, repeatable_kwargs=False,
end_tag=end_tag, end_tag=end_tag,
) )
data = _parse_component_args(parser, tag)
# Check for isolated context keyword
isolated_context = tag.flags["only"] or app_settings.CONTEXT_BEHAVIOR == ContextBehavior.ISOLATED
trace_msg("PARSE", "COMP", result.component_name, tag.id) trace_msg("PARSE", "COMP", result.component_name, tag.id)
@ -225,16 +236,16 @@ def component(parser: Parser, token: Token, tag_name: str) -> ComponentNode:
# Tag all fill nodes as children of this particular component instance # Tag all fill nodes as children of this particular component instance
for node in fill_nodes: for node in fill_nodes:
trace_msg("ASSOC", "FILL", node.name_fexp, node.node_id, component_id=tag.id) trace_msg("ASSOC", "FILL", node.name, node.node_id, component_id=tag.id)
node.component_id = tag.id node.component_id = tag.id
component_node = ComponentNode( component_node = ComponentNode(
name=result.component_name, name=result.component_name,
context_args=tag.args, args=tag.args,
context_kwargs=tag.kwargs, kwargs=tag.kwargs,
isolated_context=data.isolated_context, isolated_context=isolated_context,
fill_nodes=fill_nodes, fill_nodes=fill_nodes,
component_id=tag.id, node_id=tag.id,
) )
trace_msg("PARSE", "COMP", result.component_name, tag.id, "...Done!") trace_msg("PARSE", "COMP", result.component_name, tag.id, "...Done!")
@ -264,7 +275,7 @@ def provide(parser: Parser, token: Token) -> ProvideNode:
name=data.key, name=data.key,
nodelist=body, nodelist=body,
node_id=tag.id, node_id=tag.id,
provide_kwargs=tag.kwargs, kwargs=tag.kwargs,
) )
trace_msg("PARSE", "PROVIDE", data.key, tag.id, "...Done!") trace_msg("PARSE", "PROVIDE", data.key, tag.id, "...Done!")
@ -305,17 +316,16 @@ def html_attrs(parser: Parser, token: Token) -> HtmlAttrsNode:
"html_attrs", "html_attrs",
parser, parser,
bits, bits,
params=["attrs", "defaults"], params=[HTML_ATTRS_ATTRS_KEY, HTML_ATTRS_DEFAULTS_KEY],
optional_params=["attrs", "defaults"], optional_params=[HTML_ATTRS_ATTRS_KEY, HTML_ATTRS_DEFAULTS_KEY],
flags=[], flags=[],
keywordonly_kwargs=True, keywordonly_kwargs=True,
repeatable_kwargs=True, repeatable_kwargs=True,
) )
return HtmlAttrsNode( return HtmlAttrsNode(
attributes=tag.kwargs.get("attrs"), kwargs=tag.kwargs,
defaults=tag.kwargs.get("defaults"), kwarg_pairs=tag.kwarg_pairs,
kwargs=[(key, val) for key, val in tag.kwarg_pairs if key != "attrs" and key != "defaults"],
) )
@ -324,10 +334,10 @@ class ParsedTag(NamedTuple):
name: str name: str
bits: List[str] bits: List[str]
flags: Dict[str, bool] flags: Dict[str, bool]
args: List[FilterExpression] args: List[Expression]
named_args: Dict[str, FilterExpression] named_args: Dict[str, Expression]
kwargs: Dict[str, Expression] kwargs: RuntimeKwargs
kwarg_pairs: List[Tuple[str, Expression]] kwarg_pairs: RuntimeKwargPairs
is_inline: bool is_inline: bool
parse_body: Callable[[], NodeList] parse_body: Callable[[], NodeList]
@ -380,17 +390,13 @@ def _parse_tag(
seen_kwargs.add(key) seen_kwargs.add(key)
for bit in bits: for bit in bits:
# Extract flags, which are like keywords but without the value part value = bit
if bit in parsed_flags: bit_is_kwarg = is_kwarg(bit)
parsed_flags[bit] = True
continue
else:
bits_without_flags.append(bit)
# Record which kwargs we've seen, to detect if kwargs were passed in # Record which kwargs we've seen, to detect if kwargs were passed in
# as both aggregate and regular kwargs # as both aggregate and regular kwargs
if "=" in bit: if bit_is_kwarg:
key = bit.split("=")[0] key, value = bit.split("=", 1)
# Also pick up on aggregate keys like `attr:key=val` # Also pick up on aggregate keys like `attr:key=val`
if is_aggregate_key(key): if is_aggregate_key(key):
@ -399,6 +405,14 @@ def _parse_tag(
else: else:
mark_kwarg_key(key, False) mark_kwarg_key(key, False)
else:
# Extract flags, which are like keywords but without the value part
if value in parsed_flags:
parsed_flags[value] = True
continue
bits_without_flags.append(bit)
bits = bits_without_flags bits = bits_without_flags
# To support optional args, we need to convert these to kwargs, so `parse_bits` # To support optional args, we need to convert these to kwargs, so `parse_bits`
@ -415,7 +429,7 @@ def _parse_tag(
new_params = [] new_params = []
new_kwargs = [] new_kwargs = []
for index, bit in enumerate(bits): for index, bit in enumerate(bits):
if "=" in bit or not len(params_to_sort): if is_kwarg(bit) or not len(params_to_sort):
# Pass all remaining bits (including current one) as kwargs # Pass all remaining bits (including current one) as kwargs
new_kwargs.extend(bits[index:]) new_kwargs.extend(bits[index:])
break break
@ -436,31 +450,13 @@ def _parse_tag(
params = [param for param in params_to_sort if param not in optional_params] params = [param for param in params_to_sort if param not in optional_params]
# Parse args/kwargs that will be passed to the fill # Parse args/kwargs that will be passed to the fill
args, raw_kwarg_pairs = parse_bits( args, kwarg_pairs = parse_bits(
parser=parser, parser=parser,
bits=bits, bits=bits,
params=[] if isinstance(params, bool) else params, params=[] if isinstance(params, bool) else params,
name=tag_name, name=tag_name,
) )
# Post-process args/kwargs - Mark special cases like aggregate dicts
# or dynamic expressions
pre_aggregate_kwargs: Dict[str, FilterExpression] = {}
kwarg_pairs: List[Tuple[str, Expression]] = []
for key, val in raw_kwarg_pairs:
# NOTE: If a tag allows mutliple kwargs, and we provide a same aggregate key
# multiple times (e.g. `attr:class="hidden" and `attr:class="another"`), then
# we take only the last instance.
if is_aggregate_key(key):
pre_aggregate_kwargs[key] = val
else:
kwarg_pairs.append((key, val))
aggregate_kwargs: Dict[str, Dict[str, FilterExpression]] = process_aggregate_kwargs(pre_aggregate_kwargs)
for key, agg_dict in aggregate_kwargs.items():
entry = (key, AggregateFilterExpression(agg_dict))
kwarg_pairs.append(entry)
# Allow only as many positional args as given # Allow only as many positional args as given
if params != True and len(args) > len(params): # noqa F712 if params != True and len(args) > len(params): # noqa F712
raise TemplateSyntaxError(f"Tag '{tag_name}' received unexpected positional arguments: {args[len(params):]}") raise TemplateSyntaxError(f"Tag '{tag_name}' received unexpected positional arguments: {args[len(params):]}")
@ -472,7 +468,7 @@ def _parse_tag(
named_args = {} named_args = {}
# Validate kwargs # Validate kwargs
kwargs: Dict[str, Expression] = {} kwargs: RuntimeKwargsInput = {}
extra_keywords: Set[str] = set() extra_keywords: Set[str] = set()
for key, val in kwarg_pairs: for key, val in kwarg_pairs:
# Check if key allowed # Check if key allowed
@ -506,8 +502,8 @@ def _parse_tag(
flags=parsed_flags, flags=parsed_flags,
args=args, args=args,
named_args=named_args, named_args=named_args,
kwargs=kwargs, kwargs=RuntimeKwargs(kwargs),
kwarg_pairs=kwarg_pairs, kwarg_pairs=RuntimeKwargPairs(kwarg_pairs),
# NOTE: We defer parsing of the body, so we have the chance to call the tracing # NOTE: We defer parsing of the body, so we have the chance to call the tracing
# loggers before the parsing. This is because, if the body contains any other # loggers before the parsing. This is because, if the body contains any other
# tags, it will trigger their tag handlers. So the code called AFTER # tags, it will trigger their tag handlers. So the code called AFTER
@ -526,20 +522,6 @@ def _parse_tag_body(parser: Parser, end_tag: str, inline: bool) -> NodeList:
return body return body
class ParsedComponentTag(NamedTuple):
isolated_context: bool
def _parse_component_args(
parser: Parser,
tag: ParsedTag,
) -> ParsedComponentTag:
# Check for isolated context keyword
isolated_context = tag.flags["only"] or app_settings.CONTEXT_BEHAVIOR == ContextBehavior.ISOLATED
return ParsedComponentTag(isolated_context=isolated_context)
class ParsedSlotTag(NamedTuple): class ParsedSlotTag(NamedTuple):
name: str name: str
@ -548,7 +530,10 @@ def _parse_slot_args(
parser: Parser, parser: Parser,
tag: ParsedTag, tag: ParsedTag,
) -> ParsedSlotTag: ) -> ParsedSlotTag:
slot_name = tag.named_args["name"].token slot_name_expr = tag.named_args["name"]
if not isinstance(slot_name_expr, FilterExpression):
raise TemplateSyntaxError(f"Slot name must be string literal, got {slot_name_expr}")
slot_name = slot_name_expr.token
if not is_str_wrapped_in_quotes(slot_name): if not is_str_wrapped_in_quotes(slot_name):
raise TemplateSyntaxError(f"'{tag.name}' name must be a string 'literal', got {slot_name}.") raise TemplateSyntaxError(f"'{tag.name}' name must be a string 'literal', got {slot_name}.")
@ -557,46 +542,6 @@ def _parse_slot_args(
return ParsedSlotTag(name=slot_name) return ParsedSlotTag(name=slot_name)
class ParsedFillTag(NamedTuple):
slot_name: FilterExpression
slot_default_var: Optional[FilterExpression]
slot_data_var: Optional[FilterExpression]
def _parse_fill_args(
parser: Parser,
tag: ParsedTag,
) -> ParsedFillTag:
slot_name_fexp = tag.named_args["name"]
# Extract known kwargs
slot_data_var_fexp: Optional[FilterExpression] = tag.kwargs.get(SLOT_DATA_ATTR)
if slot_data_var_fexp and not is_str_wrapped_in_quotes(slot_data_var_fexp.token):
raise TemplateSyntaxError(
f"Value of '{SLOT_DATA_ATTR}' in '{tag.name}' tag must be a string literal, got '{slot_data_var_fexp}'"
)
slot_default_var_fexp: Optional[FilterExpression] = tag.kwargs.get(SLOT_DEFAULT_ATTR)
if slot_default_var_fexp and not is_str_wrapped_in_quotes(slot_default_var_fexp.token):
raise TemplateSyntaxError(
f"Value of '{SLOT_DEFAULT_ATTR}' in '{tag.name}' tag must be a string literal,"
f" got '{slot_default_var_fexp}'"
)
# data and default cannot be bound to the same variable
if slot_data_var_fexp and slot_default_var_fexp and slot_data_var_fexp.token == slot_default_var_fexp.token:
raise TemplateSyntaxError(
f"'{tag.name}' received the same string for slot default ({SLOT_DEFAULT_ATTR}=...)"
f" and slot data ({SLOT_DATA_ATTR}=...)"
)
return ParsedFillTag(
slot_name=slot_name_fexp,
slot_default_var=slot_default_var_fexp,
slot_data_var=slot_data_var_fexp,
)
class ParsedProvideTag(NamedTuple): class ParsedProvideTag(NamedTuple):
key: str key: str
@ -605,9 +550,12 @@ def _parse_provide_args(
parser: Parser, parser: Parser,
tag: ParsedTag, tag: ParsedTag,
) -> ParsedProvideTag: ) -> ParsedProvideTag:
provide_key = tag.named_args["name"].token provide_key_expr = tag.named_args["name"]
if not isinstance(provide_key_expr, FilterExpression):
raise TemplateSyntaxError(f"Provide key must be string literal, got {provide_key_expr}")
provide_key = provide_key_expr.token
if not is_str_wrapped_in_quotes(provide_key): if not is_str_wrapped_in_quotes(provide_key):
raise TemplateSyntaxError(f"'{tag.name}' key must be a string 'literal'.") raise TemplateSyntaxError(f"'{tag.name}' key must be a string 'literal', got {provide_key}")
provide_key = resolve_string(provide_key, parser) provide_key = resolve_string(provide_key, parser)

60
tests/test_expression.py Normal file
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.template.base import Parser
from django_components import Component, registry, types from django_components import Component, registry, types
from django_components.component import safe_resolve_dict, safe_resolve_list from django_components.expression import (
from django_components.template_parser import is_aggregate_key, process_aggregate_kwargs is_aggregate_key,
process_aggregate_kwargs,
safe_resolve_dict,
safe_resolve_list,
)
from django_components.templatetags.component_tags import _parse_tag from django_components.templatetags.component_tags import _parse_tag
from .django_test_setup import setup_test_config from .django_test_setup import setup_test_config
@ -18,9 +22,9 @@ class ParserTest(BaseTestCase):
tag = _parse_tag("component", Parser(""), bits, params=["num", "var"], keywordonly_kwargs=True) tag = _parse_tag("component", Parser(""), bits, params=["num", "var"], keywordonly_kwargs=True)
ctx = {"myvar": {"a": "b"}, "val2": 1} ctx = {"myvar": {"a": "b"}, "val2": 1}
args = safe_resolve_list(tag.args, ctx) args = safe_resolve_list(ctx, tag.args)
named_args = safe_resolve_dict(tag.named_args, ctx) named_args = safe_resolve_dict(ctx, tag.named_args)
kwargs = safe_resolve_dict(tag.kwargs, ctx) kwargs = tag.kwargs.resolve(ctx)
self.assertListEqual(args, [42, {"a": "b"}]) self.assertListEqual(args, [42, {"a": "b"}])
self.assertDictEqual(named_args, {"num": 42, "var": {"a": "b"}}) self.assertDictEqual(named_args, {"num": 42, "var": {"a": "b"}})
@ -38,8 +42,8 @@ class ParserTest(BaseTestCase):
tag = _parse_tag("component", Parser(""), bits, keywordonly_kwargs=True) tag = _parse_tag("component", Parser(""), bits, keywordonly_kwargs=True)
ctx = Context({"date": 2024, "bzz": "fzz"}) ctx = Context({"date": 2024, "bzz": "fzz"})
args = safe_resolve_list(tag.args, ctx) args = safe_resolve_list(ctx, tag.args)
kwargs = safe_resolve_dict(tag.kwargs, ctx) kwargs = tag.kwargs.resolve(ctx)
self.assertListEqual(args, []) self.assertListEqual(args, [])
self.assertDictEqual( self.assertDictEqual(

View file

@ -822,8 +822,8 @@ class ScopedSlotTest(BaseTestCase):
{% endcomponent %} {% endcomponent %}
""" """
with self.assertRaisesMessage( with self.assertRaisesMessage(
TemplateSyntaxError, RuntimeError,
"'fill' received the same string for slot default (default=...) and slot data (data=...)", 'Fill "my_slot" received the same string for slot default (default=...) and slot data (data=...)',
): ):
Template(template).render(Context()) Template(template).render(Context())