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
« 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`.
3Every call runs through the current task context, so it inherits capture,
4replay-on-failure, dry-run, recording, and `--json` steps.
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:
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`
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`.
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"""
33from __future__ import annotations
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
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
54_version_cache: dict[str, tuple[int, ...]] = {}
57class _Off:
58 """The value that disables a flag: `flag=off` → `--no-flag`.
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 """
67 __slots__ = ()
69 def __repr__(self) -> str:
70 return "off"
73off = _Off()
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}
93def _negation(tool: str, key: str, *, single_dash: bool = False) -> str:
94 """The flag that turns *key* off for *tool*.
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("_", "-")
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}
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())
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.
137class _ColorFlag(NamedTuple):
138 """How one tool forces colour on (or off) with its own switch.
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 """
147 on: tuple[str, ...]
148 off: tuple[str, ...] = ()
149 pre_verb: bool = False
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
172_COLOR: dict[str, dict[str, _ColorFlag]] = _load_color()
175def _color_flag(argv0: str, base: list[str]) -> _ColorFlag | None:
176 """The colour switch for the verb reached by *base*, or None.
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("")
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.
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((), ())
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.
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.
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)
235def _spell(flag: str, value: str | None, *, attach_long: bool) -> list[str]:
236 """One option as argv tokens — the shared placement rule.
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*:
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.
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]
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.
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
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.
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)
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
341 return shlex.quote(text)
344def _console_entrypoint(name: str) -> Any | None:
345 """The `[console_scripts]` EntryPoint named *name*, UNLOADED, or None.
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
355 for ep in entry_points(group="console_scripts", name=name):
356 return ep
357 return None
360def _accepts_args(entry: Any) -> bool:
361 """Can *entry* take the argument list directly (no sys.argv patching)?
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
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())
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()
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")
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)
407class Tool:
408 """One command-line tool; see the module docstring for the grammar.
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 """
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
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 )
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("_", "-"))
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:
481 git.opts(nofail=True).push() # tolerate a non-zero exit
482 pytest.opts(capture=False)("-s") # stream this run live
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
493 def flags(self, **kwargs: Any) -> Tool:
494 """Bind a tool's own global options *before* the next subcommand.
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:
501 tools.docker.flags(host="tcp://x").ps(all=True)
502 # -> docker --host=tcp://x ps --all
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))
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)
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]
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]
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 )
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
572 show = _Invocation(parts, tuple(argv))
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
587 return _run(
588 _invoke, nofail=nofail, capture=capture, title=title, _show=show
589 )
590 return _spawn()
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).
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
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
612 def load() -> Any:
613 import importlib
615 mod, _, attr = self._entry.partition(":")
616 return getattr(importlib.import_module(mod), attr)
618 return load
619 ep = _console_entrypoint(self._argv0)
620 return ep.load if ep is not None else None
622 def installed_version(self) -> tuple[int, ...]:
623 """The installed binary's version, as a comparable int tuple.
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]
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")
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)
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")
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("_", "-"))