]> git.ipfire.org Git - thirdparty/jinja.git/commitdiff
add required attribute to blocks 1233/head
authorAmy <leiamy12@gmail.com>
Wed, 10 Jun 2020 20:09:09 +0000 (16:09 -0400)
committerAmy <leiamy12@gmail.com>
Fri, 29 Jan 2021 15:28:39 +0000 (10:28 -0500)
required blocks must be overridden at some point, although not
necessarily by the direct child template

CHANGES.rst
docs/templates.rst
src/jinja2/compiler.py
src/jinja2/nodes.py
src/jinja2/parser.py
tests/test_idtracking.py
tests/test_inheritance.py

index 43e4ad6acc528342f9479bd27fcfe2cd9ee045ae..03c162a41f5981ede156a78ea041b4a2302a5cfe 100644 (file)
@@ -19,6 +19,8 @@ Unreleased
 -   Fix UndefinedError incorrectly being thrown on an undefined variable
     instead of ``Undefined`` being returned on
     ``NativeEnvironment`` on Python 3.10. :issue:`1335`
+-   Add ``required`` attribute to blocks that must be overridden at some
+    point, but not necessarily by the direct child :issue:`1147`
 
 
 Version 2.11.2
index 14de875e3603a37edf227a9c42123c1e4994bdb5..58ed870ef77a520793ac35d7d087dc245ddc34b8 100644 (file)
@@ -540,6 +540,40 @@ modifier to a block declaration::
 When overriding a block, the `scoped` modifier does not have to be provided.
 
 
+Required Blocks
+~~~~~~~~~~~~~~~~~~~~~~~
+
+Blocks can be marked as required. They must be overridden at some point,
+but not necessarily by the direct child template. Required blocks can
+only contain whitespace or comments, and they cannot be rendered directly.
+
+For example::
+
+    # parent.tmpl
+    body: {% block body required %}{% endblock %}
+
+    # child.tmpl
+    {% extends "parent.tmpl" %}
+
+    # grandchild.tmpl
+    {% extends "child.tmpl" %}
+    {% block body %}Hi from grandchild.{% endblock %}
+
+
+Rendering ``child.tmpl`` will give
+``TemplateRuntimeError``
+
+Rendering ``grandchild.tmpl`` will give
+``Hi from grandchild.``
+
+When combined with ``scoped``, the ``required`` modifier must be placed `after`
+the scoped modifier.  Here are some valid examples::
+
+    {% block body scoped %}{% endblock %}
+    {% block body required %}{% endblock %}
+    {% block body scoped required %}{% endblock %}
+
+
 Template Objects
 ~~~~~~~~~~~~~~~~
 
index 251aec6e4f216aca314324f2443d27852f1b9382..3098e3b1c6d6c5f49bac18247e048e1845248dcb 100644 (file)
@@ -797,6 +797,15 @@ class CodeGenerator(NodeVisitor):
         else:
             context = self.get_context_ref()
 
+        if node.required:
+            self.writeline(f"if len(context.blocks[{node.name!r}]) <= 1:", node)
+            self.indent()
+            self.writeline(
+                f'raise TemplateRuntimeError("Required block {node.name!r} not found")',
+                node,
+            )
+            self.outdent()
+
         if not self.environment.is_async and frame.buffer is None:
             self.writeline(
                 f"yield from context.blocks[{node.name!r}][0]({context})", node
index 3d4b6fdeda118f4523abe66750f5ed95fbdf7f5b..a0d719dee395a346a8a1f61f91209fa9ea15e71b 100644 (file)
@@ -340,9 +340,13 @@ class With(Stmt):
 
 
 class Block(Stmt):
-    """A node that represents a block."""
+    """A node that represents a block.
 
-    fields = ("name", "body", "scoped")
+    .. versionchanged:: 3.0.0
+        the `required` field was added.
+    """
+
+    fields = ("name", "body", "scoped", "required")
 
 
 class Include(Stmt):
index eedea7a03a9c5ef2c22df6f227fe00c665ac18d5..589cca2743f4542f7bd8b22140c7e180e3ba4611 100644 (file)
@@ -255,6 +255,7 @@ class Parser:
         node = nodes.Block(lineno=next(self.stream).lineno)
         node.name = self.stream.expect("name").value
         node.scoped = self.stream.skip_if("name:scoped")
+        node.required = self.stream.skip_if("name:required")
 
         # common problem people encounter when switching from django
         # to jinja.  we do not support hyphens in block names, so let's
@@ -266,6 +267,17 @@ class Parser:
             )
 
         node.body = self.parse_statements(("name:endblock",), drop_needle=True)
+
+        # enforce that required blocks only contain whitespace or comments
+        # by asserting that the body, if not empty, is just TemplateData nodes
+        # with whitespace data
+        if node.required and not all(
+            isinstance(child, nodes.TemplateData) and child.data.isspace()
+            for body in node.body
+            for child in body.nodes
+        ):
+            self.fail("Required blocks can only contain comments or whitespace")
+
         self.stream.skip_if("name:" + node.name)
         return node
 
@@ -924,7 +936,6 @@ class Parser:
         finally:
             if end_tokens is not None:
                 self._end_token_stack.pop()
-
         return body
 
     def parse(self):
index 8a884671dfaa30f774e56f310e5311bef74c8840..4e1d2c3d4555d4e84d5e605ad2816b15a49b6e3e 100644 (file)
@@ -38,7 +38,7 @@ def test_basics():
 
 def test_complex():
     title_block = nodes.Block(
-        "title", [nodes.Output([nodes.TemplateData("Page Title")])], False
+        "title", [nodes.Output([nodes.TemplateData("Page Title")])], False, False
     )
 
     render_title_macro = nodes.Macro(
@@ -137,6 +137,7 @@ def test_complex():
             nodes.Output([nodes.TemplateData("\n  </ul>\n")]),
         ],
         False,
+        False,
     )
 
     tmpl = nodes.Template(
index b95c47db747dd87c0a6b4f86f353c7d691f0ae67..a075ebdcb6ceb6c80f4de590566baff371bc842f 100644 (file)
@@ -3,6 +3,7 @@ import pytest
 from jinja2 import DictLoader
 from jinja2 import Environment
 from jinja2 import TemplateRuntimeError
+from jinja2 import TemplateSyntaxError
 
 LAYOUTTEMPLATE = """\
 |{% block block1 %}block 1 from layout{% endblock %}
@@ -230,6 +231,123 @@ class TestInheritance:
         rv = env.get_template("index.html").render(the_foo=42).split()
         assert rv == ["43", "44", "45"]
 
+    def test_level1_required(self, env):
+        env = Environment(
+            loader=DictLoader(
+                {
+                    "master": "{% block x required %}{# comment #}\n {% endblock %}",
+                    "level1": "{% extends 'master' %}{% block x %}[1]{% endblock %}",
+                }
+            )
+        )
+        rv = env.get_template("level1").render()
+        assert rv == "[1]"
+
+    def test_level2_required(self, env):
+        env = Environment(
+            loader=DictLoader(
+                {
+                    "master": "{% block x required %}{% endblock %}",
+                    "level1": "{% extends 'master' %}{% block x %}[1]{% endblock %}",
+                    "level2": "{% extends 'master' %}{% block x %}[2]{% endblock %}",
+                }
+            )
+        )
+        rv1 = env.get_template("level1").render()
+        rv2 = env.get_template("level2").render()
+
+        assert rv1 == "[1]"
+        assert rv2 == "[2]"
+
+    def test_level3_required(self, env):
+        env = Environment(
+            loader=DictLoader(
+                {
+                    "master": "{% block x required %}{% endblock %}",
+                    "level1": "{% extends 'master' %}",
+                    "level2": "{% extends 'level1' %}{% block x %}[2]{% endblock %}",
+                    "level3": "{% extends 'level2' %}",
+                }
+            )
+        )
+        t1 = env.get_template("level1")
+        t2 = env.get_template("level2")
+        t3 = env.get_template("level3")
+
+        with pytest.raises(TemplateRuntimeError, match="Required block 'x' not found"):
+            assert t1.render()
+
+        assert t2.render() == "[2]"
+        assert t3.render() == "[2]"
+
+    def test_invalid_required(self, env):
+        env = Environment(
+            loader=DictLoader(
+                {
+                    "master": "{% block x required %}data {# #}{% endblock %}",
+                    "master1": "{% block x required %}{% block y %}"
+                    "{% endblock %}  {% endblock %}",
+                    "master2": "{% block x required %}{% if true %}"
+                    "{% endif %}  {% endblock %}",
+                    "level1": "{% if master %}{% extends master %}"
+                    "{% else %}{% extends 'master' %}{% endif %}"
+                    "{%- block x %}CHILD{% endblock %}",
+                }
+            )
+        )
+        t = env.get_template("level1")
+
+        with pytest.raises(
+            TemplateSyntaxError,
+            match="Required blocks can only contain comments or whitespace",
+        ):
+            assert t.render(master="master")
+            assert t.render(master="master2")
+            assert t.render(master="master3")
+
+    def test_required_with_scope(self, env):
+        env = Environment(
+            loader=DictLoader(
+                {
+                    "master1": "{% for item in seq %}[{% block item scoped required %}"
+                    "{% endblock %}]{% endfor %}",
+                    "child1": "{% extends 'master1' %}{% block item %}"
+                    "{{ item }}{% endblock %}",
+                    "master2": "{% for item in seq %}[{% block item required scoped %}"
+                    "{% endblock %}]{% endfor %}",
+                    "child2": "{% extends 'master2' %}{% block item %}"
+                    "{{ item }}{% endblock %}",
+                }
+            )
+        )
+        t1 = env.get_template("child1")
+        t2 = env.get_template("child2")
+
+        assert t1.render(seq=list(range(3))) == "[0][1][2]"
+
+        # scoped must come before required
+        with pytest.raises(TemplateSyntaxError):
+            t2.render(seq=list(range(3)))
+
+    def test_duplicate_required_or_scoped(self, env):
+        env = Environment(
+            loader=DictLoader(
+                {
+                    "master1": "{% for item in seq %}[{% block item "
+                    "scoped scoped %}}{{% endblock %}}]{{% endfor %}}",
+                    "master2": "{% for item in seq %}[{% block item "
+                    "required required %}}{{% endblock %}}]{{% endfor %}}",
+                    "child": "{% if master %}{% extends master %}{% else %}"
+                    "{% extends 'master1' %}{% endif %}{%- block x %}"
+                    "CHILD{% endblock %}",
+                }
+            )
+        )
+        tmpl = env.get_template("child")
+        with pytest.raises(TemplateSyntaxError):
+            tmpl.render(master="master1", seq=list(range(3)))
+            tmpl.render(master="master2", seq=list(range(3)))
+
 
 class TestBugFix:
     def test_fixed_macro_scoping_bug(self, env):