Skip to content

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

argv: Tool[Argv]

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

at(path: str | Path) -> Self

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

flags(**kwargs: Any) -> Self

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

installed_version() -> tuple[int, ...]

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

opts(**overrides: Any) -> Self

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

Bases: list[str]

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 for sh/bash/zsh.
  • .windows() — one string, quoted the way CreateProcess parses.

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.

posix

posix() -> str

This command as one string, quoted for a POSIX shell.

windows

windows() -> str

This command as one string, quoted the way CreateProcess parses.

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.

code property

code: int

The exit code as a plain int — the value this Result is.

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.

ok property

ok: bool

True when the tool exited zero.

stderr property

stderr: str

The captured standard error (empty when capture=False).

stdout property

stdout: str

The captured standard output (empty when capture=False).

to_argv

to_argv() -> Argv

The executed command as an Argv — raw tokens, re-quotable for whichever shell a further hop will parse them with.

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

toolroom.off module-attribute

off: _Off = _Off()

Versions

toolroom.read_version

read_version(text: str) -> str

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

version_tuple(version: str) -> tuple[int, ...]

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.