Add a rule to expand jinja2 templates to a file.

BUG=b:262458659
TEST=portage/tools/run_tests.sh

Change-Id: Ia76f595e57910e6b66f834b9f199ab8edbe826e2
Reviewed-on: https://chromium-review.googlesource.com/c/chromiumos/bazel/+/5367139
Auto-Submit: Matt Stark <msta@google.com>
Commit-Queue: Matt Stark <msta@google.com>
Reviewed-by: Tim Bain <tbain@google.com>
Tested-by: Matt Stark <msta@google.com>
diff --git a/build_defs/expand_template/BUILD.bazel b/build_defs/expand_template/BUILD.bazel
new file mode 100644
index 0000000..5fba193
--- /dev/null
+++ b/build_defs/expand_template/BUILD.bazel
@@ -0,0 +1,43 @@
+# Copyright 2024 The ChromiumOS Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+load("@bazel_skylib//rules:diff_test.bzl", "diff_test")
+load("@pip//:requirements.bzl", "requirement")
+load("@rules_python//python:defs.bzl", "py_binary")
+load(":expand_template.bzl", "expand_template")
+
+py_binary(
+    name = "expand_template",
+    srcs = ["expand_template.py"],
+    visibility = ["//visibility:public"],
+    deps = [
+        requirement("jinja2"),
+    ],
+)
+
+expand_template(
+    name = "simple_json",
+    template = "testdata/simple.jinja2",
+    vars_file = "testdata/simple.in.json",
+)
+
+diff_test(
+    name = "simple_json_test",
+    file1 = ":simple_json",
+    file2 = "testdata/simple.out",
+)
+
+expand_template(
+    name = "simple_vars",
+    template = "testdata/simple.jinja2",
+    vars = {
+        "foo": "bar",
+    },
+)
+
+diff_test(
+    name = "simple_vars_test",
+    file1 = ":simple_vars",
+    file2 = "testdata/simple.out",
+)
diff --git a/build_defs/expand_template/expand_template.bzl b/build_defs/expand_template/expand_template.bzl
new file mode 100644
index 0000000..1cf63c9
--- /dev/null
+++ b/build_defs/expand_template/expand_template.bzl
@@ -0,0 +1,66 @@
+# Copyright 2024 The ChromiumOS Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+"""Rules for expanding templates."""
+
+visibility("public")
+
+def _expand_template_impl(ctx):
+    if bool(ctx.attr.vars) == bool(ctx.attr.vars_file):
+        fail("Exactly one of vars and vars_file must be provided")
+    if ctx.attr.vars_file:
+        vars_file = ctx.file.vars_file
+    else:
+        vars_file = ctx.actions.declare_file("_" + ctx.label.name + "_vars.json")
+        ctx.actions.write(vars_file, content = ctx.attr.vars)
+
+    out = ctx.actions.declare_file(ctx.attr.out or ctx.label.name)
+
+    args = ctx.actions.args()
+    args.add_all([ctx.file.template, vars_file, out])
+    ctx.actions.run(
+        executable = ctx.executable._expand,
+        arguments = [args],
+        inputs = [ctx.file.template, vars_file],
+        outputs = [out],
+    )
+
+    return [DefaultInfo(
+        files = depset([out]),
+        runfiles = ctx.runfiles(files = [out]),
+    )]
+
+_expand_template = rule(
+    implementation = _expand_template_impl,
+    attrs = dict(
+        vars = attr.string(doc = "Json-encoded mapping from variable to content"),
+        vars_file = attr.label(allow_single_file = [".json"]),
+        template = attr.label(allow_single_file = [".jinja2"], mandatory = True),
+        out = attr.string(),
+        _expand = attr.label(
+            default = "@@//bazel/build_defs/expand_template",
+            executable = True,
+            cfg = "exec",
+        ),
+    ),
+)
+
+def expand_template(*, name, vars = None, **kwargs):
+    """Expands a jinja2 template.
+
+    Args:
+        name: (str) The name of the build rule
+        vars: (Mapping[str, json-able]) A mapping from variable names to values.
+          Values must be able to be converted to json.
+        **kwargs: kwargs to pass to _expand_template"""
+    if type(vars) == "dict":
+        vars = json.encode(vars)
+    elif vars != None:
+        fail("vars must be a dictionary mapping from variable name to value")
+
+    _expand_template(
+        name = name,
+        vars = vars,
+        **kwargs
+    )
diff --git a/build_defs/expand_template/expand_template.py b/build_defs/expand_template/expand_template.py
new file mode 100644
index 0000000..7b72513
--- /dev/null
+++ b/build_defs/expand_template/expand_template.py
@@ -0,0 +1,47 @@
+# Copyright 2024 The ChromiumOS Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+"""A binary to expand jinja2 templates."""
+
+import json
+import pathlib
+import sys
+
+from jinja2 import DictLoader
+from jinja2 import Environment
+
+
+_TEMPLATE_NAME = "default"
+
+
+class TemplateError(Exception):
+    """An exception raised by a template"""
+
+
+def template_error(fmt, **kwargs):
+    raise TemplateError(fmt.format(**kwargs))
+
+
+def main(template: pathlib.Path, vars_file: pathlib.Path, out: pathlib.Path):
+    vars_values = json.loads(vars_file.read_text())
+    template = template.read_text()
+
+    env = Environment(
+        loader=DictLoader({_TEMPLATE_NAME: template}),
+        # This isn't HTML. We don't need HTML autoescaping.
+        autoescape=False,
+        lstrip_blocks=True,
+        trim_blocks=True,
+    )
+    env.globals["error"] = template_error
+    template = env.get_template(_TEMPLATE_NAME)
+    out.write_text(template.render(**vars_values))
+
+
+if __name__ == "__main__":
+    main(
+        template=pathlib.Path(sys.argv[1]),
+        vars_file=pathlib.Path(sys.argv[2]),
+        out=pathlib.Path(sys.argv[3]),
+    )
diff --git a/build_defs/expand_template/testdata/simple.in.json b/build_defs/expand_template/testdata/simple.in.json
new file mode 100644
index 0000000..c8c4105
--- /dev/null
+++ b/build_defs/expand_template/testdata/simple.in.json
@@ -0,0 +1,3 @@
+{
+  "foo": "bar"
+}
diff --git a/build_defs/expand_template/testdata/simple.jinja2 b/build_defs/expand_template/testdata/simple.jinja2
new file mode 100644
index 0000000..008c714
--- /dev/null
+++ b/build_defs/expand_template/testdata/simple.jinja2
@@ -0,0 +1 @@
+{{ foo }}
\ No newline at end of file
diff --git a/build_defs/expand_template/testdata/simple.out b/build_defs/expand_template/testdata/simple.out
new file mode 100644
index 0000000..ba0e162
--- /dev/null
+++ b/build_defs/expand_template/testdata/simple.out
@@ -0,0 +1 @@
+bar
\ No newline at end of file
diff --git a/python/requirements.txt b/python/requirements.txt
index c2547a7..b188348 100644
--- a/python/requirements.txt
+++ b/python/requirements.txt
@@ -1,2 +1,3 @@
+jinja2~=3.1.3
 pylint~=2.16.1
 zstandard~=0.21.0
diff --git a/python/requirements_lock.txt b/python/requirements_lock.txt
index d09c1cd..56b6d9d 100644
--- a/python/requirements_lock.txt
+++ b/python/requirements_lock.txt
@@ -16,6 +16,10 @@
     --hash=sha256:8bef7dde241278824a6d83f44a544709b065191b95b6e50894bdc722fcba0504 \
     --hash=sha256:f84c2818376e66cf843d497486ea8fed8700b340f308f076c6fb1229dff318b6
     # via pylint
+jinja2==3.1.3 \
+    --hash=sha256:7d6d50dd97d52cbc355597bd845fabfbac3f551e1f99619e39a35ce8c370b5fa \
+    --hash=sha256:ac8bd6544d4bb2c9792bf3a159e80bba8fda7f07e81bc3aed565432d5925ba90
+    # via -r bazel/python/requirements.txt
 lazy-object-proxy==1.9.0 \
     --hash=sha256:09763491ce220c0299688940f8dc2c5d05fd1f45af1e42e636b2e8b2303e4382 \
     --hash=sha256:0a891e4e41b54fd5b8313b96399f8b0e173bbbfc03c7631f01efbe29bb0bcf82 \
@@ -54,6 +58,68 @@
     --hash=sha256:f2457189d8257dd41ae9b434ba33298aec198e30adf2dcdaaa3a28b9994f6adb \
     --hash=sha256:f699ac1c768270c9e384e4cbd268d6e67aebcfae6cd623b4d7c3bfde5a35db59
     # via astroid
+markupsafe==2.1.5 \
+    --hash=sha256:00e046b6dd71aa03a41079792f8473dc494d564611a8f89bbbd7cb93295ebdcf \
+    --hash=sha256:075202fa5b72c86ad32dc7d0b56024ebdbcf2048c0ba09f1cde31bfdd57bcfff \
+    --hash=sha256:0e397ac966fdf721b2c528cf028494e86172b4feba51d65f81ffd65c63798f3f \
+    --hash=sha256:17b950fccb810b3293638215058e432159d2b71005c74371d784862b7e4683f3 \
+    --hash=sha256:1f3fbcb7ef1f16e48246f704ab79d79da8a46891e2da03f8783a5b6fa41a9532 \
+    --hash=sha256:2174c595a0d73a3080ca3257b40096db99799265e1c27cc5a610743acd86d62f \
+    --hash=sha256:2b7c57a4dfc4f16f7142221afe5ba4e093e09e728ca65c51f5620c9aaeb9a617 \
+    --hash=sha256:2d2d793e36e230fd32babe143b04cec8a8b3eb8a3122d2aceb4a371e6b09b8df \
+    --hash=sha256:30b600cf0a7ac9234b2638fbc0fb6158ba5bdcdf46aeb631ead21248b9affbc4 \
+    --hash=sha256:397081c1a0bfb5124355710fe79478cdbeb39626492b15d399526ae53422b906 \
+    --hash=sha256:3a57fdd7ce31c7ff06cdfbf31dafa96cc533c21e443d57f5b1ecc6cdc668ec7f \
+    --hash=sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4 \
+    --hash=sha256:3e53af139f8579a6d5f7b76549125f0d94d7e630761a2111bc431fd820e163b8 \
+    --hash=sha256:4096e9de5c6fdf43fb4f04c26fb114f61ef0bf2e5604b6ee3019d51b69e8c371 \
+    --hash=sha256:4275d846e41ecefa46e2015117a9f491e57a71ddd59bbead77e904dc02b1bed2 \
+    --hash=sha256:4c31f53cdae6ecfa91a77820e8b151dba54ab528ba65dfd235c80b086d68a465 \
+    --hash=sha256:4f11aa001c540f62c6166c7726f71f7573b52c68c31f014c25cc7901deea0b52 \
+    --hash=sha256:5049256f536511ee3f7e1b3f87d1d1209d327e818e6ae1365e8653d7e3abb6a6 \
+    --hash=sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169 \
+    --hash=sha256:598e3276b64aff0e7b3451b72e94fa3c238d452e7ddcd893c3ab324717456bad \
+    --hash=sha256:5b7b716f97b52c5a14bffdf688f971b2d5ef4029127f1ad7a513973cfd818df2 \
+    --hash=sha256:5dedb4db619ba5a2787a94d877bc8ffc0566f92a01c0ef214865e54ecc9ee5e0 \
+    --hash=sha256:619bc166c4f2de5caa5a633b8b7326fbe98e0ccbfacabd87268a2b15ff73a029 \
+    --hash=sha256:629ddd2ca402ae6dbedfceeba9c46d5f7b2a61d9749597d4307f943ef198fc1f \
+    --hash=sha256:656f7526c69fac7f600bd1f400991cc282b417d17539a1b228617081106feb4a \
+    --hash=sha256:6ec585f69cec0aa07d945b20805be741395e28ac1627333b1c5b0105962ffced \
+    --hash=sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5 \
+    --hash=sha256:7502934a33b54030eaf1194c21c692a534196063db72176b0c4028e140f8f32c \
+    --hash=sha256:7a68b554d356a91cce1236aa7682dc01df0edba8d043fd1ce607c49dd3c1edcf \
+    --hash=sha256:7b2e5a267c855eea6b4283940daa6e88a285f5f2a67f2220203786dfa59b37e9 \
+    --hash=sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb \
+    --hash=sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad \
+    --hash=sha256:8dd717634f5a044f860435c1d8c16a270ddf0ef8588d4887037c5028b859b0c3 \
+    --hash=sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1 \
+    --hash=sha256:97cafb1f3cbcd3fd2b6fbfb99ae11cdb14deea0736fc2b0952ee177f2b813a46 \
+    --hash=sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc \
+    --hash=sha256:a549b9c31bec33820e885335b451286e2969a2d9e24879f83fe904a5ce59d70a \
+    --hash=sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee \
+    --hash=sha256:ae2ad8ae6ebee9d2d94b17fb62763125f3f374c25618198f40cbb8b525411900 \
+    --hash=sha256:b91c037585eba9095565a3556f611e3cbfaa42ca1e865f7b8015fe5c7336d5a5 \
+    --hash=sha256:bc1667f8b83f48511b94671e0e441401371dfd0f0a795c7daa4a3cd1dde55bea \
+    --hash=sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f \
+    --hash=sha256:bf50cd79a75d181c9181df03572cdce0fbb75cc353bc350712073108cba98de5 \
+    --hash=sha256:bff1b4290a66b490a2f4719358c0cdcd9bafb6b8f061e45c7a2460866bf50c2e \
+    --hash=sha256:c061bb86a71b42465156a3ee7bd58c8c2ceacdbeb95d05a99893e08b8467359a \
+    --hash=sha256:c8b29db45f8fe46ad280a7294f5c3ec36dbac9491f2d1c17345be8e69cc5928f \
+    --hash=sha256:ce409136744f6521e39fd8e2a24c53fa18ad67aa5bc7c2cf83645cce5b5c4e50 \
+    --hash=sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a \
+    --hash=sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b \
+    --hash=sha256:d9fad5155d72433c921b782e58892377c44bd6252b5af2f67f16b194987338a4 \
+    --hash=sha256:daa4ee5a243f0f20d528d939d06670a298dd39b1ad5f8a72a4275124a7819eff \
+    --hash=sha256:db0b55e0f3cc0be60c1f19efdde9a637c32740486004f20d1cff53c3c0ece4d2 \
+    --hash=sha256:e61659ba32cf2cf1481e575d0462554625196a1f2fc06a1c777d3f48e8865d46 \
+    --hash=sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b \
+    --hash=sha256:ec6a563cff360b50eed26f13adc43e61bc0c04d94b8be985e6fb24b81f6dcfdf \
+    --hash=sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5 \
+    --hash=sha256:fa173ec60341d6bb97a89f5ea19c85c5643c1e7dedebc22f5181eb73573142c5 \
+    --hash=sha256:fa9db3f79de01457b03d4f01b34cf91bc0048eb2c3846ff26f66687c2f6d16ab \
+    --hash=sha256:fce659a462a1be54d2ffcacea5e3ba2d74daa74f30f5f143fe0c58636e355fdd \
+    --hash=sha256:ffee1f21e5ef0d712f9033568f8344d5da8cc2869dbd08d87c84656e6a2d2f68
+    # via jinja2
 mccabe==0.7.0 \
     --hash=sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325 \
     --hash=sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e