From 8da77f9753be0c4357daf2ac8b53730a2d977fdf Mon Sep 17 00:00:00 2001 From: Amy Date: Wed, 10 Jun 2020 16:09:09 -0400 Subject: [PATCH] add required attribute to blocks required blocks must be overridden at some point, although not necessarily by the direct child template --- CHANGES.rst | 2 + docs/templates.rst | 34 +++++++++++ src/jinja2/compiler.py | 9 +++ src/jinja2/nodes.py | 8 ++- src/jinja2/parser.py | 13 ++++- tests/test_idtracking.py | 3 +- tests/test_inheritance.py | 118 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 183 insertions(+), 4 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 43e4ad6..03c162a 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -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 diff --git a/docs/templates.rst b/docs/templates.rst index 14de875..58ed870 100644 --- a/docs/templates.rst +++ b/docs/templates.rst @@ -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 ~~~~~~~~~~~~~~~~ diff --git a/src/jinja2/compiler.py b/src/jinja2/compiler.py index 251aec6..3098e3b 100644 --- a/src/jinja2/compiler.py +++ b/src/jinja2/compiler.py @@ -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 diff --git a/src/jinja2/nodes.py b/src/jinja2/nodes.py index 3d4b6fd..a0d719d 100644 --- a/src/jinja2/nodes.py +++ b/src/jinja2/nodes.py @@ -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): diff --git a/src/jinja2/parser.py b/src/jinja2/parser.py index eedea7a..589cca2 100644 --- a/src/jinja2/parser.py +++ b/src/jinja2/parser.py @@ -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): diff --git a/tests/test_idtracking.py b/tests/test_idtracking.py index 8a88467..4e1d2c3 100644 --- a/tests/test_idtracking.py +++ b/tests/test_idtracking.py @@ -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 \n")]), ], False, + False, ) tmpl = nodes.Template( diff --git a/tests/test_inheritance.py b/tests/test_inheritance.py index b95c47d..a075ebd 100644 --- a/tests/test_inheritance.py +++ b/tests/test_inheritance.py @@ -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):