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
« 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.
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:
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`.
16Everything else — the flags, their help, their types, the negations — comes
17from the installed tool, every time the stubs are regenerated.
18"""
20from __future__ import annotations
22import os
23import re
24import shutil
25import subprocess
26import sys
27from dataclasses import dataclass, field
29from footman import _toolhelp, _toolspec
30from footman._toolspec import ToolSpec, Verb
33@dataclass(frozen=True)
34class Provision:
35 """How `fm footman tools provision` fetches this tool's *latest* binary.
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 """
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."""
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
67@dataclass(frozen=True)
68class Driver:
69 """One curated tool: what to run, and which verbs to read."""
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."""
102 @property
103 def key(self) -> str:
104 return self.attr or self.name.replace("-", "_")
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
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)
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")
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."""
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)
341def _resolve(name: str) -> str | None:
342 """The executable to read a tool from.
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)
360def installed(driver: Driver) -> bool:
361 """Whether this machine has the tool to ask."""
362 return _resolve(driver.name) is not None
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 ""
380def in_process_capable(name: str) -> bool:
381 """Whether the tool publishes a `[console_scripts]` entry point.
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
390 return tools._console_entrypoint(name) is not None
393def extract(driver: Driver) -> ToolSpec:
394 """Ask the installed tool to describe itself, best source first.
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
418def _rebase(spec: ToolSpec, base: tuple[str, ...]) -> ToolSpec:
419 """A tool bound to one verb calls it directly: `tools.ruff_format(...)`.
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)
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
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)
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 )
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