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

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