Coverage for src/footman/tools.py: 95%

263 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-24 13:38 +0000

1"""Typed bridges to command-line tools, built on `footman.run`. 

2 

3Every call runs through the current task context, so it inherits capture, 

4replay-on-failure, dry-run, recording, and `--json` steps. 

5 

6footman deliberately does **not** transcribe each tool's flags into Python 

7parameters. Transcription drifts: the wrapper pins the flag-set its author 

8copied, the tool moves on, and one day `show_source=True` emits a flag the 

9installed binary rejects. Instead, keyword arguments translate 

10*mechanically* — the installed tool's own CLI stays the single source of 

11truth, at whatever version it is: 

12 

13- `fix=True` → `--fix` (`False`/`None` → omitted entirely) 

14- `strict=off` → `--no-strict` (disable a default-on flag; `off` is the 

15 `footman.tools.off` sentinel — `no_strict=True` is the same thing by name) 

16- `output_format="github"` → `--output-format github` 

17- `select=["E", "F"]` → `--select E --select F` (an empty list/tuple is 

18 omitted entirely, so a task param's default passes straight through) 

19- `x=1` (single letter) → `-x 1` 

20- a trailing underscore escapes Python keywords: `import_="x"` → `--import x` 

21 

22Attribute access chains subcommands (`tools.docker.compose.up(detach=True)` 

23→ `docker compose up --detach`), positional strings pass through verbatim, 

24and *any* executable works without being declared here: 

25`tools.terraform("plan")` just runs `terraform plan`. 

26 

27`tool.installed_version()` returns the installed binary's version as an int 

28tuple (cached per process, resolved outside the task context so dry-run and 

29recording can't lie to it) — for the rare case where a task must branch on 

30the tool's actual CLI generation. 

31""" 

32 

33from __future__ import annotations 

34 

35# Every module import is aliased private so `tools.<name>` never resolves to it: 

36# module attribute lookup beats module `__getattr__`, so a public `run`/`sys` 

37# would make `tools.run`/`tools.sys` the imported object instead of a Tool — 

38# typechecking as Tools (per the stub) but crashing at runtime (F50, F53). 

39import re as _re 

40import subprocess as _subprocess 

41import sys as _sys 

42import threading as _threading 

43from collections.abc import Iterator 

44from typing import Any, NamedTuple 

45 

46# `Result` is public, unlike the private aliases: every tool call returns one, and 

47# the generated stubs import it from here (`from footman.tools import Result`), so 

48# it must resolve to the class — a real module binding beats `__getattr__`. 

49from footman.context import Invocation as _Invocation 

50from footman.context import Result as Result 

51from footman.context import color_on as _color_on 

52from footman.context import run as _run 

53 

54_version_cache: dict[str, tuple[int, ...]] = {} 

55 

56 

57class _Off: 

58 """The value that disables a flag: `flag=off` → `--no-flag`. 

59 

60 `False`/`None` mean *omit* — so a task parameter's default flows through 

61 untouched — which leaves no way to spell a negation. Hence an explicit 

62 sentinel: `strict=off` turns a default-on flag off. Equivalent to naming 

63 the negation directly (`no_strict=True`), but reads as intent and lets a 

64 variable drive it (`strict=on_by_default and off`). 

65 """ 

66 

67 __slots__ = () 

68 

69 def __repr__(self) -> str: 

70 return "off" 

71 

72 

73off = _Off() 

74 

75 

76# How a tool spells "off" when it is *not* `--no-<name>`. Only the 

77# exceptions live here, extracted from the tools themselves (click states 

78# it as `secondary_opts`) rather than assumed: `mkdocs build --no-clean` 

79# is rejected outright — the flag is `--dirty`. Regenerate with 

80# `fm footman tools negations`. 

81_NEGATIONS: dict[str, dict[str, str]] = { 

82 "mkdocs": { 

83 "clean": "--dirty", 

84 "use_directory_urls": "--no-directory-urls", 

85 }, 

86 # git add --all turns off as --ignore-removal (aka --no-all). 

87 "git": { 

88 "all": "--ignore-removal", 

89 }, 

90} 

91 

92 

93def _negation(tool: str, key: str, *, single_dash: bool = False) -> str: 

94 """The flag that turns *key* off for *tool*. 

95 

96 A single-dash tool (Go's `flag` package: `-fix`, not `--fix`) negates with a 

97 single dash too (`-no-fix`); a tool that spells it otherwise lives in 

98 `_NEGATIONS`, which wins here regardless of dash style. 

99 """ 

100 known = _NEGATIONS.get(tool, {}) 

101 if key in known: 

102 return known[key] 

103 dash = "-no-" if single_dash else "--no-" 

104 return dash + key.rstrip("_").replace("_", "-") 

105 

106 

107# Verbs that run *another* command: a wrapper's flags belong before the 

108# child's argv, or they leak past the tool into the child — `uv run 

109# --frozen pytest`, not `uv run pytest --frozen` (which hands `--frozen` 

110# to pytest). Dotted for nesting; extracted from each verb's usage line 

111# and checked by `fm footman tools audit`. 

112_WRAPPERS: dict[str, frozenset[str]] = { 

113 "uv": frozenset({"run", "tool.run"}), 

114 "coverage": frozenset({"run"}), 

115 "docker": frozenset({"run", "exec", "compose.run", "compose.exec"}), 

116 # python's own root is a wrapper: `python -v script.py` puts the 

117 # interpreter's options before the script, whose own args follow it. 

118 "python": frozenset({""}), 

119} 

120 

121 

122def _is_wrapper(argv0: str, base: list[str]) -> bool: 

123 """Whether the verb reached by *base* forwards to a wrapped command.""" 

124 verbs = ".".join(token for token in base if not token.startswith("-")) 

125 return verbs in _WRAPPERS.get(argv0, frozenset()) 

126 

127 

128# --- colour: force a tool's own switch when the environment isn't enough ------ 

129# 

130# footman already pushes FORCE_COLOR / NO_COLOR into every child (see 

131# `context.color_env`), which covers the modern set. This table is only for the 

132# tools that ignore those and take a flag instead (git). It is *probed*, not 

133# hand-written: `fm footman tools color` runs each tool with colour forced on 

134# and off and records the verdict in `_colordata.py`, which is loaded below. 

135 

136 

137class _ColorFlag(NamedTuple): 

138 """How one tool forces colour on (or off) with its own switch. 

139 

140 `on`/`off` are the tokens to add for each direction — either may be empty 

141 when a tool needs telling only one way (git's `auto` default is already 

142 monochrome when piped, so it has no `off`). `pre_verb` places the tokens 

143 right after the executable and before the verb (git's `-c color.ui=…` is a 

144 global, not a `diff` option); otherwise they ride with the call's flags. 

145 """ 

146 

147 on: tuple[str, ...] 

148 off: tuple[str, ...] = () 

149 pre_verb: bool = False 

150 

151 

152def _load_color() -> dict[str, dict[str, _ColorFlag]]: 

153 """Build the forcing table from probed `_colordata.py`, keyed 

154 `{argv0: {verb: flag}}` (verb `""` = tool-wide). Only a tool that a 

155 direction reports `flag` for gets an entry; everyone else obeys the 

156 environment. A missing data file degrades to no flag-forcing (env only).""" 

157 try: 

158 from footman import _colordata 

159 except ImportError: # not yet generated — env forcing still works 

160 return {} 

161 table: dict[str, dict[str, _ColorFlag]] = {} 

162 for argv0, on, off, flag_on, flag_off, pre_verb in _colordata.COLOUR.values(): 

163 if on == "flag" or off == "flag": 

164 table.setdefault(argv0, {})[""] = _ColorFlag( 

165 on=flag_on if on == "flag" else (), 

166 off=flag_off if off == "flag" else (), 

167 pre_verb=pre_verb, 

168 ) 

169 return table 

170 

171 

172_COLOR: dict[str, dict[str, _ColorFlag]] = _load_color() 

173 

174 

175def _color_flag(argv0: str, base: list[str]) -> _ColorFlag | None: 

176 """The colour switch for the verb reached by *base*, or None. 

177 

178 An exact verb match wins; a `""` entry is the tool-wide fallback (git forces 

179 colour the same way for every subcommand).""" 

180 table = _COLOR.get(argv0) 

181 if not table: 

182 return None 

183 verb = ".".join(token for token in base if not token.startswith("-")) 

184 return table.get(verb) or table.get("") 

185 

186 

187def _color_tokens(argv0: str, base: list[str], kwargs: dict[str, Any]) -> _ColorFlag: 

188 """The colour tokens to inject for this call — `_ColorFlag((), ())` for none. 

189 

190 Injected into the *executed* argv only, never the shown/recorded command 

191 line: `.command` (what `recording()` asserts) stays the tool's own call, 

192 while `.raw` / `--verbose` show the literal `git -c color.ui=always …` that 

193 ran. Skipped when the caller spells colour themselves — `color=`/`colour=`/ 

194 `colors=`/`colours=` — so a deliberate choice always wins. 

195 """ 

196 if any(k.rstrip("_") in ("color", "colour", "colors", "colours") for k in kwargs): 

197 return _ColorFlag((), ()) 

198 flag = _color_flag(argv0, base) 

199 if flag is None: 

200 return _ColorFlag((), ()) 

201 tokens = flag.on if _color_on() else flag.off 

202 return _ColorFlag(tokens, (), flag.pre_verb) if tokens else _ColorFlag((), ()) 

203 

204 

205def _emit( 

206 kwargs: dict[str, Any], tool: str = "", *, single_dash: bool = False 

207) -> Iterator[tuple[str, str | None]]: 

208 """The one translation: keyword arguments → `(flag, value)` tokens. 

209 

210 `value` is None for a switch (`--fix`) or a negation (`--dirty`); a 

211 string for a valued option; and the pair repeats for each item of a 

212 list. Both the executed argv (`_flags`) and the shown command line 

213 (`_show_parts`) are built from this, so they can never disagree about 

214 what a call means — only about how to spell it. 

215 

216 *single_dash* spells every long flag with one dash (`-fix`, not `--fix`) for 

217 Go-style tools whose `flag` package rejects the double-dash form. 

218 """ 

219 for key, value in kwargs.items(): 

220 if value is None or value is False: 

221 continue 

222 name = key.rstrip("_").replace("_", "-") 

223 if value is off: 

224 yield _negation(tool, key, single_dash=single_dash), None 

225 continue 

226 flag = f"-{name}" if single_dash or len(name) == 1 else f"--{name}" 

227 if value is True: 

228 yield flag, None 

229 continue 

230 values = value if isinstance(value, (list, tuple)) else [value] 

231 for item in values: 

232 yield flag, str(item) 

233 

234 

235def _spell(flag: str, value: str | None, *, attach_long: bool) -> list[str]: 

236 """One option as argv tokens — the shared placement rule. 

237 

238 A long option and its value can be one token (`--select=E`) or two 

239 (`--select E`). Two reads better, but three cases force *one*: 

240 

241 * a value that starts with a dash would be read as the next option — 

242 `--format -%h` fails, `--format=-%h` works; 

243 * an optional-value option can't tell its value from the next 

244 positional across a space — `--abbrev 4` is ambiguous, `--abbrev=4` 

245 is not; 

246 * a short option's value, when it starts with a dash, must be 

247 concatenated (`-k-expr`), never `-k=expr`, which most tools reject. 

248 

249 Execution attaches every long option (`attach_long=True`) so the second 

250 case is covered for tools footman can't inspect; the shown line only 

251 attaches where a space would actually break it, staying readable. 

252 """ 

253 if value is None: 

254 return [flag] 

255 long = flag.startswith("--") 

256 dash = value.startswith("-") 

257 if long and (attach_long or dash): 

258 return [f"{flag}={value}"] 

259 if not long and dash: 

260 return [f"{flag}{value}"] 

261 return [flag, value] 

262 

263 

264def _flags( 

265 kwargs: dict[str, Any], tool: str = "", *, single_dash: bool = False 

266) -> list[str]: 

267 """Translate keyword arguments into CLI flags, for execution. 

268 

269 Long options attach their value (`--select=E`) so an optional-value or 

270 dash-leading value can never be misread; short options stay separated 

271 unless the value forces concatenation. The shown line (`_show_parts`) 

272 spells the same call more readably; only the tokens differ. 

273 """ 

274 argv: list[str] = [] 

275 for flag, value in _emit(kwargs, tool, single_dash=single_dash): 

276 argv += _spell(flag, value, attach_long=True) 

277 return argv 

278 

279 

280def _show_parts( 

281 argv0: str, 

282 base: list[str], 

283 args: tuple[Any, ...], 

284 kwargs: dict[str, Any], 

285 *, 

286 single_dash: bool = False, 

287) -> tuple[tuple[str, str], ...]: 

288 """The invocation as role-tagged tokens, for a readable, painted line. 

289 

290 The same call the runtime executes, spelled for a human: options in 

291 their separated form (`--select E`, not `--select=E`) where a space is 

292 safe, attached only where separating would break the paste; values 

293 shell-quoted; every token tagged with its role so `_describe.paint_cli` 

294 can colour it the way `--help` colours a usage line. 

295 """ 

296 parts: list[tuple[str, str]] = [("prog", argv0)] 

297 for token in base: 

298 # `base` holds the verb path, and — from `.opts()` — global flags 

299 # bound before a verb. A flag reads back in separated form so the 

300 # shown line stays readable (`--host tcp://x`, not `--host=tcp://x`). 

301 if token.startswith("--") and "=" in token: 

302 flag, _, value = token.partition("=") 

303 parts.append(("opt", flag)) 

304 parts.append(("value", _quote(value))) 

305 elif token.startswith("-"): 

306 parts.append(("opt", token)) 

307 else: 

308 parts.append(("group", token)) 

309 arg_parts = [("req", _quote(str(a))) for a in args] 

310 flag_parts: list[tuple[str, str]] = [] 

311 for flag, value in _emit(kwargs, argv0, single_dash=single_dash): 

312 if value is None: 

313 flag_parts.append(("opt", flag)) 

314 continue 

315 # Decide placement on the raw value (a dash leads), quote for the 

316 # shown text. Readable where a space is safe; attached only where 

317 # separating would produce a line that doesn't run. 

318 quoted = _quote(value) 

319 if value.startswith("-"): 

320 glue = "=" if flag.startswith("--") else "" 

321 flag_parts.append(("opt", f"{flag}{glue}{quoted}")) 

322 else: 

323 flag_parts.append(("opt", flag)) 

324 flag_parts.append(("value", quoted)) 

325 # A wrapper's flags come before the wrapped argv, mirroring execution. 

326 if _is_wrapper(argv0, base): 

327 parts += flag_parts + arg_parts 

328 else: 

329 parts += arg_parts + flag_parts 

330 return tuple(parts) 

331 

332 

333def _quote(text: str) -> str: 

334 """Quote a token so the shown line round-trips through a paste — POSIX via 

335 `shlex.quote`, Windows via stdlib `subprocess.list2cmdline` (which cmd and 

336 PowerShell can read, unlike shlex's POSIX single-quotes).""" 

337 if _sys.platform == "win32": 

338 return _subprocess.list2cmdline([text]) 

339 import shlex 

340 

341 return shlex.quote(text) 

342 

343 

344def _console_entrypoint(name: str) -> Any | None: 

345 """The `[console_scripts]` EntryPoint named *name*, UNLOADED, or None. 

346 

347 Returning the EntryPoint rather than its target keeps the tool's import 

348 deferred: the module is only imported when `.load()` is called, inside 

349 the callable footman runs. So a dry-run — or a branch you never take — 

350 imports nothing, while the existence check here (pure metadata, no tool 

351 code) is still cheap enough to decide subprocess-vs-in-process eagerly. 

352 """ 

353 from importlib.metadata import entry_points 

354 

355 for ep in entry_points(group="console_scripts", name=name): 

356 return ep 

357 return None 

358 

359 

360def _accepts_args(entry: Any) -> bool: 

361 """Can *entry* take the argument list directly (no sys.argv patching)? 

362 

363 Click commands (`cli(args)`) and argv-parameter mains 

364 (`main(argv=None)`) both can — their first parameter is positional. Only 

365 a true zero-arg `main()` needs `sys.argv` patched, which is process- 

366 global and therefore serialised. 

367 """ 

368 import inspect 

369 

370 try: 

371 sig = inspect.signature(entry) 

372 except (ValueError, TypeError): 

373 return False 

374 positional = ( 

375 inspect.Parameter.POSITIONAL_ONLY, 

376 inspect.Parameter.POSITIONAL_OR_KEYWORD, 

377 inspect.Parameter.VAR_POSITIONAL, 

378 ) 

379 return any(p.kind in positional for p in sig.parameters.values()) 

380 

381 

382# Only the sys.argv-patching fallback needs serialising; argument-accepting 

383# entries (the overwhelming majority) run fully in parallel. 

384_argv_lock = _threading.Lock() 

385 

386 

387# The run-control policy a tool's `.opts()` accepts — footman options that ride 

388# *beside* the call, never translated into tool flags. A closed vocabulary, like a 

389# task's `.opts()`, so `capture` here is unambiguously footman's (not a tool's own 

390# `--capture`, e.g. pytest's); a tool's own flags go in the call or `.flags()`. 

391_TOOL_OPTS = ("nofail", "in_process", "capture", "title") 

392 

393 

394def _opts_overrides(kwargs: dict[str, Any]) -> dict[str, Any]: 

395 """Validate `.opts()` policy kwargs against the closed tool-option vocabulary.""" 

396 unknown = sorted(set(kwargs) - set(_TOOL_OPTS)) 

397 if unknown: 

398 valid = ", ".join(_TOOL_OPTS) 

399 raise TypeError( 

400 f".opts() got unknown option(s) {unknown}; valid options are {valid}. A " 

401 f"tool's own flags go in the call — tools.ruff(fix=True) — or before a " 

402 f"verb via .flags()." 

403 ) 

404 return dict(kwargs) 

405 

406 

407class Tool: 

408 """One command-line tool; see the module docstring for the grammar. 

409 

410 `in_process` (footman run-control set via `.opts()`, like `nofail`) runs a 

411 Python tool inside footman's process instead of spawning: the tool's own 

412 `[console_scripts]` entry point is resolved and invoked — the same 

413 no-transcription contract, minus the interpreter spawn. Beyond speed 

414 this matters for correctness: on macOS, SIP strips `DYLD_*` from child 

415 processes, so a tool that needs Homebrew's native libraries (mkdocs 

416 with cairo, say) can only see them in-process, where an env var set 

417 before the import sticks. Tools constructed with `in_process=True` 

418 default to it and fall back to a subprocess when no entry point is 

419 installed; `.opts(in_process=True)` is a demand and errors if the entry 

420 can't be found. Parallelism survives: entries that accept an 

421 argument list (click commands, `main(argv=None)` — detected from the 

422 signature) are called directly and capture through the per-task stdout 

423 router; only a legacy zero-arg `main()` needs `sys.argv` patched, and 

424 only those serialise. 

425 """ 

426 

427 def __init__( 

428 self, 

429 name: str, 

430 *base: str, 

431 in_process: bool = False, 

432 path: str = "", 

433 entry: str = "", 

434 single_dash: bool = False, 

435 policy: dict[str, Any] | None = None, 

436 ) -> None: 

437 self._argv0 = name # the name shown, and the console script looked up 

438 self._base = list(base) 

439 self._prefer_in_process = in_process 

440 # Run-control policy set via `.opts()` (nofail/in_process/capture/title), 

441 # resolved at call time. Rides the chain, so `git.opts(nofail=True).push()` 

442 # works. Kept apart from the tool's flags: policy vs work. 

443 self._opts = dict(policy or {}) 

444 # The executable actually run, when it isn't the name: `tools.python` 

445 # runs `sys.executable`, not whatever `python` is on PATH. 

446 self._path = path or name 

447 # An in-process callable to prefer over the console script, spelled 

448 # `module:attr`. pytest's console entry is a zero-arg `_console_main` 

449 # (serialised), but `pytest.main` takes the argument list and stays 

450 # parallel — so it is recorded here. 

451 self._entry = entry 

452 # A Go-style tool whose `flag` package wants one dash on long flags 

453 # (`eclint -fix`, not `--fix`). Tool-wide: Go's flag package is uniform, 

454 # so this rides every flag the tool emits, chained subcommands included. 

455 self._single_dash = single_dash 

456 

457 def _sub(self, *tail: str) -> Tool: 

458 """A chained tool sharing this one's executable, entry, mode, and policy.""" 

459 return Tool( 

460 self._argv0, 

461 *self._base, 

462 *tail, 

463 in_process=self._prefer_in_process, 

464 path=self._path, 

465 entry=self._entry, 

466 single_dash=self._single_dash, 

467 policy=self._opts, 

468 ) 

469 

470 def __getattr__(self, verb: str) -> Tool: 

471 if verb.startswith("_"): 471 ↛ 472line 471 didn't jump to line 472 because the condition on line 471 was never true

472 raise AttributeError(verb) 

473 return self._sub(verb.replace("_", "-")) 

474 

475 def opts(self, **overrides: Any) -> Tool: 

476 """Set footman run-control policy for the call — the same `.opts()` a task 

477 has, mirroring its policy-vs-work split. A closed vocabulary 

478 (`nofail`, `in_process`, `capture`, `title`) that rides *beside* the call, 

479 never becoming a tool flag: 

480 

481 git.opts(nofail=True).push() # tolerate a non-zero exit 

482 pytest.opts(capture=False)("-s") # stream this run live 

483 

484 Because it is a fixed set, `capture` here is unambiguously footman's — a 

485 tool's own `--capture` (pytest's) still goes in the call. The overridden 

486 options ride the chain and win at call time. For a tool's *own* global 

487 options that must precede a verb, use `.flags()`. 

488 """ 

489 t = self._sub() 

490 t._opts = {**self._opts, **_opts_overrides(overrides)} 

491 return t 

492 

493 def flags(self, **kwargs: Any) -> Tool: 

494 """Bind a tool's own global options *before* the next subcommand. 

495 

496 Some flags belong to the tool, not the verb, and must precede it: 

497 `docker --host tcp://x ps` works, `docker ps --host tcp://x` does 

498 not. `flags` places them at the current point in the chain, so they 

499 land ahead of whatever verb follows and ahead of its arguments: 

500 

501 tools.docker.flags(host="tcp://x").ps(all=True) 

502 # -> docker --host=tcp://x ps --all 

503 

504 The flags are translated by the same rules as any call, and the 

505 returned tool keeps chaining, so `.flags(...)` composes mid-stream. 

506 (footman run-control — `nofail`, `capture`, … — goes in `.opts()`.) 

507 """ 

508 return self._sub(*_flags(kwargs, self._argv0, single_dash=self._single_dash)) 

509 

510 def __call__(self, *args: Any, **kwargs: Any) -> Result: 

511 # Run-control comes from `.opts()` (policy), never the call — so every 

512 # kwarg here is a tool flag, with no reserved name to collide with a real 

513 # option (pytest's own `--capture`, say). 

514 nofail = self._opts.get("nofail", False) 

515 capture = self._opts.get("capture", True) 

516 title = self._opts.get("title", None) 

517 in_process = self._opts.get("in_process", None) 

518 flags = _flags(kwargs, self._argv0, single_dash=self._single_dash) 

519 positionals = list(map(str, args)) 

520 wrapper = _is_wrapper(self._argv0, self._base) 

521 

522 def _tail(fl: list[str]) -> list[str]: 

523 # A wrapper verb (`uv run`, `docker exec`) forwards everything after 

524 # its own arguments to a child, so this call's flags must precede the 

525 # positionals — otherwise `--frozen` lands on `pytest`, not `uv`. 

526 if wrapper: 

527 return [*self._base, *fl, *positionals] 

528 return [*self._base, *positionals, *fl] 

529 

530 # `parts` is the shown/recorded command line and never carries a forced 

531 # colour switch, so `.command`/`recording()` stay the tool's own call. 

532 parts = _show_parts( 

533 self._argv0, self._base, args, kwargs, single_dash=self._single_dash 

534 ) 

535 # The in-process argv/tail are colour-free: an in-process tool reads the 

536 # run-wide colour from the environment (set once at the run boundary), so 

537 # only a *spawned* tool that ignores the environment (git) needs its own 

538 # switch. Execution runs the real executable (`python` → sys.executable). 

539 tail = _tail(flags) 

540 argv = [self._path, *tail] 

541 

542 def _spawn() -> Result: 

543 # The forced colour switch, subprocess-only, into the executed argv: 

544 # a pre-verb global (`git -c color.ui=always`) leads; a verb-scoped 

545 # flag rides with the others. `.raw`/`--verbose` show what ran. 

546 colour = _color_tokens(self._argv0, self._base, kwargs) 

547 if not colour.on: 

548 spawned = argv 

549 elif colour.pre_verb: 

550 spawned = [self._path, *colour.on, *tail] 

551 else: 

552 spawned = [self._path, *_tail([*flags, *colour.on])] 

553 return _run( 

554 spawned, 

555 nofail=nofail, 

556 capture=capture, 

557 title=title, 

558 _show=_Invocation(parts, tuple(spawned)), 

559 ) 

560 

561 wanted = self._prefer_in_process if in_process is None else in_process 

562 if wanted: 

563 loader = self._inprocess_loader() # metadata only — no import 

564 if loader is None: 

565 if in_process is True: # a demand can't be met — fail fast 

566 raise ValueError( 

567 f"{self._argv0}: in_process=True, but no importable " 

568 f"in-process entry ({self._entry or self._argv0!r})" 

569 ) 

570 return _spawn() # prefer → subproc 

571 

572 show = _Invocation(parts, tuple(argv)) 

573 

574 def _invoke() -> Any: 

575 entry = loader() # the tool's import — deferred to execution, 

576 # so a dry-run of this call imports nothing. 

577 if _accepts_args(entry): 

578 return entry(tail) # click / main(argv): lock-free, parallel 

579 with _argv_lock: # legacy zero-arg main(): patch argv, serialised 

580 saved = _sys.argv 

581 _sys.argv = argv 

582 try: 

583 return entry() 

584 finally: 

585 _sys.argv = saved 

586 

587 return _run( 

588 _invoke, nofail=nofail, capture=capture, title=title, _show=show 

589 ) 

590 return _spawn() 

591 

592 def _inprocess_loader(self) -> Any | None: 

593 """A callable that imports and returns the in-process target — or None 

594 when there is nothing to run in-process (so the call spawns instead). 

595 

596 A recorded `entry` override wins over the console script: pytest's 

597 console entry is the zero-arg `_console_main`, but `pytest.main` takes 

598 the argument list, so it is recorded as `pytest:main` and stays 

599 parallel. Availability is checked without importing (a dry-run of the 

600 call must import nothing); the import itself is deferred to the loader. 

601 """ 

602 if self._entry: 

603 import importlib.util 

604 

605 module = self._entry.partition(":")[0] 

606 try: 

607 if importlib.util.find_spec(module) is None: 607 ↛ 608line 607 didn't jump to line 608 because the condition on line 607 was never true

608 return None 

609 except (ImportError, ValueError): 

610 return None 

611 

612 def load() -> Any: 

613 import importlib 

614 

615 mod, _, attr = self._entry.partition(":") 

616 return getattr(importlib.import_module(mod), attr) 

617 

618 return load 

619 ep = _console_entrypoint(self._argv0) 

620 return ep.load if ep is not None else None 

621 

622 def installed_version(self) -> tuple[int, ...]: 

623 """The installed binary's version, as a comparable int tuple. 

624 

625 Runs `<tool> --version` directly (never through the task context, so 

626 dry-run/recording still see the truth) and caches per process. For a 

627 tool that spells it differently, fall back to calling it yourself. 

628 """ 

629 if self._argv0 not in _version_cache: 

630 # Decode as UTF-8 with replacement (F39): a tool that prints a 

631 # non-ASCII glyph in its --version must not crash the read on a 

632 # locale-encoded pipe (cp1252 on Windows). 

633 out = _subprocess.run( 

634 [self._argv0, "--version"], 

635 capture_output=True, 

636 text=True, 

637 encoding="utf-8", 

638 errors="replace", 

639 timeout=30, 

640 ) 

641 match = _re.search(r"(\d+(?:\.\d+)+)", out.stdout or out.stderr) 

642 if out.returncode != 0 or match is None: 642 ↛ 643line 642 didn't jump to line 643 because the condition on line 642 was never true

643 raise ValueError( 

644 f"could not read a version from `{self._argv0} --version`" 

645 ) 

646 _version_cache[self._argv0] = tuple( 

647 int(part) for part in match[1].split(".") 

648 ) 

649 return _version_cache[self._argv0] 

650 

651 

652# Curated instances — the ones with a non-obvious executable name live here; 

653# everything else works through the module fallback below. 

654ruff = Tool("ruff") 

655ruff_format = Tool("ruff", "format") 

656basedpyright = Tool("basedpyright") 

657uv = Tool("uv") 

658git = Tool("git") 

659docker = Tool("docker") 

660bun = Tool("bun") 

661mkdocs = Tool("mkdocs", in_process=True) # macOS: DYLD_* only survives in-process 

662zensical = Tool("zensical", in_process=True) 

663coverage = Tool("coverage", in_process=True) 

664cspell = Tool("cspell") 

665prek = Tool("prek") 

666markdownlint = Tool("markdownlint-cli2") 

667gh = Tool("gh") 

668eclint = Tool("eclint", single_dash=True) # Go flag package: `-fix`, not `--fix` 

669djlint = Tool("djlint") 

670mypy = Tool("mypy") 

671ty = Tool("ty") 

672twine = Tool("twine") 

673git_changelog = Tool("git-changelog") 

674git_cliff = Tool("git-cliff") 

675build = Tool("pyproject-build") # the `build` package's console script 

676cmake = Tool("cmake") 

677ninja = Tool("ninja") 

678 

679 

680# pytest runs in-process through the arg-accepting `pytest.main` (parallel), 

681# not its zero-arg `_console_main` console script. python always targets the 

682# running interpreter, whatever `python`/`python3` is (or isn't) on PATH; its 

683# stub is read from provisioned interpreters. There is no `sh`: a command as a 

684# single string is `run("…")` — footman splits and runs it (no shell). When you 

685# want a shell, `run(..., shell=True)` is the front door (it resolves the 

686# interpreter per policy); these tools are the low-level primitive it builds on. 

687pytest = Tool("pytest", in_process=True, entry="pytest:main") 

688python = Tool("python", path=_sys.executable) 

689 

690# The shells, invoked to run a command *string*: `tools.bash("echo $X | grep y")` 

691# runs `bash -c "…"`, so pipes, redirects, globbing and `$VAR` all work — the 

692# low-level "I want *this* shell" primitive (`run(..., shell="bash")` is the 

693# ergonomic front door). `-c` is the run-a-string flag for every one of them 

694# (pwsh takes it as an alias for -Command); Windows `cmd` uses `/c` and is 

695# Windows-only. footman autocompletes for all but cmd (cmd has no completion). 

696bash = Tool("bash", "-c") 

697zsh = Tool("zsh", "-c") 

698fish = Tool("fish", "-c") 

699pwsh = Tool("pwsh", "-c") 

700nu = Tool("nu", "-c") 

701cmd = Tool("cmd", "/c") 

702 

703 

704def __getattr__(name: str) -> Tool: 

705 # Any executable is a tool: `tools.terraform("plan")` needs no declaration. 

706 if name.startswith("_"): 

707 raise AttributeError(name) 

708 return Tool(name.replace("_", "-"))