]> git.ipfire.org Git - thirdparty/bind9.git/commitdiff
Add an include_indented tag for indented template includes
authorNicki Křížek <nicki@isc.org>
Mon, 3 Aug 2026 12:21:37 +0000 (12:21 +0000)
committerNicki Křížek <nicki@isc.org>
Thu, 6 Aug 2026 11:50:09 +0000 (13:50 +0200)
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 <mbasti@isc.org>
Assisted-by: Claude:claude-fable-5
bin/tests/system/COOKBOOK.md
bin/tests/system/isctest/template.py
bin/tests/system/requirements.txt
bin/tests/system/selftest/tests_template.py [new file with mode: 0644]

index 08e51a1d84b4a6e111071cc853e162a32c76517f..bc359abee667a507699ff1455c9aafbe5a418ef6 100644 (file)
@@ -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:
 
index e0606fb0fb47b49f056b0b10357e0931bc5047b2..520019f408812c4097dc6102272f5c46f56c60cd 100644 (file)
@@ -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 %}
index 47d9c7797d9636e032a1e3d5ab0cba898ecd5f88..dd6e6930ea95a975b377c86657eb2a51916f79fd 100644 (file)
@@ -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 (file)
index 0000000..f6b3b9a
--- /dev/null
@@ -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"
+    )