Skip to content

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

@christophehenry

Description

@christophehenry

Code of Conduct

  • 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.

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)
 '<html></html>'
 """
-
+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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Status

    Idea

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions