```
The `_common/controls.conf.j2` include sets up the rndc control channel, so
-the test (and the runner's shutdown sequence) can use `rndc`.
+the test (and the runner's shutdown sequence) can use `rndc`. Inside an
+indented section such as a view statement, use `{% include_indented "..." %}`
+instead of `{% include %}` — it aligns the inserted block with the tag's own
+indentation.
`demo/ns1/example.db` — a plain zone file:
import re
import jinja2
+import jinja2.ext
+import jinja2.nodes
+import jinja2.parser
from .log import debug
from .vars import ALL
NS_DIR_RE = Re(r"^(a?ns([0-9]+))/")
+class IncludeIndented(jinja2.ext.Extension):
+ """
+ `{% include_indented "template" %}` — like `{% include %}`, but keeps the
+ inserted block aligned with the tag's own indentation, which a plain
+ include cannot do. The tag's leading whitespace is detected at parse time
+ and the tag expands to the equivalent of
+
+ {% filter indent(leading_whitespace) %}{% include ... %}{% endfilter %}
+
+ so the runtime semantics are exactly those of the builtin include and
+ indent. Only whitespace may precede the tag on its line: that leading
+ whitespace indents the first included line (which is why lstrip_blocks
+ must stay disabled), the indent filter indents the rest.
+ """
+
+ tags = {"include_indented"}
+
+ def parse(self, parser: jinja2.parser.Parser) -> jinja2.nodes.Node:
+ lineno = parser.stream.expect("name:include_indented").lineno
+ template = parser.parse_expression()
+ indent = self._tag_indentation(parser, lineno)
+ include = jinja2.nodes.Include(template, True, False, lineno=lineno)
+ indent_filter = jinja2.nodes.Filter(
+ None, # filled in with the block contents by the compiler
+ "indent",
+ [jinja2.nodes.Const(indent)],
+ [jinja2.nodes.Keyword("first", jinja2.nodes.Const(False))],
+ None,
+ None,
+ lineno=lineno,
+ )
+ return jinja2.nodes.FilterBlock([include], indent_filter, lineno=lineno)
+
+ def _tag_indentation(self, parser: jinja2.parser.Parser, lineno: int) -> str:
+ if parser.name is None or self.environment.loader is None:
+ parser.fail(
+ "include_indented requires a loader-backed template "
+ "to detect its indentation",
+ lineno,
+ )
+ source, _, _ = self.environment.loader.get_source(self.environment, parser.name)
+ line = source.splitlines()[lineno - 1]
+ match = re.match(
+ rf"([ \t]*){re.escape(self.environment.block_start_string)}", line
+ )
+ if match is None:
+ parser.fail(
+ "include_indented must be preceded by indentation only",
+ lineno,
+ )
+ return match.group(1)
+
+
class TemplateEngine:
"""
Engine for rendering jinja2 templates in system test directories.
variable_end_string="@",
trim_blocks=True,
keep_trailing_newline=True,
+ extensions=[IncludeIndented],
)
# allow instantiating the template dataclasses in jinja2 templates when
# using {% set %}
cryptography
h2
hypothesis>=4.41.2
-jinja2
+jinja2>=3.0.0
pytest>=7.0.0
pytest-xdist
pyyaml
--- /dev/null
+# Copyright (C) Internet Systems Consortium, Inc. ("ISC")
+#
+# SPDX-License-Identifier: MPL-2.0
+#
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, you can obtain one at https://mozilla.org/MPL/2.0/.
+#
+# See the COPYRIGHT file distributed with this work for additional
+# information regarding copyright ownership.
+"""
+isctest.template self-test
+Check the {% include_indented %} tag of the TemplateEngine.
+"""
+
+import jinja2
+import pytest
+
+
+def render(templates, system_test_dir, name, source, data=None):
+ (system_test_dir / f"{name}.j2").write_text(source)
+ templates.render(name, data)
+ return (system_test_dir / name).read_text()
+
+
+def test_include_indented(templates, system_test_dir):
+ (system_test_dir / "inc.conf.j2").write_text("first @word@;\nsecond;\n")
+ output = render(
+ templates,
+ system_test_dir,
+ "main.conf",
+ 'block {\n\t{% include_indented "inc.conf.j2" %}\n};\n',
+ {"word": "value"},
+ )
+ assert output == "block {\n\tfirst value;\n\tsecond;\n};\n"
+
+
+def test_include_indented_depth_follows_tag(templates, system_test_dir):
+ (system_test_dir / "inc.conf.j2").write_text("a;\nb;\n")
+ output = render(
+ templates,
+ system_test_dir,
+ "nested.conf",
+ 'one {\n\ttwo {\n\t\t{% include_indented "inc.conf.j2" %}\n\t};\n};\n',
+ )
+ assert output == "one {\n\ttwo {\n\t\ta;\n\t\tb;\n\t};\n};\n"
+
+
+def test_include_indented_keeps_blank_lines_blank(templates, system_test_dir):
+ (system_test_dir / "inc.conf.j2").write_text("a;\n\nb;\n")
+ output = render(
+ templates,
+ system_test_dir,
+ "blank.conf",
+ '\t{% include_indented "inc.conf.j2" %}\n',
+ )
+ assert output == "\ta;\n\n\tb;\n"
+
+
+def test_include_indented_must_follow_indentation_only(templates, system_test_dir):
+ (system_test_dir / "inc.conf.j2").write_text("a;\n")
+ with pytest.raises(jinja2.TemplateSyntaxError, match="indentation only"):
+ render(
+ templates,
+ system_test_dir,
+ "inline.conf",
+ 'block { {% include_indented "inc.conf.j2" %}\n};\n',
+ )
+
+
+def test_include_indented_requires_loaded_template(templates):
+ with pytest.raises(jinja2.TemplateSyntaxError, match="loader-backed"):
+ templates.j2env.from_string('\t{% include_indented "inc.conf.j2" %}\n')
+
+
+def test_include_indented_common_prefix(templates, system_test_dir):
+ output = render(
+ templates,
+ system_test_dir,
+ "hint.conf",
+ 'view v {\n\t{% include_indented "_common/root.hint.conf" %}\n};\n',
+ )
+ assert output == (
+ "view v {\n"
+ '\tzone "." {\n'
+ "\t\ttype hint;\n"
+ '\t\tfile "../../_common/root.hint";\n'
+ "\t};\n"
+ "};\n"
+ )