forked from QubesOS/qubes-core-admin
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathdochelpers.py
449 lines (361 loc) · 15.1 KB
/
dochelpers.py
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
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
#
# The Qubes OS Project, https://www.qubes-os.org/
#
# Copyright (C) 2010-2015 Joanna Rutkowska <[email protected]>
# Copyright (C) 2014-2015 Wojtek Porczyk <[email protected]>
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, see <https://www.gnu.org/licenses/>.
#
'''Documentation helpers.
This module contains classes and functions which help to maintain documentation,
particularly our custom Sphinx extension.
'''
import argparse
import io
import json
import os
import re
import urllib.error
import urllib.request
import docutils
import docutils.nodes
import docutils.parsers.rst
import docutils.parsers.rst.roles
import docutils.statemachine
import sphinx
import sphinx.errors
import sphinx.locale
import sphinx.util.docfields
import qubes.tools
SUBCOMMANDS_TITLE = 'COMMANDS'
OPTIONS_TITLE = 'OPTIONS'
class GithubTicket:
# pylint: disable=too-few-public-methods
def __init__(self, data):
self.number = data['number']
self.summary = data['title']
self.uri = data['html_url']
def fetch_ticket_info(app, number):
'''Fetch info about particular trac ticket given
:param app: Sphinx app object
:param str number: number of the ticket, without #
:rtype: mapping
:raises: urllib.error.HTTPError
'''
response = urllib.request.urlopen(urllib.request.Request(
app.config.ticket_base_uri.format(number=number),
headers={
'Accept': 'application/vnd.github.v3+json',
'User-agent': __name__}))
return GithubTicket(json.load(response))
def ticket(name, rawtext, text, lineno, inliner, options=None, content=None):
'''Link to qubes ticket
:param str name: The role name used in the document
:param str rawtext: The entire markup snippet, with role
:param str text: The text marked with the role
:param int lineno: The line number where rawtext appears in the input
:param docutils.parsers.rst.states.Inliner inliner: The inliner instance \
that called this function
:param options: Directive options for customisation
:param content: The directive content for customisation
''' # pylint: disable=unused-argument
if options is None:
options = {}
ticketno = text.lstrip('#')
if not ticketno.isdigit():
msg = inliner.reporter.error(
'Invalid ticket identificator: {!r}'.format(text), line=lineno)
prb = inliner.problematic(rawtext, rawtext, msg)
return [prb], [msg]
try:
info = fetch_ticket_info(inliner.document.settings.env.app, ticketno)
except urllib.error.HTTPError as e:
msg = inliner.reporter.error(
'Error while fetching ticket info: {!s}'.format(e), line=lineno)
prb = inliner.problematic(rawtext, rawtext, msg)
return [prb], [msg]
docutils.parsers.rst.roles.set_classes(options)
node = docutils.nodes.reference(
rawtext,
'#{} ({})'.format(info.number, info.summary),
refuri=info.uri,
**options)
return [node], []
class versioncheck(docutils.nodes.warning):
# pylint: disable=invalid-name
pass
def visit(self, node):
self.visit_admonition(node, 'version')
def depart(self, node):
self.depart_admonition(node)
sphinx.locale.admonitionlabels['version'] = 'Version mismatch'
class VersionCheck(docutils.parsers.rst.Directive):
'''Directive versioncheck
Check if current version (from ``conf.py``) equals version specified as
argument. If not, generate warning.'''
has_content = True
required_arguments = 1
optional_arguments = 0
final_argument_whitespace = True
option_spec = {}
def run(self):
current = self.state.document.settings.env.app.config.version
version = self.arguments[0]
if current == version:
return []
text = ' '.join('''This manual page was written for version **{}**, but
current version at the time when this page was generated is **{}**.
This may or may not mean that page is outdated or has
inconsistencies.'''.format(version, current).split())
node = versioncheck(text)
node['classes'] = ['admonition', 'warning']
self.state.nested_parse(docutils.statemachine.StringList([text]),
self.content_offset, node)
return [node]
def make_rst_section(heading, char):
return '{}\n{}\n\n'.format(heading, char[0] * len(heading))
def prepare_manpage(command):
parser = qubes.tools.get_parser_for_command(command)
stream = io.StringIO()
stream.write('.. program:: {}\n\n'.format(command))
stream.write(make_rst_section(
':program:`{}` -- {}'.format(command, parser.description), '='))
stream.write('''.. warning::
This page was autogenerated from command-line parser. It shouldn't be 1:1
conversion, because it would add little value. Please revise it and add
more descriptive help, which normally won't fit in standard ``--help``
option.
After rewrite, please remove this admonition.\n\n''')
stream.write(make_rst_section('Synopsis', '-'))
usage = ' '.join(parser.format_usage().strip().split())
if usage.startswith('usage: '):
usage = usage[len('usage: '):]
# replace METAVARS with *METAVARS*
usage = re.sub(r'\b([A-Z]{2,})\b', r'*\1*', usage)
stream.write(':command:`{}` {}\n\n'.format(command, usage))
stream.write(make_rst_section('Options', '-'))
for action in parser._actions: # pylint: disable=protected-access
stream.write('.. option:: ')
if action.metavar:
stream.write(', '.join('{}{}{}'.format(
option,
'=' if option.startswith('--') else ' ',
action.metavar)
for option in sorted(action.option_strings)))
else:
stream.write(', '.join(sorted(action.option_strings)))
stream.write('\n\n {}\n\n'.format(action.help))
stream.write(make_rst_section('Authors', '-'))
stream.write('''\
| Joanna Rutkowska <joanna at invisiblethingslab dot com>
| Rafal Wojtczuk <rafal at invisiblethingslab dot com>
| Marek Marczykowski <marmarek at invisiblethingslab dot com>
| Wojtek Porczyk <woju at invisiblethingslab dot com>
.. vim: ts=3 sw=3 et tw=80
''')
return stream.getvalue()
class OptionsCheckVisitor(docutils.nodes.SparseNodeVisitor):
''' Checks if the visited option nodes and the specified args are in sync.
'''
def __init__(self, command, args, document):
assert isinstance(args, set)
docutils.nodes.SparseNodeVisitor.__init__(self, document)
self.command = command
self.args = args
def visit_desc(self, node):
''' Skips all but 'option' elements '''
# pylint: disable=no-self-use
if not node.get('desctype', None) == 'option':
raise docutils.nodes.SkipChildren
def visit_desc_name(self, node):
''' Checks if the option is defined `self.args` '''
if not isinstance(node[0], docutils.nodes.Text):
raise sphinx.errors.SphinxError('first child should be Text')
arg = str(node[0])
try:
self.args.remove(arg)
except KeyError:
raise sphinx.errors.SphinxError(
'No such argument for {!r}: {!r}'.format(self.command, arg))
def check_undocumented_arguments(self, ignored_options=None):
''' Call this to check if any undocumented arguments are left.
While the documentation talks about a
'SparseNodeVisitor.depart_document()' function, this function does
not exists. (For details see implementation of
:py:meth:`NodeVisitor.dispatch_departure()`) So we need to
manually call this.
'''
if ignored_options is None:
ignored_options = set()
left_over_args = self.args - ignored_options
if left_over_args:
raise sphinx.errors.SphinxError(
'Undocumented arguments for command {!r}: {!r}'.format(
self.command, ', '.join(sorted(left_over_args))))
class CommandCheckVisitor(docutils.nodes.SparseNodeVisitor):
''' Checks if the visited sub command section nodes and the specified sub
command args are in sync.
'''
def __init__(self, command, sub_commands, document):
docutils.nodes.SparseNodeVisitor.__init__(self, document)
self.command = command
self.sub_commands = sub_commands
def visit_section(self, node):
''' Checks if the visited sub-command section nodes exists and it
options are in sync.
Uses :py:class:`OptionsCheckVisitor` for checking
sub-commands options
'''
# pylint: disable=no-self-use
title = str(node[0][0])
if title.upper() == SUBCOMMANDS_TITLE:
return
sub_cmd = self.command + ' ' + title
try:
args = self.sub_commands[title]
options_visitor = OptionsCheckVisitor(sub_cmd, args, self.document)
node.walkabout(options_visitor)
options_visitor.check_undocumented_arguments(
{'--help', '--quiet', '--verbose', '-h', '-q', '-v'})
del self.sub_commands[title]
except KeyError:
raise sphinx.errors.SphinxError(
'No such sub-command {!r}'.format(sub_cmd))
def visit_Text(self, node):
''' If the visited text node starts with 'alias: ', all the provided
comma separted alias in this node, are removed from
`self.sub_commands`
'''
# pylint: disable=invalid-name
text = str(node).strip()
if text.startswith('aliases:'):
aliases = {a.strip() for a in text.split('aliases:')[1].split(',')}
for alias in aliases:
assert alias in self.sub_commands
del self.sub_commands[alias]
def check_undocumented_sub_commands(self):
''' Call this to check if any undocumented sub_commands are left.
While the documentation talks about a
'SparseNodeVisitor.depart_document()' function, this function does
not exists. (For details see implementation of
:py:meth:`NodeVisitor.dispatch_departure()`) So we need to
manually call this.
'''
if self.sub_commands:
raise sphinx.errors.SphinxError(
'Undocumented commands for {!r}: {!r}'.format(
self.command, ', '.join(sorted(self.sub_commands.keys()))))
class ManpageCheckVisitor(docutils.nodes.SparseNodeVisitor):
''' Checks if the sub-commands and options specified in the 'COMMAND' and
'OPTIONS' (case insensitve) sections in sync the command parser.
'''
def __init__(self, app, command, document):
docutils.nodes.SparseNodeVisitor.__init__(self, document)
try:
parser = qubes.tools.get_parser_for_command(command)
except ImportError:
app.warn('cannot import module for command')
self.parser = None
return
except AttributeError:
raise sphinx.errors.SphinxError('cannot find parser in module')
self.command = command
self.parser = parser
self.options = set()
self.sub_commands = {}
self.app = app
# pylint: disable=protected-access
for action in parser._actions:
if action.help == argparse.SUPPRESS:
continue
if issubclass(action.__class__,
qubes.tools.AliasedSubParsersAction):
for cmd, cmd_parser in action._name_parser_map.items():
self.sub_commands[cmd] = set()
for sub_action in cmd_parser._actions:
if sub_action.help != argparse.SUPPRESS:
self.sub_commands[cmd].update(
sub_action.option_strings)
else:
self.options.update(action.option_strings)
def visit_section(self, node):
''' If section title is OPTIONS or COMMANDS dispatch the apropriate
`NodeVisitor`.
'''
if self.parser is None:
return
section_title = str(node[0][0]).upper()
if section_title == OPTIONS_TITLE:
options_visitor = OptionsCheckVisitor(self.command, self.options,
self.document)
node.walkabout(options_visitor)
options_visitor.check_undocumented_arguments()
elif section_title == SUBCOMMANDS_TITLE:
sub_cmd_visitor = CommandCheckVisitor(
self.command, self.sub_commands, self.document)
node.walkabout(sub_cmd_visitor)
sub_cmd_visitor.check_undocumented_sub_commands()
def check_man_args(app, doctree, docname):
''' Checks the manpage for undocumented or obsolete sub-commands and
options.
'''
dirname, command = os.path.split(docname)
if os.path.basename(dirname) != 'manpages':
return
app.info('Checking arguments for {!r}'.format(command))
doctree.walk(ManpageCheckVisitor(app, command, doctree))
#
# this is lifted from sphinx' own conf.py
#
event_sig_re = re.compile(r'([a-zA-Z-:<>]+)\s*\((.*)\)')
def parse_event(env, sig, signode):
# pylint: disable=unused-argument
m = event_sig_re.match(sig)
if not m:
signode += sphinx.addnodes.desc_name(sig, sig)
return sig
name, args = m.groups()
signode += sphinx.addnodes.desc_name(name, name)
plist = sphinx.addnodes.desc_parameterlist()
for arg in args.split(','):
arg = arg.strip()
plist += sphinx.addnodes.desc_parameter(arg, arg)
signode += plist
return name
#
# end of codelifting
#
def break_to_pdb(app, *_dummy):
if not app.config.break_to_pdb:
return
import pdb
pdb.set_trace()
def setup(app):
app.add_role('ticket', ticket)
app.add_config_value(
'ticket_base_uri',
'https://api.github.com/repos/QubesOS/qubes-issues/issues/{number}',
'env')
app.add_config_value('break_to_pdb', False, 'env')
app.add_node(versioncheck,
html=(visit, depart),
man=(visit, depart))
app.add_directive('versioncheck', VersionCheck)
fdesc = sphinx.util.docfields.GroupedField('parameter', label='Parameters',
names=['param'], can_collapse=True)
app.add_object_type('event', 'event', 'pair: %s; event', parse_event,
doc_field_types=[fdesc])
app.connect('doctree-resolved', break_to_pdb)
app.connect('doctree-resolved', check_man_args)
# vim: ts=4 sw=4 et