django / django/new-features

Make it easier to declare tags and filters limited within the context of another tag

Open
#93 4 comments 2 reactions 0 assignees View on GitHub
Dominant language
No language data
Stars
188
Forks
7
PR merge metrics
No merged PRs in 30d

Description

### Code of Conduct

- [x] I agree to follow Django's Code of Conduct

### Feature Description

Several django builtin tags are only available within a parent tag, lik `{% plural %}`:

```
{% blocktranslate count %}

{% plural %}

{% endblocktranslate %}
```
while this raises a `TemplateSyntaxError`:

```
{% for item in items %}

{% plural %}

{% endfor %}
````

I want to modify `django.template.Parser` to make it easier to declare such local tags.

### Problem

Django template engine provides several ways to declare new tags extremely easily. Most notoriously `django.template.Library.inclusion_tag`, `django.template.Library.simple_tag` and `django.template.Library.simple_block_tag` or any subclass of `django.template.Node` if you want a more advanced usage.

However, in some cases, you may want to declare tags or filters that are available only within the context of a particular tag. For instance `{% elif %}` and `{% else %}` are only available within `{% if %}`, `{% empty %}` makes sense only within `{% for %}` and `{% plural %}` within `{% blocktranslate %}`.

In such cases, the current code doesn't offer a way to reuse code facilities. Any variant of `django.template.Library.tag` will register tags globally and leak them outside the parent tag and into children tags. If you want to implement local tags, you're back to [manually parsing the template, token by token](https://github.com/django/django/blob/main/django/template/defaulttags.py#L994-L1018).

While this may look like fringe need, I hit this problem regularly while working with design systems where simple components, or highly customizable components cannot be implemented with a single block tag and require implementing optionnal sub-block tags that only make sens withing a parent tag.

### Request or proposal

proposal

### Additional Details

_No response_

### Implementation Suggestions

This is my proposed API, using `{% if %}` as an example:

```
register = template.Library()

def do_elif(parser: Parser, token: Token):
....

def do_else(parser: Parser, token: Token):

@register.tag(name="if")
def do_if(parser: Parser, token: Token):
local_library = template.Library()
local_library.tag("elif", do_elif)
local_library.tag("else", do_else)

with parser.local_library(local_library) as local_parser:
nodelist = local_parser.parse(("endif",))

# process nodelist
````

This diff showcases how I think it can be implemented

````
diff --git a/django/template/base.py b/django/template/base.py
index 5e541c3960..46db18eb1e 100644
--- a/django/template/base.py
+++ b/django/template/base.py
@@ -49,7 +49,7 @@ times with multiple contexts)
>>> t.render(c)
''
"""
-
+import contextlib
import inspect
import logging
import re
@@ -498,7 +498,7 @@ class DebugLexer(Lexer):


class Parser:
- def __init__(self, tokens, libraries=None, builtins=None, origin=None):
+ def __init__(self, tokens, libraries=None, builtins=None, origin=None, parent=None):
# Reverse the tokens so delete_first_token(), prepend_token(), and
# next_token() can operate at the end of the list in constant time.
self.tokens = list(reversed(tokens))
@@ -522,6 +522,8 @@ class Parser:
self.add_library(builtin)
self.origin = origin

+ self.parent = parent
+
def __repr__(self):
return "<%s tokens=%r>" % (self.__class__.__qualname__, self.tokens)

@@ -579,7 +581,9 @@ class Parser:
# Compile the callback into a node object and add it to
# the node list.
try:
- compiled_result = compile_func(self, token)
+ # Passing self.parent prevents from local libraries to leak into
+ # children tags and filters
+ compiled_result = compile_func(self.parent or self, token)
except Exception as e:
raise self.error(token, e)
self.extend_nodelist(nodelist, compiled_result, token)
@@ -680,6 +684,21 @@ class Parser:
else:
raise TemplateSyntaxError("Invalid filter: '%s'" % filter_name)

+ @contextlib.contextmanager
+ def local_library(self, library):
+ # These fields are copied to prevent messing up with parent's
+ local_parser = Parser([], libraries=self.libraries.copy(), origin=self.origin, parent=self.parent)
+ local_parser.extra_data = self.extra_data.copy()
+
+ # This allows to directly manipulate parent's tokens when calling
+ # parser.next_token() or parser.parse()
+ local_parser.tokens = self.tokens
+ local_parser.command_stack = self.command_stack
+
+ local_parser.add_library(library)
+ yield local_parser
+
+

# This only matches constant *strings* (things in quotes or marked for
# translation). Numbers are treated as variables for implementation reasons
````

[I opened a thread on the forum to discuss this](https://forum.djangoproject.com/t/make-it-easier-to-declare-tags-and-filters-available-only-within-the-context-of-another-tag/43360/2).

Contributor guide

No contributing guide indexed for this repository

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.