Coverage for src/footman/_toolspec.py: 97%
128 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"""What a command-line tool says about itself — extracted, not transcribed.
3The `tools.*` bridge translates keyword arguments mechanically, which is
4what keeps it from going stale the way hand-written wrappers do. But two
5things about a tool cannot be derived from the call: what its options
6*mean*, and how it spells a negation.
8The second one is a bug, not a nicety. `off` emits `--no-<name>`, which
9is right for most tools and wrong for enough to matter: `mkdocs build
10--no-clean` is rejected outright — the flag is `--dirty` — and five of
11mkdocs' eight negatable options disagree with the convention. Only the
12tool knows, so footman asks it.
14Extraction, richest first:
16* **click** — `Command.params` carries `opts`, `secondary_opts` (the true
17 negation), the default, the help text, and the type, as data. No
18 parsing, no guessing.
19* **argparse / optparse** — walk the parser's actions (to come).
20* **`--help` text** — for the Rust and Go tools, whose output is regular
21 and which often spell the negation in prose (clap: "Use
22 `--no-unsafe-fixes` to disable"). To come.
24Nothing here runs on the completion hot path, and nothing here is
25imported by `tools.py` at call time: the extracted facts are baked into
26a table and a stub, and the extractor only runs when regenerating them.
27"""
29from __future__ import annotations
31from dataclasses import dataclass, field
32from typing import Any
35@dataclass(frozen=True)
36class Option:
37 """One option of one tool verb, as the tool describes it."""
39 name: str
40 """The Python keyword a task writes: `use_directory_urls`."""
41 flags: tuple[str, ...] = ()
42 """What the tool accepts, longest first: `("--use-directory-urls",)`."""
43 negation: str = ""
44 """How this tool spells "off" — `--dirty`, not always `--no-<name>`.
45 Empty when the option is not a negatable flag."""
46 help: str = ""
47 """The tool's own one-line description, for the stub's docstring."""
48 type_name: str = "str"
49 """The Python type the stub declares: bool, str, int, list[str]."""
50 default: Any = None
51 """The tool's default, when it states one."""
52 choices: tuple[str, ...] = ()
53 """The closed set of values the tool accepts, when it names one — the
54 stub declares these as a `Literal`, so an IDE offers them."""
57@dataclass(frozen=True)
58class Verb:
59 """A subcommand: `mkdocs build`, `ruff check`."""
61 name: str
62 help: str = ""
63 options: tuple[Option, ...] = ()
64 wraps: bool = False
65 """Whether the verb runs *another* command — `uv run`, `docker exec`,
66 `coverage run`. Its usage trails a `[COMMAND] [ARG...]` (or "program
67 options"), and everything after the verb's own arguments belongs to the
68 wrapped program. The bridge must place this call's flags *before* those
69 arguments, or they leak past the tool into the child."""
70 positional: str = "any"
71 """What positionals the verb takes, read from its usage line — the stub
72 renders this with `/` and `*`:
74 * `"any"` — zero or more (`ruff check [FILES]...`), or unknown. The
75 conservative default: `*args`, forbids nothing.
76 * `"none"` — the tool declares only options (`mkdocs build [OPTIONS]`),
77 so the stub is keyword-only and a stray positional is a type error.
78 * `"required"` — a required leading positional (`docker run IMAGE …`),
79 named by `lead`; the stub makes it positional-only.
80 """
81 lead: str = ""
82 """The name of the required leading positional, when `positional` is
83 `"required"` — `image` for `docker run`, `repo` for `git clone`."""
86@dataclass(frozen=True)
87class ToolSpec:
88 """Everything footman knows about one tool, from the tool itself."""
90 name: str
91 help: str = ""
92 version: str = ""
93 """The version this was extracted from — what an audit compares."""
94 verbs: tuple[Verb, ...] = field(default_factory=tuple)
95 in_process: bool = False
96 """Whether the tool can run inside footman's process (it publishes a
97 `[console_scripts]` entry point)."""
99 def negations(self) -> dict[str, str]:
100 """`{option: negation}` for every option whose negation is *not*
101 the `--no-<name>` default — the table `off` consults.
103 Only the exceptions: a table of things that already work would be
104 noise, and would have to be regenerated far more often.
105 """
106 exceptions: dict[str, str] = {}
107 for verb in self.verbs:
108 for option in verb.options:
109 if not option.negation:
110 continue
111 default = "--no-" + option.name.replace("_", "-")
112 if option.negation != default:
113 exceptions[option.name] = option.negation
114 return exceptions
116 def wrappers(self) -> frozenset[str]:
117 """The dotted verb paths that wrap a command — what `_WRAPPERS`
118 holds, so the bridge places a wrapper's flags before the child's
119 argv rather than leaking them into the child."""
120 return frozenset(verb.name for verb in self.verbs if verb.wraps)
122 def color_flags(self) -> dict[str, tuple[str, str, str]]:
123 """The colour switch this tool exposes, per verb: `{verb: (flag, on,
124 off)}`.
126 A verb with a `--color`/`--colour`/`--colors` option taking a closed set
127 that includes `always`/`never` — the spelling footman would force to
128 make the tool colour past its own non-tty check (or stay quiet past an
129 ignored `NO_COLOR`). Both directions fall out of the one `choices` list,
130 so detecting the *off* form costs nothing. Empty when the tool has no
131 such switch, in which case it is assumed to obey `FORCE_COLOR`/`NO_COLOR`.
132 This informs the curated `_COLOR` table (via `fm footman tools color`);
133 it is not applied automatically — a flag is only added for a tool proven
134 to ignore the environment.
135 """
136 found: dict[str, tuple[str, str, str]] = {}
137 for verb in self.verbs:
138 for option in verb.options:
139 detected = _color_option(option)
140 if detected is not None:
141 found[verb.name] = detected
142 break
143 return found
146_COLOR_KEYWORDS = frozenset({"color", "colour", "colors", "colours"})
149def _color_option(option: Option) -> tuple[str, str, str] | None:
150 """`(flag, on, off)` if *option* is a `--color`-style switch, else None.
152 The tool's own flag spelling (longest first), and the `always`/`never`
153 values it takes from its closed set — either may be absent, but at least
154 one must be, or this is not a forcing switch footman can use."""
155 if option.name not in _COLOR_KEYWORDS or not option.choices:
156 return None
157 choices = set(option.choices)
158 on = "always" if "always" in choices else ""
159 off = "never" if "never" in choices else ""
160 if not on and not off:
161 return None
162 flag = option.flags[0] if option.flags else f"--{option.name}"
163 return (flag, on, off)
166def _type_name(param: Any) -> str:
167 """The stub's declared type for a click parameter."""
168 if getattr(param, "is_flag", False):
169 return "bool"
170 kind = getattr(getattr(param, "type", None), "name", "") or ""
171 scalar = {
172 "integer": "int",
173 "float": "float",
174 "boolean": "bool",
175 "path": "str",
176 "filename": "str",
177 "directory": "str",
178 "text": "str",
179 "choice": "str",
180 }.get(kind, "str")
181 return f"list[{scalar}]" if getattr(param, "multiple", False) else scalar
184def from_click(command: Any, *, name: str = "", version: str = "") -> ToolSpec:
185 """A `ToolSpec` from a click `Group` or `Command`.
187 click models a negatable flag as one parameter with `opts` and
188 `secondary_opts` — `--clean` / `--dirty` — which is exactly the fact
189 `off` needs and cannot infer.
190 """
191 tool = name or getattr(command, "name", "") or ""
192 commands = getattr(command, "commands", None)
193 if commands: 193 ↛ 199line 193 didn't jump to line 199 because the condition on line 193 was always true
194 verbs = tuple(
195 _verb_from_click(verb_name, sub)
196 for verb_name, sub in sorted(commands.items())
197 )
198 else: # a single-command tool: its options hang off the root
199 verbs = (_verb_from_click("", command),)
200 return ToolSpec(
201 name=tool,
202 help=_first_line(getattr(command, "help", "") or ""),
203 version=version,
204 verbs=verbs,
205 in_process=True, # a click tool always has a console_scripts entry
206 )
209def _verb_from_click(name: str, command: Any) -> Verb:
210 options = []
211 arguments = []
212 for param in getattr(command, "params", ()):
213 if getattr(param, "param_type_name", "") == "argument":
214 arguments.append(param) # a positional, for the shape below
215 continue
216 if getattr(param, "param_type_name", "") != "option": 216 ↛ 217line 216 didn't jump to line 217 because the condition on line 216 was never true
217 continue
218 secondary = tuple(getattr(param, "secondary_opts", ()) or ())
219 options.append(
220 Option(
221 name=_keyword(param),
222 flags=tuple(sorted(param.opts, key=len, reverse=True)),
223 negation=secondary[0] if secondary else "",
224 help=_first_line(getattr(param, "help", "") or ""),
225 type_name=_type_name(param),
226 default=_plain_default(param),
227 choices=_click_choices(param),
228 )
229 )
230 unique: dict[str, Option] = {}
231 for option in options:
232 unique.setdefault(option.name, option)
233 positional, lead = _click_positional(arguments)
234 return Verb(
235 name=name.replace("-", "_"),
236 help=_first_line(getattr(command, "help", "") or ""),
237 options=tuple(sorted(unique.values(), key=lambda o: o.name)),
238 positional=positional,
239 lead=lead,
240 )
243def _click_positional(arguments: list[Any]) -> tuple[str, str]:
244 """The positional shape from click's declared arguments.
246 click hands these over as data, so the shape is exact: no arguments
247 means keyword-only, a required first argument means positional-only.
248 """
249 if not arguments:
250 return "none", ""
251 first = arguments[0]
252 variadic = getattr(first, "nargs", 1) == -1
253 if getattr(first, "required", False) and not variadic:
254 return "required", str(getattr(first, "name", "") or "arg")
255 return "any", ""
258def _click_choices(param: Any) -> tuple[str, ...]:
259 """The closed set, when click declares the parameter a `Choice`."""
260 choices = getattr(getattr(param, "type", None), "choices", None)
261 if not choices:
262 return ()
263 return tuple(str(c) for c in choices)
266def _keyword(param: Any) -> str:
267 """The keyword a task writes for this parameter.
269 Not `param.name`: click names a group of mutually exclusive flags after
270 one internal variable, so mkdocs' `--dirty`, `--clean` and
271 `--dirtyreload` are all `build_type` — three parameters with one name.
272 The bridge translates a *keyword* into a *flag*, so the flag's own
273 spelling is the only name that round-trips.
274 """
275 longest = max(
276 (o for o in getattr(param, "opts", ()) if o.startswith("--")),
277 key=len,
278 default="",
279 )
280 stem = longest.removeprefix("--") if longest else str(param.name)
281 return stem.replace("-", "_")
284def _plain_default(param: Any) -> Any:
285 """The default, when it is a plain value worth showing in a stub."""
286 default = getattr(param, "default", None)
287 if isinstance(default, (bool, int, float, str)) or default is None:
288 return default
289 return None # click sentinels and callables say nothing useful here
292def _first_line(text: str) -> str:
293 """The tool's own summary: its help's first sentence-ish line."""
294 for line in text.strip().splitlines():
295 stripped = line.strip()
296 if stripped: 296 ↛ 294line 296 didn't jump to line 294 because the condition on line 296 was always true
297 return stripped
298 return ""