From 899b9a2738a6500cf4ac6ac105ec194bcee4a801 Mon Sep 17 00:00:00 2001 From: Juro Oravec Date: Fri, 23 Aug 2024 18:15:28 +0200 Subject: [PATCH] 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> --- README.md | 24 ++- src/django_components/__init__.py | 4 + src/django_components/attributes.py | 37 ++-- src/django_components/component.py | 54 ++--- src/django_components/expression.py | 154 +++++++++++--- src/django_components/node.py | 19 ++ src/django_components/provide.py | 24 +-- src/django_components/slots.py | 134 +++++++----- src/django_components/template_parser.py | 87 +------- .../templatetags/component_tags.py | 200 +++++++----------- tests/test_expression.py | 60 ++++++ tests/test_template_parser.py | 18 +- tests/test_templatetags_slot_fill.py | 4 +- 13 files changed, 448 insertions(+), 371 deletions(-) create mode 100644 tests/test_expression.py diff --git a/README.md b/README.md index f6a6df3f..72887641 100644 --- a/README.md +++ b/README.md @@ -537,7 +537,7 @@ Component.render( context: Mapping | django.template.Context | None = None, args: List[Any] | None = None, kwargs: Dict[str, Any] | None = None, - slots: Dict[str, str | SafeString | SlotRenderFunc] | None = None, + slots: Dict[str, str | SafeString | SlotFunc] | None = None, escape_slots_content: bool = True ) -> str: ``` @@ -550,7 +550,7 @@ Component.render( - _`slots`_ - Component slot fills. This is the same as pasing `{% fill %}` tags to the component. Accepts a dictionary of `{ slot_name: slot_content }` where `slot_content` can be a string - or [`SlotRenderFunc`](#slotrenderfunc). + or [`SlotFunc`](#slotfunc). - _`escape_slots_content`_ - Whether the content from `slots` should be escaped. `True` by default to prevent XSS attacks. If you disable escaping, you should make sure that any content you pass to the slots is safe, especially if it comes from user input. @@ -559,7 +559,7 @@ Component.render( - NOTE: In "isolated" mode, context is NOT accessible, and data MUST be passed via component's args and kwargs. -#### `SlotRenderFunc` +#### `SlotFunc` When rendering components with slots in `render` or `render_to_response`, you can pass either a string or a function. @@ -602,24 +602,30 @@ that allow you to specify the types of args, kwargs, slots, and data. ```py -from typing import NotRequired, Tuple, TypedDict +from typing import NotRequired, Tuple, TypedDict, SlotFunc -# Tuple +# Positional inputs - Tuple Args = Tuple[int, str] -# Mapping +# Kwargs inputs - Mapping class Kwargs(TypedDict): variable: str another: int maybe_var: NotRequired[int] -# Mapping +# Data returned from `get_context_data` - Mapping class Data(TypedDict): variable: str -# Mapping +# The data available to the `my_slot` scoped slot +class MySlotData(TypedDict): + value: int + +# Slot functions - Mapping class Slots(TypedDict): - my_slot: NotRequired[str] + # Use SlotFunc for slot functions. + # The generic specifies the `data` dictionary + my_slot: NotRequired[SlotFunc[MySlotData]] class Button(Component[Args, Kwargs, Data, Slots]): def get_context_data(self, variable, another): diff --git a/src/django_components/__init__.py b/src/django_components/__init__.py index 162a7035..58642645 100644 --- a/src/django_components/__init__.py +++ b/src/django_components/__init__.py @@ -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, diff --git a/src/django_components/attributes.py b/src/django_components/attributes.py index d9934b30..3c97283e 100644 --- a/src/django_components/attributes.py +++ b/src/django_components/attributes.py @@ -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} diff --git a/src/django_components/component.py b/src/django_components/component.py index 4ac52433..35ed9876 100644 --- a/src/django_components/component.py +++ b/src/django_components/component.py @@ -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 "".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 diff --git a/src/django_components/expression.py b/src/django_components/expression.py index d9305669..6b762096 100644 --- a/src/django_components/expression.py +++ b/src/django_components/expression.py @@ -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 +
+ ``` + + Then this renders: + ```html +
+ ``` + + 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 diff --git a/src/django_components/node.py b/src/django_components/node.py index 7d77bfa6..bcc61447 100644 --- a/src/django_components/node.py +++ b/src/django_components/node.py @@ -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: diff --git a/src/django_components/provide.py b/src/django_components/provide.py index b6ccf6dd..147455f6 100644 --- a/src/django_components/provide.py +++ b/src/django_components/provide.py @@ -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"" + return f"" 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) diff --git a/src/django_components/slots.py b/src/django_components/slots.py index 364d8536..8a895250 100644 --- a/src/django_components/slots.py +++ b/src/django_components/slots.py @@ -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] diff --git a/src/django_components/template_parser.py b/src/django_components/template_parser.py index 41d63c52..db823f6a 100644 --- a/src/django_components/template_parser.py +++ b/src/django_components/template_parser.py @@ -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 -
- ``` - - Then this renders: - ```html -
- ``` - - 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(":") diff --git a/src/django_components/templatetags/component_tags.py b/src/django_components/templatetags/component_tags.py index 567292d5..81c83d7c 100644 --- a/src/django_components/templatetags/component_tags.py +++ b/src/django_components/templatetags/component_tags.py @@ -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) diff --git a/tests/test_expression.py b/tests/test_expression.py new file mode 100644 index 00000000..0e1e1b47 --- /dev/null +++ b/tests/test_expression.py @@ -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": ""}, + ) diff --git a/tests/test_template_parser.py b/tests/test_template_parser.py index ac680f41..32f5edfa 100644 --- a/tests/test_template_parser.py +++ b/tests/test_template_parser.py @@ -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( diff --git a/tests/test_templatetags_slot_fill.py b/tests/test_templatetags_slot_fill.py index f3df47d8..6a5c9501 100644 --- a/tests/test_templatetags_slot_fill.py +++ b/tests/test_templatetags_slot_fill.py @@ -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())