API reference¶
The whole public surface. Anything underscore-prefixed is implementation — it moves without notice.
The handles¶
toolroom.Tool
¶
One command-line tool; see the module docstring for the grammar.
in_process (footman run-control set via .opts(), like nofail) runs a
Python tool inside footman's process instead of spawning: the tool's own
[console_scripts] entry point is resolved and invoked — the same
no-transcription contract, minus the interpreter spawn. Beyond speed
this matters for correctness: on macOS, SIP strips DYLD_* from child
processes, so a tool that needs Homebrew's native libraries (mkdocs
with cairo, say) can only see them in-process, where an env var set
before the import sticks. Tools constructed with in_process=True
default to it and fall back to a subprocess when no entry point is
installed; .opts(in_process=True) is a demand and errors if the entry
can't be found. Parallelism survives: entries that accept an
argument list (click commands, main(argv=None) — detected from the
signature) are called directly and capture through the per-task stdout
router; even a legacy zero-arg main() parallelises — the argv router
serves each call its own view of sys.argv.
The in-process lane is a host feature — those routers are footman's.
Standalone, a tool constructed with in_process=True simply spawns
(same command, same semantics), and .opts(in_process=True) — a
demand — is a taught refusal.
argv
property
¶
Build this call's command line instead of running it.
Insert .argv right before the parentheses — the call is otherwise
spelled exactly as it would be to run it, same verb, same flags,
same completion — and it hands back an Argv of raw tokens rather
than executing:
mkdocs.gh_deploy.argv(force=True)
# -> Argv(['mkdocs', 'gh-deploy', '--force'])
The tokens serialise at the point they cross into a shell —
cmd.posix() / cmd.windows() — or splat on as tokens (*cmd):
inner = docker.compose.up.argv(detach=True)
ssh("app@host", inner.posix())
Like .opts(), it may sit anywhere earlier in the chain
(docker.argv.compose.up(…) builds too); before the parentheses is
where it reads best and how the docs spell it. The line it builds is
colour-free: the forced-colour switch a few tools need is injected
when spawning, so what runs may carry one more flag than this
returns.
at
¶
Rebind this handle to an executable — the identity channel,
beside .opts() (policy) and .flags() (the tool's own argv).
Everything else rides along: verbs, bound flags, policy — including
a pending input= payload, whose cell is shared, not copied. What
changes is what runs: tools.python.at(venv_python) is that
venv's interpreter carrying python's whole typed surface, and the
shown command line keeps the tool's own name.
The in-process lane runs this interpreter, which is exactly what
an .at() handle says not to do — so the handle always spawns, and
an explicit in_process=True on one is a taught refusal.
flags
¶
Bind a tool's own global options before the next subcommand.
Some flags belong to the tool, not the verb, and must precede it:
docker --host tcp://x ps works, docker ps --host tcp://x does
not. flags places them at the current point in the chain, so they
land ahead of whatever verb follows and ahead of its arguments:
tools.docker.flags(host="tcp://x").ps(all=True)
# -> docker --host=tcp://x ps --all
The flags are translated by the same rules as any call, and the
returned tool keeps chaining, so .flags(...) composes mid-stream.
(footman run-control — nofail, capture, … — goes in .opts().)
installed_version
¶
The version of the binary this tool runs, as a comparable int tuple.
Asks the executable itself — <tool> --version, or whatever spelling
the tool was declared with (cmd /c ver) — and reads the answer with
the same parser the stub extractor uses, so the two can never disagree
about a version string's grammar. Runs outside the task context, so
--dry-run and recording() can't lie to it, and caches per process.
It answers "what will this task actually invoke", which is not the
version a stub header records. A stub says what extraction read, on
whatever machine synced it, from whatever binary it resolved — the
two differ whenever PATH and the extractor disagree (a Homebrew keg
against /usr/bin/git, say). This one is the end-user question; the
stub's is maintainer bookkeeping.
opts
¶
Set footman run-control policy for the call — the same .opts() a task
has, mirroring its policy-vs-work split. A closed vocabulary
(nofail, in_process, capture, title, step) that rides beside
the call, never becoming a tool flag:
git.opts(nofail=True).push() # tolerate a non-zero exit
pytest.opts(capture=False)("-s") # stream this run live
git.opts(recorded=False).rev_parse("HEAD") # a value read, not an event
recorded=False is the one that changes what the run sees rather than
how the tool is invoked: the call runs in the task's directory and
environment as always, but reports nothing — no receipt, no row in
--json, no recording() entry — and hands back its Result. Reach
for it when the call is how the task knows something rather than
something the task did.
Because it is a fixed set, capture here is unambiguously footman's — a
tool's own --capture (pytest's) still goes in the call. The overridden
options ride the chain and win at call time. For a tool's own global
options that must precede a verb, use .flags().
color="always"|"never" decides this call's colour outright, and
"auto" (the default) takes the ambient answer — the run's inside a
footman run, the user's environment outside one. An explicit choice
beats NO_COLOR, which is what makes a colour assertion hold on CI
that exports it:
git.opts(color="always").diff() # colour, whatever the ambient
The decision travels on the call rather than through the environment
because os.environ is one per process and calls are not — two
threads wanting different answers can both have one this way. It
drives whichever half of the forcing each tool needs: the variables
for those that read them, their own switch for those that don't. For
a tool's own colour flag, spell it in the call
(ruff.check(color="always")) and toolroom keeps out of the way.
env= is the child's environment exactly as run(env=…) means it —
what you pass is what the child gets — and like the rest of the set it
rides the chain and replays. input= feeds the child's standard input,
and unlike the rest it is consumed: stdin is consumable, so the
payload is delivered exactly once however the handle is chained or
shared, and a second call is a taught refusal rather than a
silently-unfed child hanging on a stdin that never comes. Re-opt with
a fresh payload per call:
uv.pip.install.opts(input=requirement)("-r", "-")
The vocabulary¶
toolroom.Argv
¶
A command line built but not run — toolroom.git.push.argv(force=True).
An ordinary list[str] everywhere in Python: it indexes, slices,
iterates, compares and prints exactly like the list it is, and any
runner spawns it as-is. Always the raw tokens — the one shape of a
command with no shell in it. Passing the tokens on is plain Python:
a wrapper takes them splatted (uv.run("--", *cmd)), and stdlib
helpers take the list directly (shlex.join(cmd)).
The moment the line is about to cross into a shell, name that shell:
.posix()— one string,shlex-quoted forsh/bash/zsh..windows()— one string, quoted the wayCreateProcessparses.
Naming it is the caller's job because toolroom cannot know it: the same handle can target different hosts across calls, the OS does not determine the shell, and the payload may reach no shell at all. The quoting is the destination's, never this machine's.
footman keeps a twin of this class for its receipts
(Result.to_argv()); both are ordinary list[str], so they compare
equal token-for-token and either flows anywhere the other does.
toolroom.Result
¶
Bases: int
The outcome of one standalone tool call — and the value it returns.
A Result is the exit code: it subclasses int, so
code = git.push(), if git.push(), and == 0 all read naturally.
It also carries the captured output, split by stream, and the tokens
that ran. Under a footman host the bridge returns footman's own
Result instead — a superset of this surface (receipts, --json,
replay-on-failure); the two are twins by design, never the same
class, and code written against this surface runs under both.
command
property
¶
command: str
The command that ran, as one readable line.
A token that redacts itself — footman's Secret, duck-typed on
reveal so nothing here imports it — reads back as ***: this
line exists to be shown, and a secret leaves through .to_argv()
or the caller's own unwrap, never through display.
toolroom.ToolError
¶
Bases: RuntimeError
A standalone tool call exited non-zero (and nofail was not set).
Carries the Result so the handler keeps the streams and the
command. Under a footman host failures take footman's lane instead
(the task fails, output replays); this error exists only where there
is no host to do that.
Flag control¶
Versions¶
toolroom.read_version
¶
The version string inside a tool's --version output, or "".
The string is preserved exactly, build tail and all (0.6.0-wk.5), for
anything that records which build was read. Use version_tuple to
compare.
toolroom.version_tuple
¶
A version string reduced to comparable integers — the CLI generation.
Only the leading numeric run counts, and the first component carrying a
build tag ends the read: 0.6.0-wk.5 and 1.13.0.git.kitware.jobserver-1
both compare as their base, (0, 6, 0) and (1, 13, 0).
That is the honest answer to the question this is asked — is the CLI new
enough — because a build tail says nothing about which flags exist.
Scraping its digits would answer it backwards: 0.6.0-wk.5 is a fork
build of 0.6.0, which every version grammar sorts at or before it,
while (0, 6, 0, 5) sorts after.
So two builds of one base compare equal, and every caller has to say
what it does with that rather than read equality as an answer. Ordering a
release chain breaks the tie on publication date, which separates a wk
series correctly without this having to know what wk means. The
snapshot guard cannot — an incoming reading is dated today whatever it
holds — so it declines to move rather than guess.
An unreadable version yields (), which compares lower than everything —
"can't tell" must never read as "newer".
Testing¶
How these fit together is Testing's story; this is the reference.
toolroom.testing.answers
¶
answers(
table: Mapping[Any, Answer] | None = None,
*,
hosted: bool = False,
) -> Iterator[list[Call]]
Answer every bridge call in the block from table, spawning nothing.
Yields the live list of Call records — assert on it after (or
during) the block. With hosted=True the block simulates the
hosted lane: _host.hosted answers True (so the bridge takes its
hosted paths — an in-process tool's callable is recorded, never
invoked), successes come back as footman's Result, and failures
raise footman's RunFailed — what code written for a footman task
returns and catches. The default speaks toolroom's own vocabulary:
Result back, ToolError raised.
The per-process installed_version cache is cleared for the block
and restored after it, so a canned version can neither be pre-empted
by a real read earlier in the process nor leak into later tests.
Not thread-safe: the seam is swapped module-wide for the duration.
A canned prefix that matched nothing by a clean block exit warns
(UnservedAnswers): an answer canned but never served is almost
always a mis-keyed prefix, and without the warning the test passes
vacuously. A block that exits on an exception is not warned — it
already failed loudly.
toolroom.testing.Call
¶
One intercepted call: what would have run, and what was handed.
argv is the name-led spelling — raw tokens, assert-friendly.
raw is what would actually have spawned: resolved interpreter
path, forced colour switch and all (the same pairing footman's
steps keep as .command/.raw). command is the shown line,
values quoted and secrets redacted.
args and kwargs are the call as the caller wrote it,
pre-render: positionals verbatim (a Path stays a Path), and the
call keywords with False values included — the bridge omits a
False flag from the argv, the record must not, because "never
handed" and "handed, as False" are different facts and tests assert
on both. The rule of thumb: assert on kwargs for what the code
decided to pass, on argv for what would have run. .flags()
keywords are part of the handle, rendered into the verb path before
any call exists, so they show only in argv/raw; run policy
stays in opts.
env is the environment the call handed the seam (None
inherits); opts carries the rest of the run policy (nofail,
capture, input, cwd, timeout, …) exactly as it crossed.
probe marks a version read — a probe has no call of its own, so
its args/kwargs are () and {} by construction.
toolroom.testing.UnservedAnswers
¶
Bases: Warning
A canned answer matched no call by the end of an answers() block.
An unmatched call is often incidental — the recorder default
exists for exactly that — but an unmatched table entry is a test
bug in the making: an answer was canned to steer the code under
test and never served, usually because the key was mis-spelt,
path-led where matching is name-led, or split into the wrong
tokens, and the test passes vacuously as if the entry did not
exist. A warning rather than an error so a deliberately shared
table (one fixture serving several tests, each exercising a subset)
can filter it; escalate it to a failure with -W error or a
filterwarnings mark where the table is per-test. A block that
exits on an exception is not warned — it already failed loudly.