Bazel has grown significantly since its initial open-source release, and it is hard to keep up with every release. One useful feature that may have slipped past you is unused_inputs_list, a parameter of ctx.actions.run(...) that lets an action report which of its declared inputs it did not actually use.
After the action executes successfully, Bazel can prune those files from the action’s effective input set. On subsequent incremental builds, changing only one of the reported unused files will not cause the action to run again.
This is post-execution dependency pruning, not pre-execution input discovery. The action still starts with all of its declared inputs. It reports unused inputs only after it has run. If the action later needs to execute again, Bazel restores the original input set first, because a previously unused input may be needed during the new execution.
A natural use case would be a compiler-like rule that begins with a conservative list of possible headers or source files, but can determine which ones it actually read.
A simple example
To demonstrate the unused_inputs_list parameter on ctx.actions.run(...), I came up with a deliberately contrived example that:
- Receives
choice.txtthrough aselectorattribute. - Reads the contents of
choice.txt. - Selects one file from
srcs. - Copies the selected file to the output.
- Reports every unselected source file as unused.
It is an extremely unrealistic rule, but I believe it demonstrates the idea clearly.
The select_one rule
Given the following select_one rule:
def _select_one_impl(ctx):
output = ctx.actions.declare_file(ctx.label.name + ".out")
unused_inputs = ctx.actions.declare_file(
ctx.label.name + ".unused_inputs",
)
args = ctx.actions.args()
args.add(ctx.file.selector)
args.add(output)
args.add(unused_inputs)
args.add_all(ctx.files.srcs)
ctx.actions.run(
executable = ctx.executable._tool,
arguments = [args],
inputs = [ctx.file.selector] + ctx.files.srcs,
outputs = [
output,
unused_inputs,
],
unused_inputs_list = unused_inputs,
mnemonic = "SelectOne",
)
return [
DefaultInfo(files = depset([output])),
]
select_one = rule(
implementation = _select_one_impl,
attrs = {
"selector": attr.label(
mandatory = True,
allow_single_file = True,
),
"srcs": attr.label_list(
mandatory = True,
allow_files = True,
),
"_tool": attr.label(
default = Label("//:selector_tool"),
executable = True,
cfg = "exec",
),
},
)
The unused_inputs file is still an ordinary declared output: it must appear in the action’s outputs list, and the tool must create it.
Passing that same File through unused_inputs_list gives the output additional meaning. It tells Bazel to read the file after execution and interpret its contents as a list of inputs that can be pruned.
The tool itself
Here is the small Swift program that reads the selector, copies the selected file, and reports the remaining candidates as unused:
import Foundation
func run() throws {
let arguments = Array(CommandLine.arguments.dropFirst())
let selectorPath = arguments[0]
let outputPath = arguments[1]
let unusedInputsListPath = arguments[2]
let candidates = Array(arguments.dropFirst(3))
let selectedName = try String(contentsOfFile: selectorPath, encoding: .utf8)
.trimmingCharacters(in: .whitespacesAndNewlines)
let selectedPath = candidates.first(where: {
URL(fileURLWithPath: $0).lastPathComponent == selectedName
})!
let selectedContents = try Data(
contentsOf: URL(fileURLWithPath: selectedPath)
)
try selectedContents.write(
to: URL(fileURLWithPath: outputPath)
)
let unusedPaths = candidates.filter { $0 != selectedPath }
let unusedContents =
unusedPaths.isEmpty
? ""
: unusedPaths.joined(separator: "\n") + "\n"
try Data(unusedContents.utf8).write(
to: URL(fileURLWithPath: unusedInputsListPath)
)
}
try run()
The unused-input file contains one input path per line. These need to be the action’s execution paths, not merely arbitrary workspace-relative names. Bazel reads each line and matches it against the mapped execution paths of the action’s inputs.
The example avoids having to reconstruct those paths by writing the exact candidate strings that Bazel passed to the tool.
The selected file must not appear in the unused-input list because its contents were copied to the output. The selector must not appear there either because changing choice.txt may change which source file is selected.
Putting it all together
Here is the final BUILD.bazel file:
load("@rules_swift//swift:swift_binary.bzl", "swift_binary")
load(":select_one.bzl", "select_one")
swift_binary(
name = "selector_tool",
srcs = ["main.swift"],
)
select_one(
name = "demo",
selector = "choice.txt",
srcs = [
"a.txt",
"b.txt",
],
)
Suppose the files contain:
# choice.txt
a.txt
# a.txt
Contents of A
# b.txt
Contents of B
On the first build, the action receives all three declared inputs:
bazel build //:demo
The tool selects a.txt, writes its contents to demo.out, and writes the path of b.txt to demo.unused_inputs.
At that point, Bazel knows that b.txt did not contribute to this execution. Modifying only b.txt will therefore not cause the SelectOne action to execute again during a subsequent incremental build:
Changing a.txt, on the other hand, must rerun the action because a.txt produced the output.
Changing choice.txt must also rerun it. When that happens, Bazel restores the complete original input set before executing the action. The tool can then select b.txt, even though b.txt was reported as unused during the previous execution.
Conclusion
I have not seen many rulesets use unused_inputs_list directly, but it is worth knowing that it exists. It is most useful for actions that must declare a conservative set of possible inputs but can reliably determine which subset mattered after execution.
I enjoy exploring the more obscure corners of Bazel from time to time, since I tend to find features I did not know I needed.