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

1"""Keep the `tools.*` stubs honest — `fm footman tools …`. 

2 

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. 

7 

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 

13 

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""" 

19 

20from __future__ import annotations 

21 

22import re as _re 

23import sys 

24from pathlib import Path 

25from typing import Annotated 

26 

27from footman import _drivers, _stubgen, _toolspec 

28from footman._describe import bold, cyan, wants_color 

29from footman.params import doc 

30from footman.registry import Group 

31 

32tasks = Group("tools", help="Keep the tools.* stubs honest") 

33 

34_STUBS = Path(__file__).resolve().parent.parent / "_stubs" 

35 

36 

37def _stub_path(key: str) -> Path: 

38 return _STUBS / f"{key}.pyi" 

39 

40 

41def _platform() -> str: 

42 return {"darwin": "macOS", "win32": "Windows"}.get(sys.platform, "Linux") 

43 

44 

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)) 

49 

50 

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 ) 

58 

59 

60def _mode(driver: _drivers.Driver, spec: _toolspec.ToolSpec) -> str: 

61 """How this tool runs: in footman's process by default, or on request. 

62 

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" 

70 

71 

72def _class_name(key: str) -> str: 

73 return "".join(part.title() for part in key.split("_")) 

74 

75 

76def _formatted(text: str) -> str: 

77 """Run the generated text through the formatter that guards the repo. 

78 

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 

84 

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 

96 

97 

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}") 

118 

119 

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}") 

142 

143 

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. 

149 

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)}") 

172 

173 

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. 

180 

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 

186 

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 ) 

232 

233 

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. 

242 

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). 

249 

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 

256 

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 

265 

266 

267def _color_probe_and_write(only: str, write: bool, on: bool) -> None: 

268 from footman import _colorprobe 

269 

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)) 

292 

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}") 

300 

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)") 

307 

308 

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": "—"} 

312 

313 

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" 

337 

338 

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. 

351 

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 

361 

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 

375 

376 shutil.rmtree(prefix, ignore_errors=True) 

377 print(f"removed {prefix}") 

378 

379 

380_MARK = {"ok": "ok", "fail": "FAIL", "skip": "—", "deferred": "parked"} 

381 

382 

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}") 

389 

390 

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 

394 

395 from footman import _provision 

396 

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 

403 

404 

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) 

409 

410_INDEX = """\ 

411# Tools 

412 

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. 

419 

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. 

425 

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`. 

429 

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: 

434 

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. 

444 

445See [the tools bridge](../../tools-bridge.md#parallelism) for how in-process 

446tools stay parallel (and the one case that can't). 

447 

448{table} 

449""" 

450 

451 

452def _header(path: Path) -> tuple[str, str]: 

453 """`(read from, in-process)` as a checked-in stub records them. 

454 

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" 

464 

465 

466def _verbs_of(path: Path) -> list[str]: 

467 """The verbs a stub declares, for the index table.""" 

468 import ast 

469 

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)) 

477 

478 

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. 

487 

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}") 

509 

510 

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/') 

520 

521 

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 ) 

530 

531 

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 [] 

536 

537 

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 ) 

549 

550 

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 ) 

558 

559 

560__all__ = ["tasks"]