Coverage for src/footman/_drivers.py: 97%

126 statements  

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

1"""The curated tools, and the little each one needs said about it. 

2 

3Extraction is generic — `_toolspec` reads a click command's parameters, 

4`_toolhelp` reads anybody's `--help` — so a driver is not a wrapper. It 

5carries only what a tool cannot tell you by being asked: 

6 

7* **which verbs are worth stubbing.** `docker --help` lists forty commands 

8 and `git` has hundreds; a stub of all of them would be a megabyte nobody 

9 reads. The list here is the verbs tasks actually call. 

10* **the quirks.** git's `--help` opens a man page, so it wants `-h`. A tool 

11 whose real name differs from its attribute (`markdownlint-cli2` is 

12 `tools.markdownlint`) says so. 

13* **the default.** Whether `tools.<name>` runs in-process by default, which 

14 mirrors how it is constructed in `tools.py`. 

15 

16Everything else — the flags, their help, their types, the negations — comes 

17from the installed tool, every time the stubs are regenerated. 

18""" 

19 

20from __future__ import annotations 

21 

22import os 

23import re 

24import shutil 

25import subprocess 

26import sys 

27from dataclasses import dataclass, field 

28 

29from footman import _toolhelp, _toolspec 

30from footman._toolspec import ToolSpec, Verb 

31 

32 

33@dataclass(frozen=True) 

34class Provision: 

35 """How `fm footman tools provision` fetches this tool's *latest* binary. 

36 

37 Data, like everything else a driver carries. The extractor reads the 

38 installed tool; this says how to *get* the latest one into a throwaway 

39 prefix, without touching the machine's own environment. 

40 """ 

41 

42 kind: str = "uv" 

43 """`uv` — a PyPI console script, `uv tool install --upgrade`d into an 

44 isolated prefix (covers the Rust and C++ tools too: ruff, prek, cmake and 

45 ninja all ship binary wheels). `node` — a package `bun install`s. `bun` — 

46 bun's own GitHub release, provisioned first because the node tier runs 

47 through it. `github` / `gitlab` — a prebuilt release asset. `system` — 

48 already on PATH (git, docker, the uv running this); never provisioned. 

49 `deferred` — parked, `note` saying why (tea, until > 0.14.2).""" 

50 package: str = "" 

51 """The PyPI or npm package, when it differs from the driver's binary name 

52 (`markdownlint-cli2`); otherwise the binary name is used.""" 

53 repo: str = "" 

54 """`owner/repo` for a `github` / `gitlab` release download.""" 

55 note: str = "" 

56 """Why a `deferred` source is parked — shown by `provision`.""" 

57 plugins: tuple[str, ...] = () 

58 """Extra packages to install *alongside* the tool (`uv --with`), so a 

59 plugin-extended CLI is read whole. pytest's `--cov*` flags come from 

60 `pytest-cov`; without it a bare provisioned pytest would stub none of them.""" 

61 

62 def target(self, name: str) -> str: 

63 """What to fetch: the explicit `package`/`repo`, else the tool *name*.""" 

64 return self.package or self.repo or name 

65 

66 

67@dataclass(frozen=True) 

68class Driver: 

69 """One curated tool: what to run, and which verbs to read.""" 

70 

71 name: str 

72 """The binary as it is invoked.""" 

73 attr: str = "" 

74 """`tools.<attr>`, when it differs from the binary's name.""" 

75 verbs: tuple[str, ...] = field(default_factory=tuple) 

76 """The subcommands to stub, dotted for nesting (`compose.up`). Empty 

77 means the tool is its own command and its options hang off `__call__`.""" 

78 help_flag: str = "--help" 

79 """git's `--help` opens a man page; `-h` is the help text.""" 

80 in_process: bool = False 

81 """Whether `tools.<attr>` prefers in-process, as `tools.py` builds it.""" 

82 base: tuple[str, ...] = field(default_factory=tuple) 

83 """A pre-bound verb: `tools.ruff_format` is `Tool("ruff", "format")`.""" 

84 source: str = "auto" 

85 """`auto` prefers structure (click) and falls back to `--help`.""" 

86 shorts: str = "only" 

87 """Short-option policy for the stub: `"none"` never keys on a short, 

88 `"only"` (default) keys on one *when it is the option's sole spelling* 

89 (python's `-m`, git's `-C`), and `"all"` also keys on a short that has a 

90 long form. Read only from `--help`, never a man page (its prose is noisy).""" 

91 url: str = "" 

92 """The tool's home, for the reference page's table.""" 

93 man: bool = False 

94 """Read each verb's *manual* (`git help <verb>`) instead of its terse 

95 `-h`. git's `-h` omits about half its flags and prints an idiosyncratic 

96 multi-form usage; the manual is complete and states one SYNOPSIS per 

97 form, so both options and positional shape come out right. Runs only at 

98 stub-generation time, so the man-page dependency never reaches a user.""" 

99 provision: Provision = field(default_factory=Provision) 

100 """How to fetch the latest binary — the default is a PyPI `uv` install.""" 

101 

102 @property 

103 def key(self) -> str: 

104 return self.attr or self.name.replace("-", "_") 

105 

106 @property 

107 def wanted(self) -> tuple[str, ...]: 

108 """The verbs to read: a pre-bound tool wants only the one it binds.""" 

109 if self.base: 

110 return (".".join(self.base),) 

111 return self.verbs 

112 

113 

114DRIVERS: tuple[Driver, ...] = ( 

115 Driver( 

116 "ruff", verbs=("check", "format", "clean"), url="https://docs.astral.sh/ruff/" 

117 ), 

118 Driver( 

119 "ruff", 

120 attr="ruff_format", 

121 base=("format",), 

122 url="https://docs.astral.sh/ruff/formatter/", 

123 ), 

124 Driver("basedpyright", url="https://docs.basedpyright.com/"), 

125 Driver( 

126 "uv", 

127 provision=Provision(package="uv"), # PyPI, `uv tool install uv` — never host 

128 url="https://docs.astral.sh/uv/", 

129 verbs=( 

130 "sync", 

131 "lock", 

132 "run", 

133 "add", 

134 "remove", 

135 "build", 

136 "publish", 

137 "export", 

138 "venv", 

139 "tree", 

140 "version", 

141 "pip.install", 

142 "pip.compile", 

143 "pip.sync", 

144 "pip.list", 

145 "tool.install", 

146 "tool.run", 

147 "tool.upgrade", 

148 ), 

149 ), 

150 Driver( 

151 "git", 

152 provision=Provision(kind="system"), 

153 url="https://git-scm.com/docs", 

154 help_flag="-h", 

155 man=True, 

156 verbs=( 

157 "add", 

158 "commit", 

159 "push", 

160 "pull", 

161 "fetch", 

162 "clone", 

163 "init", 

164 "checkout", 

165 "switch", 

166 "branch", 

167 "tag", 

168 "status", 

169 "diff", 

170 "log", 

171 "rev-parse", 

172 "describe", 

173 "stash", 

174 "restore", 

175 "worktree", 

176 ), 

177 ), 

178 Driver( 

179 "docker", 

180 provision=Provision(kind="system"), 

181 url="https://docs.docker.com/reference/cli/docker/", 

182 verbs=( 

183 "build", 

184 "run", 

185 "push", 

186 "pull", 

187 "images", 

188 "ps", 

189 "exec", 

190 "logs", 

191 "compose.up", 

192 "compose.down", 

193 "compose.build", 

194 "compose.logs", 

195 "compose.ps", 

196 "compose.run", 

197 "compose.exec", 

198 ), 

199 ), 

200 Driver( 

201 "bun", 

202 provision=Provision(kind="bun", repo="oven-sh/bun"), 

203 verbs=("install", "add", "remove", "run", "build", "test", "x"), 

204 url="https://bun.sh/docs/cli/install", 

205 ), 

206 Driver( 

207 "mkdocs", 

208 verbs=("build", "serve", "new", "gh-deploy"), 

209 in_process=True, 

210 url="https://www.mkdocs.org/", 

211 ), 

212 Driver( 

213 "zensical", 

214 verbs=("build", "serve", "new"), 

215 in_process=True, 

216 url="https://zensical.org/", 

217 ), 

218 Driver( 

219 "coverage", 

220 url="https://coverage.readthedocs.io/", 

221 verbs=("run", "report", "html", "xml", "json", "combine", "erase", "annotate"), 

222 in_process=True, 

223 ), 

224 Driver( 

225 "cspell", 

226 provision=Provision(kind="node"), 

227 verbs=("lint", "trace", "check", "suggest"), 

228 url="https://cspell.org/", 

229 ), 

230 Driver( 

231 "prek", 

232 verbs=("run", "install", "uninstall", "autoupdate", "clean"), 

233 url="https://prek.j178.dev/", 

234 ), 

235 Driver( 

236 "markdownlint-cli2", 

237 attr="markdownlint", 

238 provision=Provision(kind="node"), 

239 url="https://github.com/DavidAnson/markdownlint-cli2", 

240 ), 

241 Driver( 

242 "gh", 

243 provision=Provision(kind="github", repo="cli/cli"), 

244 url="https://cli.github.com/manual/", 

245 verbs=( 

246 "pr.create", 

247 "pr.list", 

248 "pr.view", 

249 "pr.checkout", 

250 "pr.merge", 

251 "issue.create", 

252 "issue.list", 

253 "issue.view", 

254 "release.create", 

255 "release.upload", 

256 "release.view", 

257 "release.list", 

258 "repo.clone", 

259 "repo.view", 

260 "run.list", 

261 "run.view", 

262 "run.watch", 

263 "workflow.run", 

264 "workflow.list", 

265 "auth.status", 

266 "auth.login", 

267 "api", 

268 "label.list", 

269 "label.create", 

270 ), 

271 ), 

272 Driver( 

273 "eclint", 

274 provision=Provision(kind="gitlab", repo="willemkokke/eclint"), 

275 url="https://gitlab.com/willemkokke/eclint", 

276 ), 

277 Driver("djlint", url="https://www.djlint.com/"), 

278 Driver("mypy", url="https://mypy.readthedocs.io/"), 

279 Driver("ty", verbs=("check",), url="https://docs.astral.sh/ty/"), 

280 Driver("twine", verbs=("upload", "check"), url="https://twine.readthedocs.io/"), 

281 Driver("git-changelog", url="https://pawamoy.github.io/git-changelog/"), 

282 Driver("git-cliff", url="https://git-cliff.org/"), 

283 Driver( 

284 "pyproject-build", 

285 attr="build", 

286 provision=Provision(package="build"), 

287 url="https://build.pypa.io/", 

288 ), 

289 Driver("cmake", url="https://cmake.org/documentation/"), 

290 Driver("ninja", url="https://ninja-build.org/"), 

291 Driver( 

292 "pytest", 

293 url="https://docs.pytest.org/", 

294 provision=Provision(plugins=("pytest-cov",)), # so --cov* is read too 

295 ), 

296 Driver( 

297 "python", 

298 provision=Provision(kind="python", package="3.13"), 

299 url="https://docs.python.org/3/using/cmdline.html", 

300 ), 

301 # The shells footman autocompletes for. Their stubs are hand-written (a 

302 # `source="manual"` driver is listed and paged but never extracted or 

303 # re-synced): what matters is `<shell>("command")` -> `<shell> -c command`, 

304 # not the shell binary's own hundred flags. 

305 Driver("bash", source="manual", url="https://www.gnu.org/software/bash/"), 

306 Driver("zsh", source="manual", url="https://www.zsh.org/"), 

307 Driver("fish", source="manual", url="https://fishshell.com/"), 

308 Driver("pwsh", source="manual", url="https://learn.microsoft.com/powershell/"), 

309 Driver("nu", source="manual", url="https://www.nushell.sh/"), 

310 Driver( 

311 "cmd", 

312 source="manual", 

313 url="https://learn.microsoft.com/windows-server/administration/windows-commands/cmd", 

314 ), 

315) 

316 

317# A negative lookbehind, not `\b`: a version glued to a `v` prefix (`v0.23.1`) 

318# has no word boundary before its first digit, so `\b` would skip to the middle 

319# and read `23.1`. Reject only a preceding digit or dot, so `v0.23.1` -> `0.23.1` 

320# while `2` inside `1.2.3` still can't start a fresh match. 

321_VERSION = re.compile(r"(?<![\d.])(\d+\.\d+(?:\.\d+)?(?:[-.][A-Za-z0-9]+)*)\b") 

322 

323 

324_HOST_READ = frozenset(d.name for d in DRIVERS if d.provision.kind == "system") 

325"""Tools read straight off the host, never provisioned into an isolated prefix 

326(git, docker, uv) — the only ones for which Homebrew is consulted on macOS.""" 

327 

328 

329def _brew_prefixes() -> tuple[str, ...]: 

330 """Homebrew's prefixes, most-authoritative first: an explicit 

331 `HOMEBREW_PREFIX`, then the Apple-silicon and Intel defaults.""" 

332 prefixes: list[str] = [] 

333 if "HOMEBREW_PREFIX" in os.environ: 333 ↛ 335line 333 didn't jump to line 335 because the condition on line 333 was always true

334 prefixes.append(os.environ["HOMEBREW_PREFIX"]) 

335 for default in ("/opt/homebrew", "/usr/local"): 

336 if default not in prefixes: 

337 prefixes.append(default) 

338 return tuple(prefixes) 

339 

340 

341def _resolve(name: str) -> str | None: 

342 """The executable to read a tool from. 

343 

344 A *host-read* tool on macOS (git; docker and uv carry no keg) prefers its 

345 Homebrew **keg** (`opt/<name>/bin/<name>`) — the newest build, and it 

346 survives `brew unlink`, so an intentionally-off-`PATH` tool is still read; 

347 a tool with no keg simply falls through. Everything else — every provisioned 

348 tier (pip/uv/npm/release) and every platform but macOS — is plain 

349 `shutil.which`, so a `provision --sync` prefix and a venv win, and a stale 

350 `/opt/homebrew/bin` console-script shim is never picked. 

351 """ 

352 if name in _HOST_READ and sys.platform == "darwin": 

353 for prefix in _brew_prefixes(): 

354 keg = os.path.join(prefix, "opt", name, "bin", name) 

355 if os.access(keg, os.X_OK) and not os.path.isdir(keg): 

356 return keg 

357 return shutil.which(name) 

358 

359 

360def installed(driver: Driver) -> bool: 

361 """Whether this machine has the tool to ask.""" 

362 return _resolve(driver.name) is not None 

363 

364 

365def version(name: str) -> str: 

366 """`<tool> --version`, reduced to the version itself.""" 

367 binary = _resolve(name) 

368 if binary is None: 

369 return "" 

370 try: 

371 done = subprocess.run( 

372 [binary, "--version"], capture_output=True, text=True, timeout=30 

373 ) 

374 except (OSError, subprocess.SubprocessError): 

375 return "" 

376 match = _VERSION.search(done.stdout or done.stderr) 

377 return match[1] if match else "" 

378 

379 

380def in_process_capable(name: str) -> bool: 

381 """Whether the tool publishes a `[console_scripts]` entry point. 

382 

383 That entry point is exactly what `Tool.__call__` resolves to run a tool 

384 inside footman's process, so its existence *is* the capability — no 

385 list to maintain, and it answers correctly for a tool footman has never 

386 heard of. 

387 """ 

388 from footman import tools 

389 

390 return tools._console_entrypoint(name) is not None 

391 

392 

393def extract(driver: Driver) -> ToolSpec: 

394 """Ask the installed tool to describe itself, best source first. 

395 

396 click hands over its parameters as data — including `secondary_opts`, 

397 the negation a `--help` scrape can only find if the tool happens to 

398 mention it in prose. So structure wins when it is available, and the 

399 help text covers everyone else. 

400 """ 

401 spec = ToolSpec(name=driver.name) 

402 if driver.source in {"auto", "click"}: 

403 spec = _from_click(driver) or spec 

404 if not spec.verbs and driver.source in {"auto", "help"}: 

405 spec = _toolhelp.from_help( 

406 driver.name, 

407 binary=_resolve(driver.name), 

408 verbs=driver.wanted, 

409 version=version(driver.name), 

410 in_process=in_process_capable(driver.name), 

411 flag=driver.help_flag, 

412 man=driver.man, 

413 shorts=driver.shorts, 

414 ) 

415 return _rebase(spec, driver.base) if driver.base else spec 

416 

417 

418def _rebase(spec: ToolSpec, base: tuple[str, ...]) -> ToolSpec: 

419 """A tool bound to one verb calls it directly: `tools.ruff_format(...)`. 

420 

421 So that verb's options become the stub's `__call__`, and the rest of 

422 the tool is somebody else's stub. 

423 """ 

424 wanted = ".".join(base).replace("-", "_") 

425 for verb in spec.verbs: 

426 if verb.name == wanted: 

427 return ToolSpec( 

428 name=spec.name, 

429 help=verb.help or spec.help, 

430 version=spec.version, 

431 verbs=(Verb(name="", help=verb.help, options=verb.options),), 

432 in_process=spec.in_process, 

433 ) 

434 return ToolSpec(name=spec.name, help=spec.help, version=spec.version) 

435 

436 

437def _from_click(driver: Driver) -> ToolSpec | None: 

438 """A spec from the tool's click command, when it is a click tool.""" 

439 from footman import tools 

440 

441 entry = tools._console_entrypoint(driver.name) 

442 if entry is None: 

443 return None 

444 try: 

445 command = entry.load() 

446 except Exception: # a tool that won't import can't describe itself 

447 return None 

448 if not hasattr(command, "params"): 

449 return None # not click: argparse mains and plain functions land here 

450 spec = _toolspec.from_click(command, name=driver.name, version=version(driver.name)) 

451 return _select(spec, driver.wanted) 

452 

453 

454def _select(spec: ToolSpec, verbs: tuple[str, ...]) -> ToolSpec: 

455 """Keep the verbs the driver asked for, plus the tool's own options.""" 

456 if not verbs: 456 ↛ 457line 456 didn't jump to line 457 because the condition on line 456 was never true

457 return spec 

458 wanted = {v.replace("-", "_") for v in verbs} | {""} 

459 kept = tuple(v for v in spec.verbs if v.name in wanted) 

460 return ToolSpec( 

461 name=spec.name, 

462 help=spec.help, 

463 version=spec.version, 

464 verbs=kept, 

465 in_process=spec.in_process, 

466 ) 

467 

468 

469def find(key: str) -> Driver | None: 

470 """The driver for `tools.<key>`.""" 

471 for driver in DRIVERS: 

472 if driver.key == key: 

473 return driver 

474 return None