From: Nicki Křížek Date: Mon, 3 Aug 2026 12:21:37 +0000 (+0000) Subject: Add an include_indented tag for indented template includes X-Git-Url: http://git.ipfire.org/gitweb.cgi?a=commitdiff_plain;h=ec9ba4ac823d445372a9bce0149a40b8a7a3bd02;p=thirdparty%2Fbind9.git Add an include_indented tag for indented template includes jinja2's {% include %} inserts the rendered file verbatim, so includes inside view statements have to be wrapped in a three-line {% filter indent %} block with the depth repeated by hand. Add a small jinja2 extension providing {% include_indented "..." %}, which detects the tag's own leading whitespace at parse time and expands to the builtin include wrapped in the builtin indent filter, keeping stock jinja2 runtime semantics. Detection needs the tag alone on its line in a loader-backed template; anything else fails at parse time instead of misrendering. Co-Authored-By: Martin Basti Assisted-by: Claude:claude-fable-5 --- diff --git a/bin/tests/system/COOKBOOK.md b/bin/tests/system/COOKBOOK.md index 08e51a1d84b..bc359abee66 100644 --- a/bin/tests/system/COOKBOOK.md +++ b/bin/tests/system/COOKBOOK.md @@ -96,7 +96,10 @@ zone "example" { ``` 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: diff --git a/bin/tests/system/isctest/template.py b/bin/tests/system/isctest/template.py index e0606fb0fb4..520019f4088 100644 --- a/bin/tests/system/isctest/template.py +++ b/bin/tests/system/isctest/template.py @@ -19,6 +19,9 @@ from typing import TYPE_CHECKING, Any import re import jinja2 +import jinja2.ext +import jinja2.nodes +import jinja2.parser from .log import debug from .vars import ALL @@ -29,6 +32,59 @@ if TYPE_CHECKING: 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. @@ -60,6 +116,7 @@ class TemplateEngine: variable_end_string="@", trim_blocks=True, keep_trailing_newline=True, + extensions=[IncludeIndented], ) # allow instantiating the template dataclasses in jinja2 templates when # using {% set %} diff --git a/bin/tests/system/requirements.txt b/bin/tests/system/requirements.txt index 47d9c7797d9..dd6e6930ea9 100644 --- a/bin/tests/system/requirements.txt +++ b/bin/tests/system/requirements.txt @@ -5,7 +5,7 @@ dnspython>=2.7.0 cryptography h2 hypothesis>=4.41.2 -jinja2 +jinja2>=3.0.0 pytest>=7.0.0 pytest-xdist pyyaml diff --git a/bin/tests/system/selftest/tests_template.py b/bin/tests/system/selftest/tests_template.py new file mode 100644 index 00000000000..f6b3b9a1ced --- /dev/null +++ b/bin/tests/system/selftest/tests_template.py @@ -0,0 +1,90 @@ +# 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" + )