2"""Generate the CLI reference by introspecting the fully assembled argparse parser."""
4from __future__
import annotations
9from pathlib
import Path
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"
26_MACHINE_DEPENDENT_DEFAULTS = {
"workers":
"<local CPU count, capped 1-8>"}
31 @brief Build the real parser, including registrars delegated from other modules.
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
36 @return The assembled argparse parser.
38 sys.path.insert(0, str(REPO_ROOT))
41 return build_main_parser()
46 @brief Escape text for generated HTML.
47 @param[in] text Value to escape.
48 @return Escaped string.
51 str(text).replace(
"&",
"&").replace(
"<",
"<").replace(
">",
">")
57 @brief Capture the user-relevant contract of one argparse action.
58 @param[in] action Parser action.
59 @return Mapping describing the action.
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):
66 default = str(action.default)
68 "flags":
list(action.option_strings),
70 "required": bool(getattr(action,
"required",
False)),
71 "choices": sorted(str(choice)
for choice
in action.choices)
if action.choices
else [],
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)),
85_DEFAULT_GROUP_TITLE_SPELLINGS = frozenset({
"optional arguments",
"options"})
86_CANONICAL_DEFAULT_GROUP_TITLE =
"options"
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.
95 if title
in _DEFAULT_GROUP_TITLE_SPELLINGS:
96 return _CANONICAL_DEFAULT_GROUP_TITLE
97 return title
or "arguments"
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.
106 for action
in parser._actions:
107 if isinstance(action, argparse._SubParsersAction):
108 return dict(action.choices)
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.
120 for group
in parser._action_groups:
123 for action
in group._group_actions
124 if not isinstance(action, argparse._SubParsersAction)
125 and action.dest != argparse.SUPPRESS
131 for child, child_parser
in sorted(
subcommands(parser).items())
135 "help": (parser.description
or "").strip().splitlines()[0]
if parser.description
else "",
137 "subcommands": children,
143 @brief The full normalized parser snapshot.
144 @return Snapshot mapping.
148 "program": parser.prog,
151 for name, sub
in sorted(
subcommands(parser).items())
158 @brief Render one command and its subcommands as HTML.
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.
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}>']
174 out.append(f
"<p>{escape(command['help'])}</p>")
175 for title, actions
in command[
"groups"].items():
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 "—"
189 default = f
"<code>{escape(action['default'])}</code>" if action[
"default"]
else "—"
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>'
199 out.append(
"</table>")
200 for child
in command[
"subcommands"].values():
207 @brief Render the whole reference as an includable HTML fragment.
208 @param[in] data Parser snapshot.
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>",
218 for command
in data[
"commands"].values():
220 return "\n".join(out) +
"\n"
225 @brief Write or verify the generated CLI reference.
226 @return Process status code.
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()
233 payload = json.dumps(data, indent=2, sort_keys=
True) +
"\n"
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
243 print(
"Generated CLI reference is stale:", file=sys.stderr)
245 print(f
" {path}", file=sys.stderr)
246 print(
"\nThe parser changed. Regenerate with: make docs-cli-reference", file=sys.stderr)
248 print(f
"CLI reference is current ({len(data['commands'])} commands).")
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.")
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.