-
Notifications
You must be signed in to change notification settings - Fork 20
Expand file tree
/
Copy pathgenerate-command-doc
More file actions
executable file
·220 lines (170 loc) · 7.23 KB
/
generate-command-doc
File metadata and controls
executable file
·220 lines (170 loc) · 7.23 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
#!/usr/bin/env python3
"""
Generate documentation pages for each Nextstrain CLI command.
Uses the Sphinx ``program`` and ``option`` directives so they can be
cross-referenced. An alternative to using the ``autodoc`` or ``autoprogram``
extensions which dynamically generate rST at Sphinx build time instead.
To generate rST files for all commands::
./devel/generate-command-doc
Files are only written if their contents need updating. The paths of the
updated files are output to stderr.
To instead output (to stdout) the rST for a single command::
./devel/generate-command-doc nextstrain build
This can be useful for development loops.
"""
import os
from argparse import ArgumentParser, SUPPRESS, _SubParsersAction
from contextlib import contextmanager, redirect_stdout
from difflib import diff_bytes, unified_diff
from hashlib import md5
from inspect import cleandoc
from pathlib import Path
from sys import exit, stdout, stderr
from tempfile import TemporaryDirectory
from textwrap import dedent, indent
from typing import Iterable, Tuple, Union
doc = (Path(__file__).resolve().parent.parent / "doc/").relative_to(Path.cwd())
tmp = TemporaryDirectory()
# Force some environment vars before loading any Nextstrain CLI code which may
# inspect them, for reproducible/stable output.
os.environ.update({
# The argparse HelpFormatter cares about terminal size, but we don't want
# that to vary based on where we run this program.
"COLUMNS": "1000",
# Avoid the current user's personal configuration from affecting output.
"NEXTSTRAIN_HOME": tmp.name,
# Ensure we detect a browser for stable `nextstrain view` output.
"BROWSER": "/bin/true",
# Ensure HOST and PORT are stable for `nextstrain view` output.
"HOST": "127.0.0.1",
"PORT": "4000",
})
from nextstrain.cli import make_parser
from nextstrain.cli.argparse import HelpFormatter, walk_commands, OPTIONS_TITLE
from nextstrain.cli.debug import debug
# We generate docs for these and they can be directly accessed, but they're
# unlinked so we must note that to suppress warnings from Sphinx.
hidden = {
"nextstrain",
"nextstrain debugger",
"nextstrain init-shell",
}
argparser = ArgumentParser(
prog = "./devel/generate-command-doc",
usage = "./devel/generate-command-doc [--check] [<command> […]]",
description = __doc__,
formatter_class = HelpFormatter)
argparser.add_argument("command_given", nargs = "*", metavar = "<command>", help = "The single command, given as multiple arguments, for which to generate rST.")
argparser.add_argument("--check", action = "store_true", help = "Only check if any file contents have changed; do not update any files. Exits 1 if there are changes, 0 if not.")
argparser.add_argument("--diff", action = "store_true", help = "Show a diff for files that change (or would change, if --check is also specified).")
def main(*, command_given = None, check = False, diff = False):
if check and command_given:
print("error: --check is only supported when updating files for all commands", file = stderr)
return 1
if diff and command_given:
print("error: --diff is only supported when updating files for all commands", file = stderr)
return 1
check_failed = False
command_given = tuple(command_given)
for command, parser in walk_commands(make_parser()):
if command_given and command != command_given:
continue
page = command_page(command, parser)
path = doc / f"{page}.rst"
debug(f"--> {' '.join(command)}")
debug(f"Generating rST…")
rst = generate_rst(command, parser)
if command_given:
print(rst)
else:
new_rst = rst.encode("utf-8")
old_rst = path.read_bytes() if path.exists() else None
new_md5 = md5(new_rst).hexdigest()
old_md5 = md5(old_rst).hexdigest() if old_rst is not None else "0" * 32
debug(f"Old MD5: {old_md5}")
debug(f"New MD5: {new_md5}")
if old_md5 != new_md5:
if check:
check_failed = True
else:
path.write_bytes(new_rst)
debug(f"wrote {len(new_rst):,} bytes ({new_md5}) to {path}")
print(path, file = stderr)
if diff:
stdout.writelines(
unified_diff(
old_rst.decode("utf-8").splitlines(keepends = True) if old_rst is not None else [],
new_rst.decode("utf-8").splitlines(keepends = True),
str(path),
str(path),
old_md5,
new_md5))
else:
debug(f"{path} unchanged")
return 1 if check and check_failed else 0
def command_page(command: Tuple[str, ...], parser: ArgumentParser) -> str:
return (
"commands/"
+ "/".join(command[1:])
+ ("/index" if parser._subparsers else ""))
def generate_rst(command: Tuple[str, ...], parser: ArgumentParser) -> str:
return "\n".join(chunk or "" for chunk in _generate_rst(command, parser))
def _generate_rst(command: Tuple[str, ...], parser: ArgumentParser) -> Iterable[Union[str, None]]:
program = " ".join(command)
formatter = parser.formatter_class(program)
usage = parser.format_usage()
description = cleandoc(parser.description or "")
epilog = cleandoc(parser.epilog or "")
if program in hidden:
yield ":orphan:"
yield
yield ".. default-role:: literal"
yield
yield ".. role:: command-reference(ref)"
yield
yield f".. program:: {program}"
yield
yield f".. _{program}:"
yield
yield "=" * len(program)
yield program
yield "=" * len(program)
yield
yield ".. code-block:: none"
yield
yield indent(usage, " ")
yield
yield description
yield
for group in parser._action_groups:
if not group._group_actions:
continue
title = group.title if group.title and group.title != OPTIONS_TITLE else "options"
description = cleandoc(group.description or "")
yield title
yield "=" * len(title)
yield
yield description
yield
for action in group._group_actions:
if action.help is SUPPRESS:
continue
if isinstance(action, _SubParsersAction):
for choice in action._choices_actions:
subcommand = choice.dest
subparser = action.choices[subcommand]
subpage = command_page((*command, subcommand), subparser)
yield f".. option:: {subcommand}"
yield
yield indent(f"{choice.help}. See :doc:`/{subpage}`.", " ")
yield
else:
invocation = formatter._format_action_invocation(action)
description = (action.help or "") % {"default": action.default}
yield f".. option:: {invocation}"
yield
yield indent(description, " ")
yield
yield epilog
if __name__ == "__main__":
exit(main(**vars(argparser.parse_args())))