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
« 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.
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.
9Three things make a generated stub better than a hand-written one:
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".
17Every generated verb still ends in `**flags: Any`, so a stub that lags the
18installed tool degrades a hint and never a run.
19"""
21from __future__ import annotations
23import re
24import textwrap
25from collections.abc import Iterable
27from footman._toolspec import Option, ToolSpec, Verb
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"""
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])
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)
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("_"))
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
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
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)
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)
149def _method(verb: Verb, key: str) -> str:
150 """One verb as a stub method — or `__call__` for a tool's own flags.
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)
178def _positional_lines(verb: Verb) -> list[str]:
179 """The positional part of a verb's signature, from its usage shape.
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,"]
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"})
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())
218def _safe(name: str) -> str:
219 """A flag whose name is a Python keyword takes footman's trailing `_`."""
220 import keyword
222 return f"{name}_" if keyword.iskeyword(name) else name
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("\\", "\\\\")
232def _annotation(option: Option) -> str:
233 """The stub's declared type for one option.
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.
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"
255def _docstring(verb: Verb) -> str:
256 """The verb's own help, plus one `Args:` entry per flag.
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)
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"^(#|>)")
293def _md_safe(lines: list[str]) -> list[str]:
294 """Escape a Markdown block marker a wrap left at a line's start.
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
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)}: ."]