dochelpers.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443
  1. #!/usr/bin/python2 -O
  2. # vim: fileencoding=utf-8
  3. #
  4. # The Qubes OS Project, https://www.qubes-os.org/
  5. #
  6. # Copyright (C) 2010-2015 Joanna Rutkowska <joanna@invisiblethingslab.com>
  7. # Copyright (C) 2014-2015 Wojtek Porczyk <woju@invisiblethingslab.com>
  8. #
  9. # This program is free software; you can redistribute it and/or modify
  10. # it under the terms of the GNU General Public License as published by
  11. # the Free Software Foundation; either version 2 of the License, or
  12. # (at your option) any later version.
  13. #
  14. # This program is distributed in the hope that it will be useful,
  15. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  16. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  17. # GNU General Public License for more details.
  18. #
  19. # You should have received a copy of the GNU General Public License along
  20. # with this program; if not, write to the Free Software Foundation, Inc.,
  21. # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
  22. #
  23. '''Documentation helpers.
  24. This module contains classes and functions which help to maintain documentation,
  25. particularly our custom Sphinx extension.
  26. '''
  27. import argparse
  28. import csv
  29. import os
  30. import posixpath
  31. import re
  32. import StringIO
  33. import urllib2
  34. import docutils
  35. import docutils.nodes
  36. import docutils.parsers.rst
  37. import docutils.parsers.rst.roles
  38. import docutils.statemachine
  39. import sphinx
  40. import sphinx.errors
  41. import sphinx.locale
  42. import sphinx.util.docfields
  43. import qubes.tools
  44. SUBCOMMANDS_TITLE = 'COMMANDS'
  45. OPTIONS_TITLE = 'OPTIONS'
  46. def fetch_ticket_info(uri):
  47. '''Fetch info about particular trac ticket given
  48. :param str uri: URI at which ticket resides
  49. :rtype: mapping
  50. :raises: urllib2.HTTPError
  51. '''
  52. data = urllib2.urlopen(uri + '?format=csv').read()
  53. reader = csv.reader((line + '\n' for line in data.split('\r\n')),
  54. quoting=csv.QUOTE_MINIMAL, quotechar='"')
  55. return dict(zip(*((cell.decode('utf-8') for cell in row)
  56. for row in list(reader)[:2])))
  57. def ticket(name, rawtext, text, lineno, inliner, options=None, content=None):
  58. '''Link to qubes ticket
  59. :param str name: The role name used in the document
  60. :param str rawtext: The entire markup snippet, with role
  61. :param str text: The text marked with the role
  62. :param int lineno: The line number where rawtext appears in the input
  63. :param docutils.parsers.rst.states.Inliner inliner: The inliner instance \
  64. that called this function
  65. :param options: Directive options for customisation
  66. :param content: The directive content for customisation
  67. ''' # pylint: disable=unused-argument
  68. if options is None:
  69. options = {}
  70. ticketno = text.lstrip('#')
  71. if not ticketno.isdigit():
  72. msg = inliner.reporter.error(
  73. 'Invalid ticket identificator: {!r}'.format(text), line=lineno)
  74. prb = inliner.problematic(rawtext, rawtext, msg)
  75. return [prb], [msg]
  76. app = inliner.document.settings.env.app
  77. uri = posixpath.join(app.config.ticket_base_uri, ticketno)
  78. try:
  79. info = fetch_ticket_info(uri)
  80. except urllib2.HTTPError, e:
  81. msg = inliner.reporter.error(
  82. 'Error while fetching ticket info: {!s}'.format(e), line=lineno)
  83. prb = inliner.problematic(rawtext, rawtext, msg)
  84. return [prb], [msg]
  85. docutils.parsers.rst.roles.set_classes(options)
  86. node = docutils.nodes.reference(
  87. rawtext,
  88. '#{} ({})'.format(ticketno, info['summary']),
  89. refuri=uri,
  90. **options)
  91. return [node], []
  92. class versioncheck(docutils.nodes.warning):
  93. # pylint: disable=invalid-name
  94. pass
  95. def visit(self, node):
  96. self.visit_admonition(node, 'version')
  97. def depart(self, node):
  98. self.depart_admonition(node)
  99. sphinx.locale.admonitionlabels['version'] = 'Version mismatch'
  100. class VersionCheck(docutils.parsers.rst.Directive):
  101. '''Directive versioncheck
  102. Check if current version (from ``conf.py``) equals version specified as
  103. argument. If not, generate warning.'''
  104. has_content = True
  105. required_arguments = 1
  106. optional_arguments = 0
  107. final_argument_whitespace = True
  108. option_spec = {}
  109. def run(self):
  110. current = self.state.document.settings.env.app.config.version
  111. version = self.arguments[0]
  112. if current == version:
  113. return []
  114. text = ' '.join('''This manual page was written for version **{}**, but
  115. current version at the time when this page was generated is **{}**.
  116. This may or may not mean that page is outdated or has
  117. inconsistencies.'''.format(version, current).split())
  118. node = versioncheck(text)
  119. node['classes'] = ['admonition', 'warning']
  120. self.state.nested_parse(docutils.statemachine.StringList([text]),
  121. self.content_offset, node)
  122. return [node]
  123. def make_rst_section(heading, char):
  124. return '{}\n{}\n\n'.format(heading, char[0] * len(heading))
  125. def prepare_manpage(command):
  126. parser = qubes.tools.get_parser_for_command(command)
  127. stream = StringIO.StringIO()
  128. stream.write('.. program:: {}\n\n'.format(command))
  129. stream.write(make_rst_section(
  130. ':program:`{}` -- {}'.format(command, parser.description), '='))
  131. stream.write('''.. warning::
  132. This page was autogenerated from command-line parser. It shouldn't be 1:1
  133. conversion, because it would add little value. Please revise it and add
  134. more descriptive help, which normally won't fit in standard ``--help``
  135. option.
  136. After rewrite, please remove this admonition.\n\n''')
  137. stream.write(make_rst_section('Synopsis', '-'))
  138. usage = ' '.join(parser.format_usage().strip().split())
  139. if usage.startswith('usage: '):
  140. usage = usage[len('usage: '):]
  141. # replace METAVARS with *METAVARS*
  142. usage = re.sub(r'\b([A-Z]{2,})\b', r'*\1*', usage)
  143. stream.write(':command:`{}` {}\n\n'.format(command, usage))
  144. stream.write(make_rst_section('Options', '-'))
  145. for action in parser._actions: # pylint: disable=protected-access
  146. stream.write('.. option:: ')
  147. if action.metavar:
  148. stream.write(', '.join('{}{}{}'.format(
  149. option,
  150. '=' if option.startswith('--') else ' ',
  151. action.metavar)
  152. for option in sorted(action.option_strings)))
  153. else:
  154. stream.write(', '.join(sorted(action.option_strings)))
  155. stream.write('\n\n {}\n\n'.format(action.help))
  156. stream.write(make_rst_section('Authors', '-'))
  157. stream.write('''\
  158. | Joanna Rutkowska <joanna at invisiblethingslab dot com>
  159. | Rafal Wojtczuk <rafal at invisiblethingslab dot com>
  160. | Marek Marczykowski <marmarek at invisiblethingslab dot com>
  161. | Wojtek Porczyk <woju at invisiblethingslab dot com>
  162. .. vim: ts=3 sw=3 et tw=80
  163. ''')
  164. return stream.getvalue()
  165. class OptionsCheckVisitor(docutils.nodes.SparseNodeVisitor):
  166. ''' Checks if the visited option nodes and the specified args are in sync.
  167. '''
  168. def __init__(self, command, args, document):
  169. assert isinstance(args, set)
  170. docutils.nodes.SparseNodeVisitor.__init__(self, document)
  171. self.command = command
  172. self.args = args
  173. def visit_desc(self, node):
  174. ''' Skips all but 'option' elements '''
  175. # pylint: disable=no-self-use
  176. if not node.get('desctype', None) == 'option':
  177. raise docutils.nodes.SkipChildren
  178. def visit_desc_name(self, node):
  179. ''' Checks if the option is defined `self.args` '''
  180. if not isinstance(node[0], docutils.nodes.Text):
  181. raise sphinx.errors.SphinxError('first child should be Text')
  182. arg = str(node[0])
  183. try:
  184. self.args.remove(arg)
  185. except KeyError:
  186. raise sphinx.errors.SphinxError(
  187. 'No such argument for {!r}: {!r}'.format(self.command, arg))
  188. def check_undocumented_arguments(self, ignored_options=None):
  189. ''' Call this to check if any undocumented arguments are left.
  190. While the documentation talks about a
  191. 'SparseNodeVisitor.depart_document()' function, this function does
  192. not exists. (For details see implementation of
  193. :py:method:`NodeVisitor.dispatch_departure()`) So we need to
  194. manually call this.
  195. '''
  196. if ignored_options is None:
  197. ignored_options = set()
  198. left_over_args = self.args - ignored_options
  199. if left_over_args:
  200. raise sphinx.errors.SphinxError(
  201. 'Undocumented arguments for command {!r}: {!r}'.format(
  202. self.command, ', '.join(sorted(left_over_args))))
  203. class CommandCheckVisitor(docutils.nodes.SparseNodeVisitor):
  204. ''' Checks if the visited sub command section nodes and the specified sub
  205. command args are in sync.
  206. '''
  207. def __init__(self, command, sub_commands, document):
  208. docutils.nodes.SparseNodeVisitor.__init__(self, document)
  209. self.command = command
  210. self.sub_commands = sub_commands
  211. def visit_section(self, node):
  212. ''' Checks if the visited sub-command section nodes exists and it
  213. options are in sync.
  214. Uses :py:class:`OptionsCheckVisitor` for checking
  215. sub-commands options
  216. '''
  217. # pylint: disable=no-self-use
  218. title = str(node[0][0])
  219. if title.upper() == SUBCOMMANDS_TITLE:
  220. return
  221. sub_cmd = self.command + ' ' + title
  222. try:
  223. args = self.sub_commands[title]
  224. options_visitor = OptionsCheckVisitor(sub_cmd, args, self.document)
  225. node.walkabout(options_visitor)
  226. options_visitor.check_undocumented_arguments(
  227. {'--help', '--quiet', '--verbose', '-h', '-q', '-v'})
  228. del self.sub_commands[title]
  229. except KeyError:
  230. raise sphinx.errors.SphinxError(
  231. 'No such sub-command {!r}'.format(sub_cmd))
  232. def visit_Text(self, node):
  233. ''' If the visited text node starts with 'alias: ', all the provided
  234. comma separted alias in this node, are removed from
  235. `self.sub_commands`
  236. '''
  237. # pylint: disable=invalid-name
  238. text = str(node).strip()
  239. if text.startswith('aliases:'):
  240. aliases = {a.strip() for a in text.split('aliases:')[1].split(',')}
  241. for alias in aliases:
  242. assert alias in self.sub_commands
  243. del self.sub_commands[alias]
  244. def check_undocumented_sub_commands(self):
  245. ''' Call this to check if any undocumented sub_commands are left.
  246. While the documentation talks about a
  247. 'SparseNodeVisitor.depart_document()' function, this function does
  248. not exists. (For details see implementation of
  249. :py:method:`NodeVisitor.dispatch_departure()`) So we need to
  250. manually call this.
  251. '''
  252. if self.sub_commands:
  253. raise sphinx.errors.SphinxError(
  254. 'Undocumented commands for {!r}: {!r}'.format(
  255. self.command, ', '.join(sorted(self.sub_commands.keys()))))
  256. class ManpageCheckVisitor(docutils.nodes.SparseNodeVisitor):
  257. ''' Checks if the sub-commands and options specified in the 'COMMAND' and
  258. 'OPTIONS' (case insensitve) sections in sync the command parser.
  259. '''
  260. def __init__(self, app, command, document):
  261. docutils.nodes.SparseNodeVisitor.__init__(self, document)
  262. try:
  263. parser = qubes.tools.get_parser_for_command(command)
  264. except ImportError:
  265. app.warn('cannot import module for command')
  266. self.parser = None
  267. return
  268. except AttributeError:
  269. raise sphinx.errors.SphinxError('cannot find parser in module')
  270. self.command = command
  271. self.parser = parser
  272. self.options = set()
  273. self.sub_commands = {}
  274. self.app = app
  275. # pylint: disable=protected-access
  276. for action in parser._actions:
  277. if action.help == argparse.SUPPRESS:
  278. continue
  279. if issubclass(action.__class__,
  280. qubes.tools.AliasedSubParsersAction):
  281. for cmd, cmd_parser in action._name_parser_map.items():
  282. self.sub_commands[cmd] = set()
  283. for sub_action in cmd_parser._actions:
  284. if sub_action.help != argparse.SUPPRESS:
  285. self.sub_commands[cmd].update(
  286. sub_action.option_strings)
  287. else:
  288. self.options.update(action.option_strings)
  289. def visit_section(self, node):
  290. ''' If section title is OPTIONS or COMMANDS dispatch the apropriate
  291. `NodeVisitor`.
  292. '''
  293. if self.parser is None:
  294. return
  295. section_title = str(node[0][0]).upper()
  296. if section_title == OPTIONS_TITLE:
  297. options_visitor = OptionsCheckVisitor(self.command, self.options,
  298. self.document)
  299. node.walkabout(options_visitor)
  300. options_visitor.check_undocumented_arguments()
  301. elif section_title == SUBCOMMANDS_TITLE:
  302. sub_cmd_visitor = CommandCheckVisitor(
  303. self.command, self.sub_commands, self.document)
  304. node.walkabout(sub_cmd_visitor)
  305. sub_cmd_visitor.check_undocumented_sub_commands()
  306. def check_man_args(app, doctree, docname):
  307. ''' Checks the manpage for undocumented or obsolete sub-commands and
  308. options.
  309. '''
  310. command = os.path.split(docname)[1]
  311. app.info('Checking arguments for {!r}'.format(command))
  312. doctree.walk(ManpageCheckVisitor(app, command, doctree))
  313. #
  314. # this is lifted from sphinx' own conf.py
  315. #
  316. event_sig_re = re.compile(r'([a-zA-Z-:<>]+)\s*\((.*)\)')
  317. def parse_event(env, sig, signode):
  318. # pylint: disable=unused-argument
  319. m = event_sig_re.match(sig)
  320. if not m:
  321. signode += sphinx.addnodes.desc_name(sig, sig)
  322. return sig
  323. name, args = m.groups()
  324. signode += sphinx.addnodes.desc_name(name, name)
  325. plist = sphinx.addnodes.desc_parameterlist()
  326. for arg in args.split(','):
  327. arg = arg.strip()
  328. plist += sphinx.addnodes.desc_parameter(arg, arg)
  329. signode += plist
  330. return name
  331. #
  332. # end of codelifting
  333. #
  334. def break_to_pdb(app, *dummy):
  335. if not app.config.break_to_pdb:
  336. return
  337. import pdb
  338. pdb.set_trace()
  339. def setup(app):
  340. app.add_role('ticket', ticket)
  341. app.add_config_value('ticket_base_uri',
  342. 'https://wiki.qubes-os.org/ticket/', 'env')
  343. app.add_config_value('break_to_pdb', False, 'env')
  344. app.add_node(versioncheck,
  345. html=(visit, depart),
  346. man=(visit, depart))
  347. app.add_directive('versioncheck', VersionCheck)
  348. fdesc = sphinx.util.docfields.GroupedField('parameter', label='Parameters',
  349. names=['param'], can_collapse=True)
  350. app.add_object_type('event', 'event', 'pair: %s; event', parse_event,
  351. doc_field_types=[fdesc])
  352. app.connect('doctree-resolved', break_to_pdb)
  353. app.connect('doctree-resolved', check_man_args)
  354. # vim: ts=4 sw=4 et