Skip to content

feat: Literal dicts and lists part 2 #902

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Conversation

JuroOravec
Copy link
Collaborator

@JuroOravec JuroOravec commented Jan 13, 2025

Changes:

  • Inputs to template tags now behave closer to python functions:
    • Overriding kwargs:
      {% component **attrs1 **attrs2 %}
      • Before, kwargs worked that latter kwargs overrode earlier. So, below, if attrs1 and attr2 contained the same key, the key in attrs1 would be ignored and overriden by the value of the key in attrs2
      • Now, this works the same way as calling python function like component(**attrs1, **attrs2), which will raise TypeError if both contain the same key.
      • To work around this, one can merge the two dictionaries before spreading them onto the template tag:
        {% component
           ...{
               **attrs1,
               **attrs2,
           }
        %}
  • One can now spread both lists and dicts
    • Previously, only dicts could be spread with ...
  • One may now put positional args after kwargs.
  • It is no longer possible to start key names with :, because it's indistinguishable from template filters
    • E.g. {% component key1=val1|lower :placeholder="No text" %}
    • Instead, users will now have to scope such keys under dictionaries, as attrs::placeholder="No text"

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:
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This updates the logic how we generate the API reference for template tags (e.g. {% component %}, etc).

@JuroOravec JuroOravec force-pushed the jo-list-and-dict-literals-part-2 branch from ce39271 to 4984b3b Compare January 13, 2025 14:47
@@ -18,32 +18,19 @@
class HtmlAttrsNode(BaseNode):
def __init__(
self,
kwargs: RuntimeKwargs,
kwarg_pairs: RuntimeKwargPairs,
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Previously, one could pass in the same key multiple times to key defined on {% html_attrs %}, e.g.:

{% html_attrs
    class="pt-4"
    class=class_from_var
    class="extra-class"
%}

However, the inputs to template tags now follow how regular python functions accept input. And so, it's no longer valid to pass in multiple kwargs with the same key.

To keep the {% html_attrs %} working the same, I added this behaviour upstream in https://github.com/EmilStenstrom/django-components/pull/902/files#diff-56d60a8b1d11d8ff86b912ddde6d8ee539f71a1fae917bc39a1fd049ea22705cR309.

With the support for literal lists and dicts, I suggest to deprecate the behaviour of supporting multiple same-name keys, and then remove it in v1.

And instead, once in v1, one would do:

{% html_attrs
    class=["pt-4", class_from_var, "extra-class"]
%}

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to our discussion of attrs:class=X I like this syntax. It isn't that DRY, but it reads better than python syntax imho. I like the idea (later in the thread) of it being an automatically enabled plugin that converts one syntax to the other, instead of being removed.

@@ -18,32 +18,19 @@
class HtmlAttrsNode(BaseNode):
def __init__(
self,
kwargs: RuntimeKwargs,
kwarg_pairs: RuntimeKwargPairs,
params: TagParams,
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had to update all or Node classes, so that they accept a TagParams object instead of specific kwargs or args.

TagParams is simply an object that holds the data about the args and kwargs that were passed to a template tag, and will resolve it at render time, when there is access to the Context object.


For more info:

Previously when I used a template tag, e.g.

{% component arg1 key1=arg ...attrs key2=123 / %}

We statically decided which parts of that are arguments, and which are kwargs.

So e.g. in the example above:

  • args would be arg1,
  • kwargs would be key1=arg ...attrs key2=123

But in this PR I refactored this. And instead, we don't try to interpret the inputs statically at all. So how it now works is that:

  1. We take the whole input arg1 key1=arg ...attrs key2=123 and split it into "attributes", e.g. arg1, key1=arg, ...attrs, key2=123.
  2. Once we get to rendering, where we can resolve these attributes to their values, we expand the spreads.
    • if the spreaded value is a mapping (dict), we apply it as kwargs (**attrs)
    • if the spreaded value is an iterable (list / str), we apply it as args (*attrs)
    • Otherwise we raise, because we're trying to spread a non-iterable object.
  3. At this point we have the key-value pairs, but we still have them all as a single list of inputs. We don't know which ones are arguments and which ones are kwargs.
  4. New addition of this PR is that we use the function signature defined on the TagSpec to sort these parameters. Basically it's like as if we passed them all to a function like fn(arg1, key1=arg, **attrs, key2=123).
    • Thus, we let Python do the function validation. if there's duplicate keys, or a missing required param, or extra params, then python will throw TypeError.
  5. Also, inside this fn (called validator), we use the python function signature to discern which inputs are args and which are kwargs.


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"]:
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interestingly, with the new flow, the attr:key=val syntax could be later redefined as an (internal) plugin.

@@ -103,72 +104,6 @@ def render(self, context: Context) -> str:
return str(result)


class RuntimeKwargs:
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Much of the logic around resolving args and kwargs passed to template tags was removed and replaced by the code here.

Personally I think the flow is now a bit simpler. Because where before we passed around half-resolved values like SpreadOperator or DynamicFilterExpression, all of that is now encapsulated by TagAttr. And at render time, we resolve it all into TagParam, which just holds the key and value of arg / kwarg.

@@ -267,7 +266,7 @@ def parse(self, tokens: List[str]) -> TagResult:
raise TemplateSyntaxError(f"Component name must be a string 'literal', got: {comp_name}")

# Remove the quotes
comp_name = resolve_string(comp_name)
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The old resolve_string used needlessly complex logic to remove surrounding double quotes (it relied on parsing a string as FilterExpression and evaluating that).

@register.tag("slot")
@with_tag_spec(
TagSpec(
tag="slot",
end_tag="endslot",
positional_only_args=[],
Copy link
Collaborator Author

@JuroOravec JuroOravec Jan 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The whole point of the TagSpec is to:

  1. validate the parameters passed to template tags
  2. as source of truth for the documentation

As you can see, the old values were already based on how Python describes function input - there's positional-only args, keyword-only args, and params which can be either as positional or as kwargs.

Instead of listing all of those, I switched to simply use the already-available inspect.Signature, which captures allof this info too.

The only difference is that for {% html_attrs %} we allowed to pass multiple kwargs with the same key, which is not allowed with a normal python function. That's why I want to deprecate that eventually, so that calling template tags from within the template would eventually behave similarly to calling python functions.

As for why I introduced the TagSpec.signature, see this code. Basically that function constructs another function ("validator"), and passing in the parameters. And the validator has its signature set to TagSpec.signature. This makes it so that when then we pass in parameters to the validator function, it raises an error the same way as when you pass wrong input to a python function.

And constructing the validator from the signature was much simpler than constructing it from all the individual fields.


elif self.type == "dict":
resolved_dict: Dict = {}
dict_pair: List = []
Copy link
Collaborator Author

@JuroOravec JuroOravec Jan 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fun fact:

How I handled the literal dictonaries is that the the keys and values are stored as a list. But not as a list of key-value tuples, but a flat list.

The reason for that is because dictionaries may contain spread syntax, like so:

{"key1": val1, **attrs, "key2": val2}

So one way to look at the dictionary is that as if both , and : were delimeters. So the example above would be parsed into a list like so (omitting the classes that wrap the values for brevity):

["key1", val1, **attrs, "key2", val2]

However, this means that we then need to validate that:

  • the values between start / end / spreads should always be in pairs (e.g. "key1" and val1). E.g.:
    ["key1", val1, **attrs, "key2", val2]
       (key)  (val) (spread) (key)   (val)
    
  • The spead must be on the position of dictionary key.

Thus, with this structure, it's only here in resolve() (AKA at render time), when we replace the spread with the actual values, and where we finally construct a python dictionary out of this flat representation.

Note: I decided for the flat structure mostly because I found it hard to make sense of the code, when there was a lot of variables that could be either a simple value or a tuple of two values.

# and we want to parse `yesno` next
if not is_first_part:
filter_token = taken_n(1) # | or :
take_while(TAG_WHITESPACE) # Allow whitespace after filter

if filter_token == ":" and values_parts[-1].filter != "|":
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Django's filters have structure var|filter:arg, where the filters can be chained. This addition catches the case when the expression uses : without previously using |.

So e.g. var|filter:arg is fine, while var:arg will raise an error.

@@ -87,9 +35,106 @@ class TagSpec(NamedTuple):
- and treated as `only=False` and `required=False` if omitted
"""

def copy(self) -> "TagSpec":
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We allow each component to potentially use a different template tag. E.g. {% table ... / %} instead of {% component "table" ... / %}.

For that, we have to set the tag and endtag fields on the component's TagSpec instance.

But that means we also have to make copies of the original TagSpec. Hence this copy() function.

return pos_args, kw_args

# Set the signature on the function
validator.__signature__ = self.signature # type: ignore[attr-defined]
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is one part of the validation magic. Even though the validator function was defined to accept *args: Any, **kwargs: Any, when we set the __signature__, then any args / kwargs that we pass to validator will be validated against this new signature.

try:
# Create function namespace
namespace: Dict[str, Any] = {"validator": validator, "call_args": call_args, "call_kwargs": call_kwargs}
exec(validator_call_script, namespace)
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the second part of the validation magic. I wanted the args and kwargs being passed to a template tag to behave same way sa it would behave if they were passed to a function.

So e.g. if we have a template tag {% slot name="abc" data=[1, 2, 3] %}, then this should behave as if passing the data as if {% slot %} was a fucntion:

slot(name="abc", data=[1, 2, 3])

But when it comes to dealing with spreads, e.g.:

slot(**attrs1, **attrs2)

Then if the two dictionaries had at least one shared key, then python would raise a TypeError. Because Python would evaluate this as

slot(
    # kwargs from attrs1
    key1=1,
    key2=2,
    # kwargs from attrs2
    key1=3,
    key4=4,
)

which contains two key1 kwargs, and thus raises.


Initially I wanted to pass the params to the validator as simply spreading them:

validator(*args, **kwargs)

But for that, the kwargs would already contain ALL combined kwargs. And while multiple same keys raises in a function, it does NOT in a dictionary. In other words

{ **attrs1, **attrs2 }

doesn't raise, and it keeps only the key1 from attrs2.

So this is not the behaviour I wanted.


Instead, the trick I did to achieve python function validation behaviour is that I wrapped each kwarg in it's own dictionary.

So instead of passing in

validator(key=value)

I instead did something like:

kwarg = {key: value}
validator(**kwarg)

But the challenge here was that I don't know of any way how to apply a dynamic number of spreads to a python function. So instead I turned to exec()

I dynamically generate a script like this, that contains as many **call_kwargs[0] as the kwargs I need to spread:

args, kwargs = validator(
    *call_args,
    **call_kwargs[0],
    **call_kwargs[1],
    **call_kwargs[2],
)

And then I execute the script, passing in the definition of call_args and call_kwargs.

Thus, if any of call_kwargs[0], call_kwargs[1], etc... contain kwargs with the same key, Python's function validation will capture that.

# it into args and kwargs
def validator(*args: Any, **kwargs: Any) -> Tuple[List[Any], Dict[str, Any]]:
# Let Python do the signature validation
bound = self.signature.bind(*args, **kwargs)
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Third part of the validation magic is that inside the validator, we use the given TagSpec.signature to figure out which parameters are args and kwargs.

Distinguishing between args and kwargs is important, because some of the Component features require this distinction.

  • E.g. for the type hints when passing inputs to Component.render(), args and kwargs have to be defined separately due to Python typing limitations.
  • Or the feature about component defaults, that will likely (or at least at first) apply only to kwargs.


def _parse_tag_input(tag_name: str, attrs: List[TagAttr]) -> Tuple[List[str], List[TagKwarg], Set[str]]:
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lots of changes and deletions in this file, as previously we tried to extract and separate args and kwargs BEFORE resolving the values, while now we do it only at render time. Only flags (e.g. {% slot "name" default %}) are still extracted at the time when the template is being parsed / compiled.

@@ -46,38 +46,6 @@ def make_context(d: Dict):
#######################


class ResolveTests(BaseTestCase):
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed, the tested functions were removeed.

@@ -729,7 +697,7 @@ def test_html_attrs(self):
)

@parametrize_context_behavior(["django", "isolated"])
def test_later_spreads_overwrite_earlier(self):
def test_later_spreads_do_not_overwrite_earlier(self):
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One of potentially breaking changes - Previously, when there was

{% component ...attrs1 ...attr2 %}

then if there was the same key in both attrs1 and attrs2, the first one would be overwritten.

But as described in some of the other comments here, now this raises an error

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is reasonable.

""",
)

@parametrize_context_behavior(["django", "isolated"])
def test_raises_if_positional_arg_after_spread(self):
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another change is that, since the template tag input now mimics the behaviour of python functions, it's possible to pass in args after kwargs, e.g.

{% component name="abc" 123 %}

Before, user would get an error if an arg was found after a kwarg

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this is allowed in python?

>>> def x(x, y=None): print(x, y)
...
>>> x(5, 6)
5 6
>>> x(y=4, 6)
  File "<stdin>", line 1
    x(y=4, 6)
            ^
SyntaxError: positional argument follows keyword argument

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, good catch! So it's a behaviour of the changes I made, not of python functions. Looking again, it's because I re-sort the inputs here to put all args into a single list, that then goes before the kwargs.

So if you were to call {% component "test" y=4 6 %}

it's as if the function was called like x( *[6], **{"y":4} )

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that's quite strange behavior frankly. That y=4 could have been a mistake (should have written x=4), and now the function is being called with another random parameter. Stuff could be deleted because of an error like that, if you send True to the wrong function. I think it would be safer to crash with a syntax error...

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yup, in the next PR one will have to again put args before kwargs!

)
)

class AggregateKwargsTest(BaseTestCase):
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This group of tests was moved here from tests/test_template_parser.py

attrs:@click.stop="dispatch('click_event')"
attrs:x-data="{hello: 'world'}"
attrs:class=class_var
attrs::placeholder="No text"
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another potentially breaking change is that it is no longer allowed to define keys that start with :.

Previously, one could have

{% component 'test' :placeholder="No text" %}

But now, if I want to pass down such key, I have to scoped it under a dictionary, as

{% component 'test' attrs::placeholder="No text" %}

This is because, in the previous PR, I updated the tag parser to allow whitespace around filters (|filter) and filter args (:arg).

But as a result of that, when we have a code like this:

{% component 'test'
    attrs:class=class_var|lower
    :placeholder="No text"
/ %}

Then it's hard to tell if user meant the above, or

{% component 'test'
    attrs:class=class_var|lower:placeholder="No text"
/ %}

We would need to have the knowledge that the filter lower does not accept any arguments to know that the first case is desirable. And I would rather not depend on such conditional evaluation.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed.

@@ -2414,3 +2496,361 @@ def test_spread_list_literal_as_attribute(self):
"...[val1]",
],
)

def test_dynamic_expressions(self):
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a lot of trivial code changes in this file as the TagValueStruct class now accepts a Parser, to be able to evaluate template tags and filters.

But the genuinely new tests start here.

@JuroOravec JuroOravec marked this pull request as ready for review January 13, 2025 19:22
@JuroOravec
Copy link
Collaborator Author

@EmilStenstrom This one is now ready for review. Sorry, this one is fairly complex as ti touches a lot of files. Unfortunately it felt like this was the minimum to make this work

I'll update the docs in a separate PR

Copy link
Collaborator

@EmilStenstrom EmilStenstrom left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi, happy with this feature!

I've read through the comments, but the code is way too much to get through in reasonable time. I've read through the comments and written a few things. Go ahead and merge.

@JuroOravec JuroOravec merged commit 8cd4b03 into django-components:master Jan 14, 2025
15 checks passed
@JuroOravec JuroOravec deleted the jo-list-and-dict-literals-part-2 branch January 14, 2025 08:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants