Coverage for src/footman/_stubgen.py: 100%

157 statements  

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

1"""Render a `ToolSpec` as the stub a type checker reads. 

2 

3The bridge in `tools.py` translates keyword arguments mechanically and will 

4happily pass a flag no stub has heard of — that is what keeps it from going 

5stale. The stub is the other half: it tells an IDE what the installed tool 

6*currently* accepts, so the flags complete and their help text is right 

7there on hover, without importing the tool to find out. 

8 

9Three things make a generated stub better than a hand-written one: 

10 

11* it says what the tool says, because it was read from the tool; 

12* it can be regenerated, so drift is a diff rather than an archaeology 

13 exercise (`fm footman tools audit` is that diff, run in CI); 

14* it can carry the tool's own prose per flag, including the one fact a 

15 bridge can never infer — how *this* tool spells "off". 

16 

17Every generated verb still ends in `**flags: Any`, so a stub that lags the 

18installed tool degrades a hint and never a run. 

19""" 

20 

21from __future__ import annotations 

22 

23import re 

24import textwrap 

25from collections.abc import Iterable 

26 

27from footman._toolspec import Option, ToolSpec, Verb 

28 

29_HEADER = """\ 

30# Generated by `fm footman tools sync` — do not edit by hand. 

31# 

32# Read from {name} {version} on {platform}. In-process: {in_process}. 

33# Every verb ends in `**flags: Any`: the stub suggests what this tool 

34# accepts, and can never forbid what the bridge would happily pass. 

35{imports} 

36""" 

37 

38 

39def render( 

40 spec: ToolSpec, 

41 *, 

42 platform: str = "", 

43 class_name: str = "", 

44 in_process: str = "", 

45) -> str: 

46 """The full text of `_stubs/<tool>.pyi` for *spec*.""" 

47 root = class_name or _class_name(spec.name) 

48 tree = _tree(spec.verbs) 

49 classes = _classes(tree, root) 

50 header = _HEADER.format( 

51 name=spec.name, 

52 version=spec.version or "an unpinned version", 

53 platform=platform or "this machine", 

54 in_process=in_process or ("available" if spec.in_process else "no"), 

55 imports=_imports("\n".join(classes)), 

56 ) 

57 return "\n".join([header, *classes]) 

58 

59 

60def _imports(body: str) -> str: 

61 """Just the imports the body uses — an unused one fails the lint gate.""" 

62 typing = ["Any"] + (["Literal"] if "Literal[" in body else []) 

63 aliases = ("_Flag", "_Value", "_ValuedFlag") 

64 # `Result` is always the return type of every generated call, so always imported. 

65 names = ["Result", "Tool"] + [n for n in aliases if re.search(rf"\b{n}\b", body)] 

66 lines = [] 

67 if "Sequence[" in body: 

68 lines.append("from collections.abc import Sequence") 

69 lines.append(f"from typing import {', '.join(typing)}") 

70 lines.append("") 

71 lines.append(f"from footman.tools import {', '.join(names)}") 

72 return "\n".join(lines) 

73 

74 

75def _class_name(name: str) -> str: 

76 """`markdownlint-cli2` → `Markdownlint`. Public: these classes are the 

77 typed surface of `tools.<name>`, used (only in type position — they are 

78 stub-only, never imported at run time) as if they were public.""" 

79 return "".join(part.title() for part in name.replace("-", "_").split("_")) 

80 

81 

82def _tree(verbs: Iterable[Verb]) -> dict[str, object]: 

83 """Nest dotted verb names: `compose.up` hangs under `compose`.""" 

84 tree: dict[str, object] = {} 

85 for verb in verbs: 

86 node = tree 

87 parts = verb.name.split(".") if verb.name else [] 

88 for part in parts[:-1]: 

89 child = node.setdefault(part, {}) 

90 node = child if isinstance(child, dict) else {} 

91 node[parts[-1] if parts else ""] = verb 

92 return tree 

93 

94 

95def _classes(tree: dict[str, object], name: str) -> list[str]: 

96 """This class, plus one for every subgroup below it, deepest first.""" 

97 out: list[str] = [] 

98 body: list[str] = [] 

99 for key in sorted(tree): 

100 node = tree[key] 

101 if isinstance(node, dict): 

102 child = f"{name}{key.title().replace('_', '')}" 

103 out.extend(_classes(node, child)) 

104 body.append(f" {key}: {child}") 

105 for key in sorted(tree): 

106 node = tree[key] 

107 if isinstance(node, Verb): 

108 body.append(_method(node, key)) 

109 # A tool with subcommands gets a typed `.flags()` returning its own class, 

110 # so a globals-before-verb chain stays checked. The root verb's options 

111 # are the typed globals (git's `--git-dir`, docker's `--host`); a tool 

112 # with none still gets the self-returning override, for the chaining. 

113 # (footman run-control rides the inherited `.opts()`, typed on Tool.) 

114 if _has_subcommands(tree): 

115 root = tree.get("") 

116 globals_ = root.options if isinstance(root, Verb) else () 

117 body.append(_flags_method(globals_, name)) 

118 out.append(f"class {name}(Tool):\n" + ("\n".join(body) or " ...")) 

119 return out 

120 

121 

122def _has_subcommands(tree: dict[str, object]) -> bool: 

123 """Whether this class has verbs to precede — the only case `.flags()` 

124 means anything (a global belongs *before* a subcommand).""" 

125 return any(key != "" for key in tree) 

126 

127 

128def _flags_method(options: tuple[Option, ...], class_name: str) -> str: 

129 """The typed `flags()` for a tool's global options — returns the tool, 

130 so `tools.docker.flags(host=…).compose.up(…)` stays checked. With no 

131 globals it is still declared, so the return type carries the chain. 

132 (footman run-control — nofail/capture/… — goes on the inherited `.opts()`.)""" 

133 lines = [" def flags(", " self,"] 

134 typed = _unique(options) 

135 if typed: # a `*` separator with only `**flags` after it is a syntax error 

136 lines.append(" *,") 

137 for option in typed: 

138 lines.append(f" {_safe(option.name)}: {_annotation(option)} = ...,") 

139 lines.append(" **flags: Any,") 

140 lines.append(f" ) -> {class_name}:") 

141 lines.append(' """Bind tool-level global options before the subcommand.') 

142 lines.append("") 

143 lines.append(" `tools.docker.flags(host=...)` puts a tool's own") 

144 lines.append(' options ahead of the verb, where they belong."""') 

145 lines.append(" ...") 

146 return "\n".join(lines) 

147 

148 

149def _method(verb: Verb, key: str) -> str: 

150 """One verb as a stub method — or `__call__` for a tool's own flags. 

151 

152 footman run-control (nofail/capture/title/in_process) lives on the inherited 

153 `.opts()`, never the call, so a call signature is pure flags — which also 

154 means an option literally named `capture` (pytest's) types through here.""" 

155 name = key or "__call__" 

156 override = " # type: ignore[override]" if name == "__call__" else "" 

157 header = f" def {name}({override}" if override else f" def {name}(" 

158 positional = _positional_lines(verb) 

159 options = _unique(verb.options) 

160 # A bare `*,` must be followed by a keyword-only parameter; when the only 

161 # thing after it would be `**flags`, drop it — `def f(self, **flags)` already 

162 # forbids a positional. (Previously the nofail/in_process params backfilled 

163 # the `*,`; those moved to `.opts()`.) 

164 if positional == [" *,"] and not options: 

165 positional = [] 

166 lines = [header, " self,", *positional] 

167 for option in options: 

168 lines.append(f" {_safe(option.name)}: {_annotation(option)} = ...,") 

169 lines.append(" **flags: Any,") 

170 lines.append(" ) -> Result:") 

171 doc = _docstring(verb) 

172 if doc: 

173 lines.append(doc) 

174 lines.append(" ...") 

175 return "\n".join(lines) 

176 

177 

178def _positional_lines(verb: Verb) -> list[str]: 

179 """The positional part of a verb's signature, from its usage shape. 

180 

181 `"none"` → keyword-only (`*,`), so a stray positional is a type error; 

182 `"required"` → a positional-only leading argument, so passing it by 

183 keyword is one too; `"any"` → `*args`, the conservative default that 

184 forbids nothing. 

185 """ 

186 if verb.positional == "none": 

187 return [" *,"] 

188 if verb.positional == "required": 

189 lead = _safe(verb.lead) if verb.lead else "arg" 

190 # If the leading positional's name also names an option, the two 

191 # would collide into one parameter. Can't spell both, so don't 

192 # constrain: fall back to `*args`. 

193 if lead in {_safe(o.name) for o in verb.options}: 

194 return [" *args: str,"] 

195 return [f" {lead}: str,", " /,", " *args: str,"] 

196 return [" *args: str,"] 

197 

198 

199# The method's own structural parameters. An option named the same (git 

200# rev-parse has `--flags`) would be a duplicate parameter — it still works, 

201# swallowed by `**flags` at type-check and `**kwargs` at run time, just not 

202# typed. `nofail`/`in_process` are no longer here — they moved to `.opts()`, so 

203# a tool that really has a `--nofail` flag now types through the call. 

204_RESERVED = frozenset({"self", "args", "flags"}) 

205 

206 

207def _unique(options: tuple[Option, ...]) -> list[Option]: 

208 """One parameter per keyword — a repeat would be a syntax error, and a 

209 name that clashes with a fixed parameter is dropped to the catch-all.""" 

210 seen: dict[str, Option] = {} 

211 for option in options: 

212 safe = _safe(option.name) 

213 if safe not in _RESERVED: 

214 seen.setdefault(safe, option) 

215 return list(seen.values()) 

216 

217 

218def _safe(name: str) -> str: 

219 """A flag whose name is a Python keyword takes footman's trailing `_`.""" 

220 import keyword 

221 

222 return f"{name}_" if keyword.iskeyword(name) else name 

223 

224 

225def _esc(text: str) -> str: 

226 r"""Double every backslash: a help string like mypy's `--exclude '\.py$'` 

227 must land as a literal in the generated docstring, not an invalid `\.` 

228 escape sequence that a compiler warns on.""" 

229 return text.replace("\\", "\\\\") 

230 

231 

232def _annotation(option: Option) -> str: 

233 """The stub's declared type for one option. 

234 

235 Deliberately wide, because the stub's contract is to suggest and never 

236 to forbid. `_Value` takes a sequence as well as a scalar for *every* 

237 option, since the bridge repeats a flag for each item and whether the 

238 tool accepts repetition is the tool's business, not the stub's — 

239 `select=["E", "F"]` works, so it must type-check. 

240 

241 A closed set of values is the one place a narrow type earns itself: a 

242 `Literal` makes the IDE offer the values, and the tool would reject a 

243 non-member anyway. 

244 """ 

245 if option.type_name == "bool": 

246 return "_Flag" 

247 if option.type_name == "optvalue": 

248 return "_ValuedFlag" # usable bare or with a value — `--gpg-sign[=<key>]` 

249 if option.choices: 

250 literal = "Literal[" + ", ".join(repr(c) for c in option.choices) + "]" 

251 return f"{literal} | Sequence[{literal}] | None" 

252 return "_Value" 

253 

254 

255def _docstring(verb: Verb) -> str: 

256 """The verb's own help, plus one `Args:` entry per flag. 

257 

258 Google-style because two readers want it: Pylance surfaces a 

259 parameter's line on hover and in the completion popup, and griffe reads 

260 the same section to build the reference page — so the tool's help text 

261 is written once and lands in both. 

262 """ 

263 documented = [o for o in _unique(verb.options) if o.help or o.negation] 

264 if not verb.help and not documented: 

265 return "" 

266 summary = textwrap.wrap( 

267 _esc(verb.help) or "Run this verb.", 

268 width=84, 

269 initial_indent=" " * 8, 

270 subsequent_indent=" " * 8, 

271 break_long_words=False, 

272 break_on_hyphens=False, 

273 ) or [" " * 8 + "Run this verb."] 

274 summary = _md_safe(summary) 

275 summary[0] = ' """' + summary[0].lstrip() 

276 lines = list(summary) 

277 if documented: 

278 lines.append("") 

279 lines.append(" Args:") 

280 for option in documented: 

281 lines.extend(_arg_lines(option)) 

282 lines.append(' """') 

283 return "\n".join(lines) 

284 

285 

286# A wrapped docstring line whose first non-space character opens a Markdown 

287# block renders as that block in the reference page (griffe reads these). 

288# Python-Markdown is lenient: `#2` (git's merge-stage notation) is a header 

289# and `>x` a blockquote, no space required. Both escape as `\#` / `\>`. 

290_LEADING_MD = re.compile(r"^(#|>)") 

291 

292 

293def _md_safe(lines: list[str]) -> list[str]: 

294 """Escape a Markdown block marker a wrap left at a line's start. 

295 

296 The backslash is doubled: this text becomes a docstring in the 

297 generated `.pyi`, where a lone `\\#` would be an invalid escape 

298 sequence — `\\\\#` is the literal `\\#` that Markdown renders as `#`. 

299 """ 

300 out: list[str] = [] 

301 for line in lines: 

302 indent = line[: len(line) - len(line.lstrip())] 

303 content = line[len(indent) :] 

304 out.append(f"{indent}\\\\{content}" if _LEADING_MD.match(content) else line) 

305 return out 

306 

307 

308def _arg_lines(option: Option) -> list[str]: 

309 """One `Args:` entry, wrapped, with the `off` spelling when it matters.""" 

310 text = _esc(option.help).rstrip(".") 

311 if option.type_name.startswith("list[") or option.type_name.endswith("[]"): 

312 text = f"{text}. May be repeated: a list emits the flag once per item" 

313 if option.type_name == "optvalue": 

314 text = f"{text}. Value optional: `True` for the bare flag, or pass one" 

315 if option.negation: 

316 default_on = option.default is True 

317 lead = "Defaults on — " if default_on else "" 

318 text = f"{text}. {lead}`{option.name}=off` emits `{option.negation}`" 

319 elif option.default not in (None, "", False): 

320 text = f"{text}. Defaults to `{option.default}`" 

321 wrapped = textwrap.wrap( 

322 f"{_safe(option.name)}: {text}.".replace(" ", " "), 

323 width=84, 

324 initial_indent=" " * 12, 

325 subsequent_indent=" " * 16, 

326 break_long_words=False, 

327 break_on_hyphens=False, 

328 ) 

329 return _md_safe(wrapped) or [f"{' ' * 12}{_safe(option.name)}: ."]