In the world of Bazel, we prefer to be explicit as much as possible and register actions ahead of time. In fact, this is not just a preference but a strongly enforced rule that enables all the good stuff Bazel gives us.
However, there is a world where a bit of dynamism is useful—or, in some cases, unavoidable.
Today I am exploring the little-known map_directory API that was introduced in Bazel 9.
Registering actions dynamically
Say we write a rule that generates a couple of files and then want to register a separate Bazel action for each of those files.
The Bazel API did not facilitate this use case before version 9. Sure, we could have resorted to all sorts of tricks and hacks, but there was no nice native way to register actions based on the contents of a generated directory.
This is where map_directory comes in. It allows part of action registration to be deferred until execution time, when the contents of an input directory are known.
map_directory in action
The example below demonstrates generating a couple of files and then copying them with separate actions:
def _copy_files(template_ctx, *, input_directories, output_directories, tools, **_kwargs):
for src in input_directories["src"].children:
out = template_ctx.declare_file(src.tree_relative_path, directory = output_directories["out"])
# Here we register one action per file; Bazel executes the command later.
# Each action depends only on its input file and the copy tool.
template_ctx.run(
executable = tools["copy"],
inputs = [src],
outputs = [out],
arguments = [src.path, out.path],
)
def _demo_impl(ctx):
src = ctx.actions.declare_directory(ctx.label.name + "_input")
out = ctx.actions.declare_directory(ctx.label.name + "_output")
ctx.actions.run_shell(
outputs = [src],
arguments = [src.path],
command = """
mkdir -p "$1/nested"
echo hello > "$1/a.txt"
echo world > "$1/nested/b.txt"
""",
)
copy = ctx.actions.declare_file(ctx.label.name + "_copy.sh")
ctx.actions.write(copy, '#!/bin/sh\nexec /bin/cp "$@"\n', is_executable = True)
ctx.actions.map_directory(
input_directories = {"src": src},
output_directories = {"out": out},
tools = {"copy": copy},
mnemonic = "CopyFile",
implementation = _copy_files,
)
return [DefaultInfo(files = depset([out]))]
map_directory_demo = rule(implementation = _demo_impl)
Creating a target from this rule and building it produces an unremarkable result, but it demonstrates what is now possible with this API at our disposal.
The important part is that _copy_files does not run during the regular analysis phase. It runs later, once Bazel knows the contents of the src tree artifact. At that point, it can inspect its children and register an individual copy action for each file.
Conclusion
While still greatly limited, map_directory allows us to introduce some controlled dynamism into our Bazel builds and makes certain things possible that previously required considerably more creativity.
I am pretty sure there are far more interesting examples of this feature being used, so feel free to look around on Github.
Finally, as always, I suggest consulting the official docs, at least as a reference, as well as the GitHub discussion around dynamic dependencies.