Coverage for src/footman/tasks/tools.py: 87%
256 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-24 13:38 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-24 13:38 +0000
1"""Keep the `tools.*` stubs honest — `fm footman tools …`.
3The bridge never goes stale, because it transcribes nothing. Its *stub*
4can: a stub is a description of a tool at a version, and tools move. These
5tasks close that gap by regenerating the description from the installed
6tools and by failing a check when the two disagree.
8 fm footman tools list what footman curates, and what's installed
9 fm footman tools spec ruff what one tool says about itself, right now
10 fm footman tools sync rewrite the stubs from the installed tools
11 fm footman tools audit fail if a stub and its tool disagree
12 fm footman tools color how footman forces colour, per tool
14`audit` is the one worth running anywhere: it answers "does the version I
15have still match what my editor is telling me?" without changing a file.
16Tools that aren't installed are skipped and named — a check that quietly
17covered three of thirteen would be worse than no check.
18"""
20from __future__ import annotations
22import re as _re
23import sys
24from pathlib import Path
25from typing import Annotated
27from footman import _drivers, _stubgen, _toolspec
28from footman._describe import bold, cyan, wants_color
29from footman.params import doc
30from footman.registry import Group
32tasks = Group("tools", help="Keep the tools.* stubs honest")
34_STUBS = Path(__file__).resolve().parent.parent / "_stubs"
37def _stub_path(key: str) -> Path:
38 return _STUBS / f"{key}.pyi"
41def _platform() -> str:
42 return {"darwin": "macOS", "win32": "Windows"}.get(sys.platform, "Linux")
45def _generate(driver: _drivers.Driver) -> str:
46 """The stub text for one installed tool, formatted the way ruff would."""
47 spec = _drivers.extract(driver)
48 return _formatted(_render(driver, spec))
51def _render(driver: _drivers.Driver, spec: _toolspec.ToolSpec) -> str:
52 return _stubgen.render(
53 spec,
54 platform=_platform(),
55 class_name=_class_name(driver.key),
56 in_process=_mode(driver, spec),
57 )
60def _mode(driver: _drivers.Driver, spec: _toolspec.ToolSpec) -> str:
61 """How this tool runs: in footman's process by default, or on request.
63 A Python tool publishes a `[console_scripts]` entry point, which is
64 what `Tool.__call__` resolves — so the capability is detected, not
65 listed. Whether footman *prefers* it is the driver's business.
66 """
67 if driver.in_process:
68 return "default"
69 return "available" if spec.in_process else "no"
72def _class_name(key: str) -> str:
73 return "".join(part.title() for part in key.split("_"))
76def _formatted(text: str) -> str:
77 """Run the generated text through the formatter that guards the repo.
79 Generated code lands in `src/`, where `ruff format --check` runs on
80 every commit — so it has to be formatted the same way by construction,
81 not by a follow-up nobody remembers.
82 """
83 import subprocess
85 try:
86 done = subprocess.run(
87 ["ruff", "format", "--stdin-filename", "stub.pyi", "-"],
88 input=text,
89 capture_output=True,
90 text=True,
91 timeout=60,
92 )
93 except (OSError, subprocess.SubprocessError):
94 return text
95 return done.stdout or text
98@tasks.task(name="list")
99def list_(
100 missing: Annotated[bool, doc("only the tools this machine lacks")] = False,
101):
102 """The curated tools: version, in-process capability, stub state."""
103 on = wants_color(sys.stdout)
104 rows: list[tuple[str, str, str, str]] = []
105 for driver in _drivers.DRIVERS:
106 here = _drivers.installed(driver)
107 if missing and here:
108 continue
109 version = _drivers.version(driver.name) if here else ""
110 capable = _drivers.in_process_capable(driver.name) if here else False
111 mode = "in-process" if driver.in_process else ("capable" if capable else "—")
112 stub = "yes" if _stub_path(driver.key).exists() else "no"
113 rows.append((driver.key, version or "not installed", mode, stub))
114 width = max((len(r[0]) for r in rows), default=4)
115 print(bold(f"{'tool'.ljust(width)} version in-process stub", on))
116 for key, version, mode, stub in rows:
117 print(f"{key.ljust(width)} {version:<12} {mode:<11} {stub}")
120@tasks.task
121def spec(
122 name: Annotated[str, doc("a curated tool: ruff, uv, mkdocs, …")],
123 verb: Annotated[str, doc("one verb, dotted for nesting (compose.up)")] = "",
124):
125 """Print what a tool says about itself, as footman reads it."""
126 driver = _drivers.find(name)
127 if driver is None:
128 raise SystemExit(f"no driver for {name!r}; try `fm footman tools list`")
129 if not _drivers.installed(driver): 129 ↛ 130line 129 didn't jump to line 130 because the condition on line 129 was never true
130 raise SystemExit(f"{driver.name} is not installed")
131 on = wants_color(sys.stdout)
132 extracted = _drivers.extract(driver)
133 print(bold(f"{extracted.name} {extracted.version}", on), extracted.help)
134 for one in extracted.verbs:
135 if verb and one.name != verb:
136 continue
137 label = one.name or "(the tool itself)"
138 print(cyan(f"\n {label}", on), f"— {len(one.options)} options")
139 for option in one.options:
140 negation = f" off → {option.negation}" if option.negation else ""
141 print(f" {option.name:<28} {option.type_name:<10}{negation}")
144@tasks.task
145def sync(
146 only: Annotated[str, doc("regenerate just this tool")] = "",
147):
148 """Rewrite the stubs from the tools installed on this machine.
150 A tool that isn't installed keeps the stub that is checked in — there
151 is nothing to read it from, and a stub that exists beats one that was
152 deleted because a laptop happened to be missing a binary.
153 """
154 _STUBS.mkdir(exist_ok=True)
155 wrote, skipped = [], []
156 for driver in _drivers.DRIVERS:
157 if only and driver.key != only:
158 continue
159 if driver.source == "manual": 159 ↛ 160line 159 didn't jump to line 160 because the condition on line 159 was never true
160 continue # hand-written stub — never extracted or overwritten
161 if not _drivers.installed(driver): 161 ↛ 162line 161 didn't jump to line 162 because the condition on line 161 was never true
162 skipped.append(driver.key)
163 continue
164 text = _generate(driver)
165 path = _stub_path(driver.key)
166 if not path.exists() or path.read_text(encoding="utf-8") != text: 166 ↛ 156line 166 didn't jump to line 156 because the condition on line 166 was always true
167 path.write_text(text, encoding="utf-8")
168 wrote.append(driver.key)
169 print(f"wrote {len(wrote)} stub(s): {', '.join(wrote) or 'none changed'}")
170 if skipped: 170 ↛ 171line 170 didn't jump to line 171 because the condition on line 170 was never true
171 print(f"skipped (not installed): {', '.join(skipped)}")
174@tasks.task
175def audit(
176 only: Annotated[str, doc("check just this tool")] = "",
177 fix: Annotated[bool, doc("write the differences instead of reporting")] = False,
178):
179 """Fail when a checked-in stub and its installed tool disagree.
181 Drift here is not a broken build — every stubbed verb ends in
182 `**flags: Any`, so the bridge still runs whatever you pass. It is a
183 stale *hint*, and this is how it gets noticed.
184 """
185 from footman import tools as _bridge
187 stale, skipped, wrong, checked = [], [], [], 0
188 for driver in _drivers.DRIVERS:
189 if only and driver.key != only:
190 continue
191 if driver.source == "manual": 191 ↛ 192line 191 didn't jump to line 192 because the condition on line 191 was never true
192 continue # hand-written stub — nothing to compare against
193 if not _drivers.installed(driver): 193 ↛ 194line 193 didn't jump to line 194 because the condition on line 193 was never true
194 skipped.append(driver.key)
195 continue
196 path = _stub_path(driver.key)
197 spec = _drivers.extract(driver)
198 fresh = _formatted(_render(driver, spec))
199 checked += 1
200 if not path.exists() or path.read_text(encoding="utf-8") != fresh:
201 stale.append(driver.key)
202 if fix:
203 path.write_text(fresh, encoding="utf-8")
204 # Two extracted facts the *runtime* reads: the negation table `off`
205 # consults, and the wrapper set that decides flag ordering. Both
206 # must match the installed tool, or a task emits the wrong command.
207 if driver.base: 207 ↛ 208line 207 didn't jump to line 208 because the condition on line 207 was never true
208 continue
209 found = spec.negations()
210 if found != _bridge._NEGATIONS.get(driver.name, {}):
211 wrong.append(f"_NEGATIONS[{driver.name!r}] should be {found}")
212 wraps = spec.wrappers()
213 if wraps != _bridge._WRAPPERS.get(driver.name, frozenset()):
214 wrong.append(f"_WRAPPERS[{driver.name!r}] should be {set(wraps)}")
215 if skipped: 215 ↛ 216line 215 didn't jump to line 216 because the condition on line 215 was never true
216 print(f"skipped (not installed): {', '.join(skipped)}")
217 if wrong:
218 raise SystemExit(
219 "tools.py runtime tables disagree with the installed tool(s):\n "
220 + "\n ".join(wrong)
221 )
222 if not stale:
223 print(f"{checked} stub(s) match their installed tool")
224 return
225 if fix:
226 print(f"updated {len(stale)} stub(s): {', '.join(stale)}")
227 return
228 raise SystemExit(
229 f"{len(stale)} stub(s) differ from the installed tool: "
230 f"{', '.join(stale)}\nrun `fm footman tools sync` to update"
231 )
234@tasks.task
235def color(
236 only: Annotated[str, doc("probe just this tool")] = "",
237 write: Annotated[bool, doc("regenerate src/footman/_colordata.py")] = True,
238 prefix: Annotated[str, doc("probe binaries from this prefix's bin/")] = "",
239):
240 """Probe how footman forces colour for each installed tool, and regenerate
241 the colour data.
243 footman spawns over pipes (no PTY), so it forces colour into the tools it
244 spawns — by the environment (`FORCE_COLOR`/`NO_COLOR`) for the modern set, by
245 the tool's own switch for the few that ignore it. Which is which is *probed*,
246 not assumed: each tool is run with colour forced on and off, and the bytes
247 read, so a direction is recorded `env`, `flag` (like git's
248 `-c color.ui=always`), `none`, or `unprobed` (no trigger figured out).
250 Writes `src/footman/_colordata.py`, which `tools.py` reads for its forcing
251 table and the docs read for the support table. Point `--prefix` at a
252 `fm footman tools provision` directory to probe the complete, latest set
253 rather than whatever happens to be on PATH.
254 """
255 import os
257 saved_path = os.environ.get("PATH", "")
258 if prefix: 258 ↛ 259line 258 didn't jump to line 259 because the condition on line 258 was never true
259 bindir = Path(prefix).expanduser().resolve() / "bin"
260 os.environ["PATH"] = f"{bindir}{os.pathsep}{saved_path}"
261 try:
262 _color_probe_and_write(only, write, wants_color(sys.stdout))
263 finally:
264 os.environ["PATH"] = saved_path
267def _color_probe_and_write(only: str, write: bool, on: bool) -> None:
268 from footman import _colorprobe
270 installed: list[tuple[str, str, str, _toolspec.ToolSpec]] = []
271 for driver in _drivers.DRIVERS:
272 if only and driver.key != only:
273 continue
274 if driver.source == "manual" or not _drivers.installed(driver): 274 ↛ 275line 274 didn't jump to line 275 because the condition on line 274 was never true
275 continue
276 binary = _drivers._resolve(driver.name)
277 if binary is None: 277 ↛ 278line 277 didn't jump to line 278 because the condition on line 277 was never true
278 continue
279 # Only a triggered, non-curated tool needs its stub read for a `--color`
280 # candidate; a curated tool (git) and an untriggered one (→ `unprobed`)
281 # skip the sometimes-slow extraction.
282 needs_spec = (
283 driver.key in _colorprobe.TRIGGERS
284 and driver.key not in _colorprobe._CURATED
285 )
286 spec: _toolspec.ToolSpec = (
287 _drivers.extract(driver)
288 if needs_spec
289 else _toolspec.ToolSpec(name=driver.name)
290 )
291 installed.append((driver.key, driver.name, binary, spec))
293 results = _colorprobe.probe_all(installed)
294 width = max((len(k) for k in results), default=4)
295 print(bold(f"{'tool'.ljust(width)} {'on':<8} {'off':<8} switch", on))
296 for key in sorted(results):
297 _argv0, verdict = results[key]
298 switch = " ".join(verdict.flag.on) if verdict.flag else ""
299 print(f"{key.ljust(width)} {verdict.on:<8} {verdict.off:<8} {switch}")
301 if write and not only: 301 ↛ 302line 301 didn't jump to line 302 because the condition on line 301 was never true
302 data = Path(__file__).resolve().parent.parent / "_colordata.py"
303 data.write_text(_formatted(_colorprobe.render(results)), encoding="utf-8")
304 docs = Path(__file__).resolve().parents[3] / "docs" / "color-support.md"
305 docs.write_text(_color_docs_table(results), encoding="utf-8")
306 print(f"\nwrote {data.name} + {docs.name} ({len(results)} tools)")
309# How each probed verdict reads in the docs support table.
310_ON_WORD = {"env": "environment", "none": "— *(no colour over a pipe)*", "n/a": ""}
311_OFF_WORD = {"env": "environment", "none": "**can't silence**", "n/a": "—"}
314def _color_docs_table(results: dict) -> str:
315 """A Markdown support table from the probe results — generated into the docs,
316 never hand-maintained. `on`/`off` columns read the verdict for each tool;
317 the forced switch is shown where a direction needs one."""
318 lines = [
319 "<!-- Generated by `fm footman tools color` — do not edit by hand. -->",
320 "",
321 "| Tool | Colour on | Colour off |",
322 "| ---- | --------- | ---------- |",
323 ]
324 for key in sorted(results):
325 _argv0, v = results[key]
326 if v.on == "n/a" and v.off == "n/a":
327 lines.append(f"| `{key}` | *(pass-through wrapper)* | |")
328 continue
329 on = f"`{' '.join(v.flag.on)}`" if v.on == "flag" and v.flag else _ON_WORD[v.on]
330 off = (
331 f"`{' '.join(v.flag.off)}`"
332 if v.off == "flag" and v.flag and v.flag.off
333 else _OFF_WORD[v.off]
334 )
335 lines.append(f"| `{key}` | {on} | {off} |")
336 return "\n".join(lines) + "\n"
339@tasks.task
340def provision(
341 only: Annotated[str, doc("provision just this tool")] = "",
342 prefix: Annotated[Path, doc("directory to materialise the binaries into")] = Path(
343 ".tools-latest"
344 ),
345 sync_: Annotated[
346 bool, doc("run `tools sync` against the prefix afterwards")
347 ] = False,
348 clean: Annotated[bool, doc("remove the prefix when done")] = False,
349):
350 """Fetch the latest curated tools into an isolated prefix — no pollution.
352 The stubs are read from installed binaries, so syncing against the newest
353 release means having it on PATH. This gathers the latest of every curated
354 tool under one throwaway prefix — `uv tool install` for the PyPI wheels
355 (the Rust and C++ tools included), bun's own release then `bun add` for the
356 node CLIs, a release asset for the Go ones — touching nothing outside it.
357 `--sync` then rewrites the stubs against that prefix; `--clean` deletes it.
358 Deleting the prefix is the whole undo.
359 """
360 from footman import _provision
362 # Absolute: bun errors `ReadOnlyFileSystem` on a relative BUN_INSTALL, and
363 # an absolute prefix keeps every tier's launchers and env vars unambiguous.
364 prefix = Path(prefix).expanduser().resolve()
365 outcomes = _provision.provision(_drivers.DRIVERS, prefix, only=only)
366 _print_outcomes(outcomes)
367 if sync_:
368 _sync_against(prefix, only)
369 else:
370 print(
371 f'\nput them on PATH:\n export PATH="{_provision.bin_dir(prefix)}:$PATH"'
372 )
373 if clean:
374 import shutil
376 shutil.rmtree(prefix, ignore_errors=True)
377 print(f"removed {prefix}")
380_MARK = {"ok": "ok", "fail": "FAIL", "skip": "—", "deferred": "parked"}
383def _print_outcomes(outcomes: list) -> None:
384 """The provisioning result, one aligned line per tool."""
385 width = max((len(o.key) for o in outcomes), default=4)
386 for out in outcomes:
387 mark = _MARK.get(out.status, out.status)
388 print(f"{mark:<6} {out.key.ljust(width)} {out.kind:<8} {out.detail}")
391def _sync_against(prefix: Path, only: str) -> None:
392 """Run `sync` with the prefix on PATH, so it reads the fresh binaries."""
393 import os
395 from footman import _provision
397 saved = os.environ.get("PATH", "")
398 os.environ["PATH"] = f"{_provision.bin_dir(prefix)}{os.pathsep}{saved}"
399 try:
400 sync(only=only)
401 finally:
402 os.environ["PATH"] = saved
405_READ_FROM = _re.compile(
406 r"Read from (?P<tool>\S+) (?P<version>\S+) on (?P<platform>\w+)\."
407 r"(?: In-process: (?P<mode>\w+)\.)?"
408)
410_INDEX = """\
411# Tools
413Import a tool by name — `from footman.tools import git` — and call it,
414`git.commit(…)`. No declaration needed: [the bridge](../../tools-bridge.md)
415translates keyword arguments into flags mechanically, and every tool on
416your PATH already works. These pages document the **stubs**: what each
417curated tool accepted at the version footman last read it from, with that
418tool's own help text per flag.
420Nothing here is a wrapper. The stubs are generated by `fm footman tools
421sync`, which asks the installed binaries what they take, and
422`fm footman tools audit` fails when a stub and its tool disagree. A flag
423missing from a stub still runs — every verb ends in `**flags: Any`, so a
424stub can suggest but never forbid.
426Where a flag defaults *on*, its documentation names the spelling that
427turns it off, because that is the one thing the bridge cannot infer:
428`clean=off` emits `mkdocs build --dirty`, not `--no-clean`.
430The **In-process** column is a deliberate choice, not a capability dump.
431Tasks run concurrently as threads, and a tool call is normally a subprocess —
432isolated, trivially parallel. A Python tool with a `[console_scripts]` entry
433point *can* run in footman's own process instead, skipping the spawn:
435- **default** — footman prefers in-process. `mkdocs` (macOS strips `DYLD_*`
436 from subprocesses, so cairo only resolves in-process), `zensical` and
437 `coverage` (pure Python) qualify, and their entry points accept an argument
438 list, so they stay parallel.
439- **available** — an entry point exists but running it in-process buys
440 nothing: `basedpyright` ships a Python launcher that just spawns node, so
441 footman subprocesses it anyway.
442- **no** — a Rust/Go/Node binary with no Python entry point; always a
443 subprocess.
445See [the tools bridge](../../tools-bridge.md#parallelism) for how in-process
446tools stay parallel (and the one case that can't).
448{table}
449"""
452def _header(path: Path) -> tuple[str, str]:
453 """`(read from, in-process)` as a checked-in stub records them.
455 The table is built from the files rather than from the tools, so
456 building the docs needs nothing on PATH and the page says exactly what
457 ships — including for the tools this machine cannot ask.
458 """
459 head = path.read_text(encoding="utf-8")[:600].replace("\n# ", " ")
460 match = _READ_FROM.search(head)
461 if not match:
462 return "hand-written", "unknown"
463 return f"{match['version']} ({match['platform']})", match["mode"] or "unknown"
466def _verbs_of(path: Path) -> list[str]:
467 """The verbs a stub declares, for the index table."""
468 import ast
470 found: list[str] = []
471 for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))):
472 if isinstance(node, ast.ClassDef):
473 for item in node.body:
474 if isinstance(item, ast.FunctionDef) and not item.name.startswith("_"):
475 found.append(item.name)
476 return sorted(set(found))
479@tasks.task
480def pages(
481 out: Annotated[Path, doc("directory to write the reference pages into")],
482 nav: Annotated[
483 Path | None, doc("a config whose Tools nav block to rewrite")
484 ] = None,
485):
486 """Write one reference page per tool, plus the index table.
488 Built from the checked-in stubs rather than from the installed tools, so
489 the docs build needs nothing on PATH and says exactly what ships. Tools
490 are ordered alphabetically. With *nav*, the tool entries of that config's
491 Tools list are regenerated too (between markers), so the sidebar can never
492 fall behind the drivers again.
493 """
494 out.mkdir(parents=True, exist_ok=True)
495 stubbed = sorted(
496 (d for d in _drivers.DRIVERS if _stub_path(d.key).exists()),
497 key=lambda d: d.key,
498 )
499 rows = ["| Tool | Read from | In-process | Verbs |", "| --- | --- | --- | --- |"]
500 for driver in stubbed:
501 rows.append(_row(driver, _stub_path(driver.key)))
502 (out / f"{driver.key}.md").write_text(_page(driver), encoding="utf-8")
503 (out / "index.md").write_text(
504 _INDEX.format(table="\n".join(rows)), encoding="utf-8"
505 )
506 if nav is not None:
507 write_tools_nav(nav, [d.key for d in stubbed])
508 print(f"wrote {len(stubbed)} tool page(s) into {out}")
511# The tool entries of the docs nav are regenerated between these markers, so a
512# new driver never needs a hand-edit — `nav_keys` reads them back for the test
513# that fails when the sidebar falls behind `DRIVERS`.
514_NAV_BEGIN = " # tools-nav:begin (generated by `fm footman tools pages`)"
515_NAV_END = " # tools-nav:end"
516_NAV_RE = _re.compile(
517 _re.escape(_NAV_BEGIN) + r".*?" + _re.escape(_NAV_END), _re.DOTALL
518)
519_NAV_ENTRY = _re.compile(r'\{\s*"(?P<key>[^"]+)"\s*=\s*"_generated/tools/')
522def write_tools_nav(config: Path, keys: list[str]) -> None:
523 """Rewrite a zensical/mkdocs Tools nav's tool entries from *keys*."""
524 entries = [f' {{ "{k}" = "_generated/tools/{k}.md" }},' for k in keys]
525 block = "\n".join([_NAV_BEGIN, *entries, _NAV_END])
526 config.write_text(
527 _NAV_RE.sub(lambda _m: block, config.read_text(encoding="utf-8")),
528 encoding="utf-8",
529 )
532def nav_keys(config: Path) -> list[str]:
533 """The tool keys the config's generated Tools-nav block lists, in order."""
534 match = _NAV_RE.search(config.read_text(encoding="utf-8"))
535 return [m["key"] for m in _NAV_ENTRY.finditer(match.group())] if match else []
538def _row(driver: _drivers.Driver, path: Path) -> str:
539 """One line of the index table: what it is, and what it was read from."""
540 verbs = _verbs_of(path)
541 listed = ", ".join(f"`{v}`" for v in verbs[:5]) or "the tool itself"
542 if len(verbs) > 5:
543 listed += f", … ({len(verbs)} in all)"
544 version, mode = _header(path)
545 home = f" ([docs]({driver.url}))" if driver.url else ""
546 return (
547 f"| [`{driver.key}`]({driver.key}.md){home} | {version} | {mode} | {listed} |"
548 )
551def _page(driver: _drivers.Driver) -> str:
552 """One tool's reference page — mkdocstrings renders it from the stub."""
553 home = f"[{driver.name} documentation]({driver.url})\n\n" if driver.url else ""
554 return (
555 f"# {driver.key}\n\n{home}"
556 f"::: footman._stubs.{driver.key}.{_class_name(driver.key)}\n"
557 )
560__all__ = ["tasks"]