Skip to content

Commit

Permalink
feat: Literal dicts and lists part 2 (#902)
Browse files Browse the repository at this point in the history
  • Loading branch information
JuroOravec authored Jan 14, 2025
1 parent d3c5c53 commit 8cd4b03
Show file tree
Hide file tree
Showing 19 changed files with 1,301 additions and 951 deletions.
29 changes: 9 additions & 20 deletions docs/scripts/reference.py
Original file line number Diff line number Diff line change
Expand Up @@ -593,30 +593,19 @@ def _format_tag_signature(tag_spec: TagSpec) -> str:
{% endcomponent %}
```
"""
params: List[str] = [tag_spec.tag]

if tag_spec.positional_only_args:
params.extend([*tag_spec.positional_only_args, "/"])

optional_kwargs = set(tag_spec.optional_kwargs or [])

params.extend([f"{name}=None" if name in optional_kwargs else name for name in tag_spec.pos_or_keyword_args or []])

if tag_spec.positional_args_allow_extra:
params.append("[arg, ...]")

if tag_spec.keywordonly_args is True:
params.append("**kwargs")
elif tag_spec.keywordonly_args:
params.extend(
[f"{name}=None" if name in optional_kwargs else name for name in (tag_spec.keywordonly_args or [])]
)
# The signature returns a string like:
# `(arg: Any, **kwargs: Any) -> None`
params_str = str(tag_spec.signature)
# Remove the return type annotation, the `-> None` part
params_str = params_str.rsplit("->", 1)[0]
# Remove brackets around the params, to end up only with `arg: Any, **kwargs: Any`
params_str = params_str.strip()[1:-1]

if tag_spec.flags:
params.extend([f"[{name}]" for name in tag_spec.flags])
params_str += " " + " ".join([f"[{name}]" for name in tag_spec.flags])

# Create the function signature
full_tag = f"{{% {' '.join(params)} %}}"
full_tag = "{% " + tag_spec.tag + " " + params_str + " %}"
if tag_spec.end_tag:
full_tag += f"\n{{% {tag_spec.end_tag} %}}"

Expand Down
23 changes: 5 additions & 18 deletions src/django_components/attributes.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@
from django.utils.html import conditional_escape, format_html
from django.utils.safestring import SafeString, mark_safe

from django_components.expression import RuntimeKwargPairs, RuntimeKwargs
from django_components.node import BaseNode
from django_components.util.template_tag import TagParams

HTML_ATTRS_DEFAULTS_KEY = "defaults"
HTML_ATTRS_ATTRS_KEY = "attrs"
Expand All @@ -18,32 +18,19 @@
class HtmlAttrsNode(BaseNode):
def __init__(
self,
kwargs: RuntimeKwargs,
kwarg_pairs: RuntimeKwargPairs,
params: TagParams,
node_id: Optional[str] = None,
):
super().__init__(nodelist=None, args=None, kwargs=kwargs, node_id=node_id)
self.kwarg_pairs = kwarg_pairs
super().__init__(nodelist=None, params=params, node_id=node_id)

def render(self, context: Context) -> str:
append_attrs: List[Tuple[str, Any]] = []

# Resolve all data
kwargs = self.kwargs.resolve(context)
args, kwargs = self.params.resolve(context)
attrs = kwargs.pop(HTML_ATTRS_ATTRS_KEY, None) or {}
defaults = kwargs.pop(HTML_ATTRS_DEFAULTS_KEY, None) or {}

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))
append_attrs = list(kwargs.items())

# Merge it
final_attrs = {**defaults, **attrs}
Expand Down
13 changes: 5 additions & 8 deletions src/django_components/component.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
Dict,
Generator,
Generic,
List,
Literal,
Mapping,
NamedTuple,
Expand Down Expand Up @@ -54,7 +53,6 @@
cache_component_js_vars,
postprocess_component_html,
)
from django_components.expression import Expression, RuntimeKwargs, safe_resolve_list
from django_components.node import BaseNode
from django_components.slots import (
ComponentSlotContext,
Expand All @@ -72,6 +70,7 @@
from django_components.template import cached_template
from django_components.util.logger import trace_msg
from django_components.util.misc import gen_id
from django_components.util.template_tag import TagParams
from django_components.util.validation import validate_typed_dict, validate_typed_tuple

# TODO_REMOVE_IN_V1 - Users should use top-level import instead
Expand Down Expand Up @@ -1214,14 +1213,13 @@ class ComponentNode(BaseNode):
def __init__(
self,
name: str,
args: List[Expression],
kwargs: RuntimeKwargs,
registry: ComponentRegistry, # noqa F811
nodelist: NodeList,
params: TagParams,
isolated_context: bool = False,
nodelist: Optional[NodeList] = None,
node_id: Optional[str] = None,
) -> None:
super().__init__(nodelist=nodelist or NodeList(), args=args, kwargs=kwargs, node_id=node_id)
super().__init__(nodelist=nodelist or NodeList(), params=params, node_id=node_id)

self.name = name
self.isolated_context = isolated_context
Expand All @@ -1246,8 +1244,7 @@ def render(self, context: Context) -> str:
# 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
args = safe_resolve_list(context, self.args)
kwargs = self.kwargs.resolve(context)
args, kwargs = self.params.resolve(context)

slot_fills = resolve_fills(context, self.nodelist, self.name)

Expand Down
188 changes: 78 additions & 110 deletions src/django_components/expression.py
Original file line number Diff line number Diff line change
@@ -1,42 +1,43 @@
import re
from abc import ABC, abstractmethod
from typing import Any, Dict, List, Mapping, Optional, Tuple, Union
from typing import TYPE_CHECKING, Any, Dict, List

from django.template import Context, Node, NodeList, TemplateSyntaxError
from django.template.base import FilterExpression, Lexer, Parser, VariableNode
from django.template.base import Lexer, Parser, VariableNode

Expression = Union[FilterExpression, "DynamicFilterExpression"]
RuntimeKwargsInput = Dict[str, Union[Expression, "Operator"]]
RuntimeKwargPairsInput = List[Tuple[str, Union[Expression, "Operator"]]]
if TYPE_CHECKING:
from django_components.util.template_tag import TagParam


class Operator(ABC):
class DynamicFilterExpression:
"""
Operator describes something that somehow changes the inputs
to template tags (the `{% %}`).
To make working with Django templates easier, we allow to use (nested) template tags `{% %}`
inside of strings that are passed to our template tags, e.g.:
For example, a SpreadOperator inserts one or more kwargs at the
specified location.
"""
```django
{% component "my_comp" value_from_tag="{% gen_dict %}" %}
```
We call this the "dynamic" or "nested" expression.
A string is marked as a dynamic expression only if it contains any one
of `{{ }}`, `{% %}`, or `{# #}`.
@abstractmethod
def resolve(self, context: Context) -> Any: ... # noqa E704
If the expression consists of a single tag, with no extra text, we return the tag's
value directly. E.g.:
```django
{% component "my_comp" value_from_tag="{% gen_dict %}" %}
```
class SpreadOperator(Operator):
"""Operator that inserts one or more kwargs at the specified location."""
will pass a dictionary to the component input `value_from_tag`.
def __init__(self, expr: Expression) -> None:
self.expr = expr
But if the text already contains spaces or more tags, e.g.
def resolve(self, context: Context) -> Dict[str, Any]:
data = self.expr.resolve(context)
if not isinstance(data, dict):
raise RuntimeError(f"Spread operator expression must resolve to a Dict, got {data}")
return data
`{% component "my_comp" value_from_tag=" {% gen_dict %} " %}`
Then we treat it as a regular template and pass it as string.
"""

class DynamicFilterExpression:
def __init__(self, parser: Parser, expr_str: str) -> None:
if not is_dynamic_expression(expr_str):
raise TemplateSyntaxError(f"Not a valid dynamic expression: '{expr_str}'")
Expand Down Expand Up @@ -103,72 +104,6 @@ def render(self, context: Context) -> str:
return str(result)


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)


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:
if isinstance(kwarg, SpreadOperator):
spread_kwargs = kwarg.resolve(context)
for spread_key, spread_value in spread_kwargs.items():
resolved_kwarg_pairs.append((spread_key, spread_value))
else:
resolved_kwarg_pairs.append((key, kwarg.resolve(context)))

return resolved_kwarg_pairs


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(
context: Context,
kwargs: Dict[str, Union[Expression, "Operator"]],
) -> Dict[str, Any]:
result = {}

for key, kwarg in kwargs.items():
# If we've come across a Spread Operator (...), we insert the kwargs from it here
if isinstance(kwarg, SpreadOperator):
spread_dict = kwarg.resolve(context)
if spread_dict is not None:
for spreadkey, spreadkwarg in spread_dict.items():
result[spreadkey] = spreadkwarg
else:
result[key] = kwarg.resolve(context)
return result


def resolve_string(
s: str,
parser: Optional[Parser] = None,
context: Optional[Mapping[str, Any]] = None,
) -> str:
parser = parser or Parser([])
context = Context(context or {})
return parser.compile_filter(s).resolve(context)


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.
Expand Down Expand Up @@ -203,14 +138,8 @@ def is_dynamic_expression(value: Any) -> bool:
return True


def is_spread_operator(value: Any) -> bool:
if not isinstance(value, str) or not value:
return False

return value.startswith("...")


def process_aggregate_kwargs(kwargs: Mapping[str, Any]) -> Dict[str, Any]:
# TODO - Move this out into a plugin?
def process_aggregate_kwargs(params: List["TagParam"]) -> List["TagParam"]:
"""
This function aggregates "prefixed" kwargs into dicts. "Prefixed" kwargs
start with some prefix delimited with `:` (e.g. `attrs:`).
Expand Down Expand Up @@ -264,26 +193,65 @@ def process_aggregate_kwargs(kwargs: Mapping[str, Any]) -> Dict[str, Any]:
"fallthrough attributes", and sufficiently easy for component authors to process
that input while still being able to provide their own keys.
"""
processed_kwargs = {}
from django_components.util.template_tag import TagParam

_check_kwargs_for_agg_conflict(params)

processed_params = []
seen_keys = set()
nested_kwargs: Dict[str, Dict[str, Any]] = {}
for key, val in kwargs.items():
if not is_aggregate_key(key):
processed_kwargs[key] = val
for param in params:
# Positional args
if param.key is None:
processed_params.append(param)
continue

# Regular kwargs without `:` prefix
if not is_aggregate_key(param.key):
outer_key = param.key
inner_key = None
seen_keys.add(outer_key)
processed_params.append(param)
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
# NOTE: Trim off the outer_key from keys
outer_key, inner_key = param.key.split(":", 1)
if outer_key not in nested_kwargs:
nested_kwargs[outer_key] = {}
nested_kwargs[outer_key][inner_key] = param.value

# Assign aggregated values into normal input
for key, val in nested_kwargs.items():
if key in processed_kwargs:
if key in seen_keys:
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
processed_params.append(TagParam(key=key, value=val))

return processed_params


def _check_kwargs_for_agg_conflict(params: List["TagParam"]) -> None:
seen_regular_kwargs = set()
seen_agg_kwargs = set()

for param in params:
# Ignore positional args
if param.key is None:
continue

is_agg_kwarg = is_aggregate_key(param.key)
if (
(is_agg_kwarg and (param.key in seen_regular_kwargs))
or (not is_agg_kwarg and (param.key in seen_agg_kwargs))
): # fmt: skip
raise TemplateSyntaxError(
f"Received argument '{param.key}' both as a regular input ({param.key}=...)"
f" and as an aggregate dict ('{param.key}:key=...'). Must be only one of the two"
)

return processed_kwargs
if is_agg_kwarg:
seen_agg_kwargs.add(param.key)
else:
seen_regular_kwargs.add(param.key)
Loading

0 comments on commit 8cd4b03

Please sign in to comment.