PICurv 0.1.0
A Parallel Particle-In-Cell Solver for Curvilinear LES
 
Loading...
Searching...
No Matches
generate_cli_reference.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""Generate the CLI reference by introspecting the fully assembled argparse parser."""
3
4from __future__ import annotations
5
6import argparse
7import json
8import sys
9from pathlib import Path
10
11
12REPO_ROOT = Path(__file__).resolve().parents[2]
13GENERATED_DIR = REPO_ROOT / "docs" / "generated"
14HTML_PATH = GENERATED_DIR / "cli_reference.html"
15JSON_PATH = GENERATED_DIR / "cli_reference.json"
16
17
18#: Defaults an action bakes into the parser at build time by reading the local machine
19#: (CPU count, and anything with the same shape added later), keyed by the dest name
20#: that carries them. A number computed this way is only ever right for the machine that
21#: happened to build the parser: the committed reference would otherwise assert a
22#: worker count that is correct nowhere but the machine that last regenerated it, and
23#: `--check` would disagree with every other machine forever, including CI's runner and
24#: a real user's. Rendered symbolically instead, so the file states the *rule* the CLI
25#: actually applies rather than one instance of applying it.
26_MACHINE_DEPENDENT_DEFAULTS = {"workers": "<local CPU count, capped 1-8>"}
27
28
30 """!
31 @brief Build the real parser, including registrars delegated from other modules.
32
33 @details Regex over `cli.py` would miss `add_storage_parser()`, which contributes a
34 whole top-level command from `storage.py`. Only the assembled parser knows
35 the true command set.
36 @return The assembled argparse parser.
37 """
38 sys.path.insert(0, str(REPO_ROOT))
39 from picurv_cli.cli import build_main_parser
40
41 return build_main_parser()
42
43
44def escape(text) -> str:
45 """!
46 @brief Escape text for generated HTML.
47 @param[in] text Value to escape.
48 @return Escaped string.
49 """
50 return (
51 str(text).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
52 )
53
54
55def describe_action(action) -> dict:
56 """!
57 @brief Capture the user-relevant contract of one argparse action.
58 @param[in] action Parser action.
59 @return Mapping describing the action.
60 """
61 if action.dest in _MACHINE_DEPENDENT_DEFAULTS and action.default not in (None, argparse.SUPPRESS):
62 default = _MACHINE_DEPENDENT_DEFAULTS[action.dest]
63 elif action.default in (None, argparse.SUPPRESS):
64 default = None
65 else:
66 default = str(action.default)
67 return {
68 "flags": list(action.option_strings),
69 "dest": action.dest,
70 "required": bool(getattr(action, "required", False)),
71 "choices": sorted(str(choice) for choice in action.choices) if action.choices else [],
72 "default": default,
73 "nargs": None if action.nargs is None else str(action.nargs),
74 "help": (action.help or "").strip(),
75 "is_flag": isinstance(action, (argparse._StoreTrueAction, argparse._StoreFalseAction)),
76 }
77
78
79#: argparse's own title for the group of options nobody explicitly grouped. Python
80#: renamed it from "optional arguments" to "options" in 3.10 (bpo-9694); a group's
81#: title is otherwise a literal string this repo wrote, so only this one default
82#: needs normalizing. Left un-normalized, the generated reference would flip between
83#: spellings depending on which Python happened to run the generator, making `--check`
84#: fail on any interpreter that disagrees with whichever one last regenerated the file.
85_DEFAULT_GROUP_TITLE_SPELLINGS = frozenset({"optional arguments", "options"})
86_CANONICAL_DEFAULT_GROUP_TITLE = "options"
87
88
89def _normalize_group_title(title) -> str:
90 """!
91 @brief Resolve one argparse group title to a Python-version-independent form.
92 @param[in] title Raw `group.title` from argparse, or None for the positional group.
93 @return Canonical title.
94 """
95 if title in _DEFAULT_GROUP_TITLE_SPELLINGS:
96 return _CANONICAL_DEFAULT_GROUP_TITLE
97 return title or "arguments"
98
99
100def subcommands(parser) -> dict:
101 """!
102 @brief The subparser map of a parser, if it has one.
103 @param[in] parser Parser to inspect.
104 @return Mapping of command name to subparser.
105 """
106 for action in parser._actions:
107 if isinstance(action, argparse._SubParsersAction):
108 return dict(action.choices)
109 return {}
110
111
112def describe_parser(name: str, parser) -> dict:
113 """!
114 @brief Recursively capture a command and its subcommands.
115 @param[in] name Command name.
116 @param[in] parser Parser for that command.
117 @return Nested command description.
118 """
119 groups: dict = {}
120 for group in parser._action_groups:
121 actions = [
122 describe_action(action)
123 for action in group._group_actions
124 if not isinstance(action, argparse._SubParsersAction)
125 and action.dest != argparse.SUPPRESS
126 ]
127 if actions:
128 groups.setdefault(_normalize_group_title(group.title), []).extend(actions)
129 children = {
130 child: describe_parser(child, child_parser)
131 for child, child_parser in sorted(subcommands(parser).items())
132 }
133 return {
134 "name": name,
135 "help": (parser.description or "").strip().splitlines()[0] if parser.description else "",
136 "groups": groups,
137 "subcommands": children,
138 }
139
140
141def snapshot() -> dict:
142 """!
143 @brief The full normalized parser snapshot.
144 @return Snapshot mapping.
145 """
146 parser = build_parser()
147 return {
148 "program": parser.prog,
149 "commands": {
150 name: describe_parser(name, sub)
151 for name, sub in sorted(subcommands(parser).items())
152 },
153 }
154
155
156def render_command(command: dict, depth: int = 0, prefix: str = "") -> list:
157 """!
158 @brief Render one command and its subcommands as HTML.
159
160 @details A subcommand heading carries its full invocation path. Rendering `status`
161 on its own would sit indistinguishably beside the top-level
162 `status-source`, and a reader scanning headings could not tell which
163 command a flag table belongs to.
164 @param[in] command Command description.
165 @param[in] depth Nesting depth.
166 @param[in] prefix Invocation path of the parent command, empty at the top level.
167 @return HTML lines.
168 """
169 heading = "h3" if depth == 0 else "h4"
170 invocation = f"{prefix}{command['name']}"
171 slug = invocation.replace("-", "_").replace(" ", "_")
172 out = [f'<{heading} id="cli_{slug}"><code>picurv {escape(invocation)}</code></{heading}>']
173 if command["help"]:
174 out.append(f"<p>{escape(command['help'])}</p>")
175 for title, actions in command["groups"].items():
176 if not actions:
177 continue
178 out.append(f"<p><b>{escape(title)}</b></p>")
179 out.append('<table class="markdownTable"><tr>'
180 '<th class="markdownTableHeadNone">Flag</th>'
181 '<th class="markdownTableHeadNone">Required</th>'
182 '<th class="markdownTableHeadNone">Choices</th>'
183 '<th class="markdownTableHeadNone">Default</th>'
184 '<th class="markdownTableHeadNone">Description</th></tr>')
185 for action in actions:
186 flags = ", ".join(f"<code>{escape(flag)}</code>" for flag in action["flags"]) or \
187 f"<code>{escape(action['dest'])}</code> (positional)"
188 choices = ", ".join(f"<code>{escape(c)}</code>" for c in action["choices"]) or "&#8212;"
189 default = f"<code>{escape(action['default'])}</code>" if action["default"] else "&#8212;"
190 out.append(
191 "<tr>"
192 f'<td class="markdownTableBodyNone">{flags}</td>'
193 f'<td class="markdownTableBodyNone">{"yes" if action["required"] else "no"}</td>'
194 f'<td class="markdownTableBodyNone">{choices}</td>'
195 f'<td class="markdownTableBodyNone">{default}</td>'
196 f'<td class="markdownTableBodyNone">{escape(action["help"])}</td>'
197 "</tr>"
198 )
199 out.append("</table>")
200 for child in command["subcommands"].values():
201 out += render_command(child, depth + 1, f"{invocation} ")
202 return out
203
204
205def render(data: dict) -> str:
206 """!
207 @brief Render the whole reference as an includable HTML fragment.
208 @param[in] data Parser snapshot.
209 @return HTML text.
210 """
211 out = [
212 "<!-- GENERATED FILE - do not edit by hand.",
213 " Source of truth: the assembled argparse parser (picurv_cli.cli.build_main_parser),",
214 " including registrars delegated from other modules such as storage.py.",
215 " Regenerate with: make docs-cli-reference -->",
216 f"<p>{len(data['commands'])} top-level commands.</p>",
217 ]
218 for command in data["commands"].values():
219 out += render_command(command)
220 return "\n".join(out) + "\n"
221
222
223def main() -> int:
224 """!
225 @brief Write or verify the generated CLI reference.
226 @return Process status code.
227 """
228 parser = argparse.ArgumentParser(description="Generate the CLI reference from the live parser.")
229 parser.add_argument("--check", action="store_true", help="Fail if the generated output is stale.")
230 args = parser.parse_args()
231
232 data = snapshot()
233 payload = json.dumps(data, indent=2, sort_keys=True) + "\n"
234 html = render(data)
235
236 if args.check:
237 stale = [
238 path.relative_to(REPO_ROOT)
239 for path, content in ((JSON_PATH, payload), (HTML_PATH, html))
240 if not path.is_file() or path.read_text(encoding="utf-8") != content
241 ]
242 if stale:
243 print("Generated CLI reference is stale:", file=sys.stderr)
244 for path in stale:
245 print(f" {path}", file=sys.stderr)
246 print("\nThe parser changed. Regenerate with: make docs-cli-reference", file=sys.stderr)
247 return 1
248 print(f"CLI reference is current ({len(data['commands'])} commands).")
249 return 0
250
251 GENERATED_DIR.mkdir(parents=True, exist_ok=True)
252 JSON_PATH.write_text(payload, encoding="utf-8")
253 HTML_PATH.write_text(html, encoding="utf-8")
254 total = sum(1 for _ in data["commands"])
255 subs = sum(len(c["subcommands"]) for c in data["commands"].values())
256 print(f"Wrote CLI reference: {total} commands, {subs} subcommands.")
257 return 0
258
259
260if __name__ == "__main__":
261 raise SystemExit(main())
str render(dict data)
Render the whole reference as an includable HTML fragment.
dict snapshot()
The full normalized parser snapshot.
int main()
Write or verify the generated CLI reference.
dict describe_action(action)
Capture the user-relevant contract of one argparse action.
list render_command(dict command, int depth=0, str prefix="")
Render one command and its subcommands as HTML.
dict subcommands(parser)
The subparser map of a parser, if it has one.
str escape(text)
Escape text for generated HTML.
build_parser()
Build the real parser, including registrars delegated from other modules.
dict describe_parser(str name, parser)
Recursively capture a command and its subcommands.
str _normalize_group_title(title)
Resolve one argparse group title to a Python-version-independent form.
Head of a generic C-style linked list.
Definition variables.h:475