add required attribute to blocks

required blocks must be overridden at some point, although not
necessarily by the direct child template
This commit is contained in:
Amy 2020-06-10 16:09:09 -04:00
parent bc22a8b0d3
commit 8da77f9753
7 changed files with 183 additions and 4 deletions

View 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

View 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
~~~~~~~~~~~~~~~~

View 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

View 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):

View 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):

View 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(

View 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):