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

1"""What a command-line tool says about itself — extracted, not transcribed. 

2 

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. 

7 

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. 

13 

14Extraction, richest first: 

15 

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. 

23 

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

28 

29from __future__ import annotations 

30 

31from dataclasses import dataclass, field 

32from typing import Any 

33 

34 

35@dataclass(frozen=True) 

36class Option: 

37 """One option of one tool verb, as the tool describes it.""" 

38 

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

55 

56 

57@dataclass(frozen=True) 

58class Verb: 

59 """A subcommand: `mkdocs build`, `ruff check`.""" 

60 

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 `*`: 

73 

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

84 

85 

86@dataclass(frozen=True) 

87class ToolSpec: 

88 """Everything footman knows about one tool, from the tool itself.""" 

89 

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

98 

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. 

102 

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 

115 

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) 

121 

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

125 

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 

144 

145 

146_COLOR_KEYWORDS = frozenset({"color", "colour", "colors", "colours"}) 

147 

148 

149def _color_option(option: Option) -> tuple[str, str, str] | None: 

150 """`(flag, on, off)` if *option* is a `--color`-style switch, else None. 

151 

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) 

164 

165 

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 

182 

183 

184def from_click(command: Any, *, name: str = "", version: str = "") -> ToolSpec: 

185 """A `ToolSpec` from a click `Group` or `Command`. 

186 

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 ) 

207 

208 

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 ) 

241 

242 

243def _click_positional(arguments: list[Any]) -> tuple[str, str]: 

244 """The positional shape from click's declared arguments. 

245 

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

256 

257 

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) 

264 

265 

266def _keyword(param: Any) -> str: 

267 """The keyword a task writes for this parameter. 

268 

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("-", "_") 

282 

283 

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 

290 

291 

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