dochelpers.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317
  1. #
  2. # The Qubes OS Project, https://www.qubes-os.org/
  3. #
  4. # Copyright (C) 2010-2015 Joanna Rutkowska <joanna@invisiblethingslab.com>
  5. # Copyright (C) 2014-2015 Wojtek Porczyk <woju@invisiblethingslab.com>
  6. #
  7. # This program is free software; you can redistribute it and/or modify
  8. # it under the terms of the GNU Lesser General Public License as published by
  9. # the Free Software Foundation; either version 2.1 of the License, or
  10. # (at your option) any later version.
  11. #
  12. # This program is distributed in the hope that it will be useful,
  13. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  14. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  15. # GNU Lesser General Public License for more details.
  16. #
  17. # You should have received a copy of the GNU Lesser General Public License along
  18. # with this program; if not, write to the Free Software Foundation, Inc.,
  19. # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
  20. #
  21. """Documentation helpers.
  22. This module contains classes and functions which help to maintain documentation,
  23. particularly our custom Sphinx extension.
  24. """
  25. import argparse
  26. import io
  27. import os
  28. import re
  29. import docutils
  30. import docutils.nodes
  31. import docutils.parsers.rst
  32. import docutils.parsers.rst.roles
  33. import docutils.statemachine
  34. import sphinx
  35. import sphinx.errors
  36. import sphinx.locale
  37. import sphinx.util.docfields
  38. from sphinx.util import logging
  39. import qubesadmin.tools
  40. try:
  41. log = logging.getLogger(__name__)
  42. except AttributeError:
  43. log = None
  44. SUBCOMMANDS_TITLE = 'COMMANDS'
  45. OPTIONS_TITLE = 'OPTIONS'
  46. def make_rst_section(heading, char):
  47. """Format a section header in rst"""
  48. return '{}\n{}\n\n'.format(heading, char[0] * len(heading))
  49. def prepare_manpage(command):
  50. """Build a man page skeleton"""
  51. parser = qubesadmin.tools.get_parser_for_command(command)
  52. stream = io.StringIO()
  53. stream.write('.. program:: {}\n\n'.format(command))
  54. stream.write(make_rst_section(
  55. ':program:`{}` -- {}'.format(command, parser.description), '='))
  56. stream.write(""".. warning::
  57. This page was autogenerated from command-line parser. It shouldn't be 1:1
  58. conversion, because it would add little value. Please revise it and add
  59. more descriptive help, which normally won't fit in standard ``--help``
  60. option.
  61. After rewrite, please remove this admonition.\n\n""")
  62. stream.write(make_rst_section('Synopsis', '-'))
  63. usage = ' '.join(parser.format_usage().strip().split())
  64. if usage.startswith('usage: '):
  65. usage = usage[len('usage: '):]
  66. # replace METAVARS with *METAVARS*
  67. usage = re.sub(r'\b([A-Z]{2,})\b', r'*\1*', usage)
  68. stream.write(':command:`{}` {}\n\n'.format(command, usage))
  69. stream.write(make_rst_section('Options', '-'))
  70. for action in parser._actions: # pylint: disable=protected-access
  71. stream.write('.. option:: ')
  72. if action.metavar:
  73. stream.write(', '.join(
  74. '{}{}{}'.format(option, '=' if option.startswith('--') else ' ',
  75. action.metavar) for option in
  76. sorted(action.option_strings)))
  77. else:
  78. stream.write(', '.join(sorted(action.option_strings)))
  79. stream.write('\n\n {}\n\n'.format(action.help))
  80. stream.write(make_rst_section('Authors', '-'))
  81. stream.write("""\
  82. | Joanna Rutkowska <joanna at invisiblethingslab dot com>
  83. | Rafal Wojtczuk <rafal at invisiblethingslab dot com>
  84. | Marek Marczykowski <marmarek at invisiblethingslab dot com>
  85. | Wojtek Porczyk <woju at invisiblethingslab dot com>
  86. .. vim: ts=3 sw=3 et tw=80
  87. """)
  88. return stream.getvalue()
  89. class OptionsCheckVisitor(docutils.nodes.SparseNodeVisitor):
  90. """ Checks if the visited option nodes and the specified args are in sync.
  91. """
  92. def __init__(self, command, args, document):
  93. assert isinstance(args, set)
  94. docutils.nodes.SparseNodeVisitor.__init__(self, document)
  95. self.command = command
  96. self.args = args
  97. def visit_desc(self, node):
  98. """ Skips all but 'option' elements """
  99. # pylint: disable=no-self-use
  100. if not node.get('desctype', None) == 'option':
  101. raise docutils.nodes.SkipChildren
  102. def visit_desc_name(self, node):
  103. """ Checks if the option is defined `self.args` """
  104. if not isinstance(node[0], docutils.nodes.Text):
  105. raise sphinx.errors.SphinxError('first child should be Text')
  106. arg = str(node[0])
  107. try:
  108. self.args.remove(arg)
  109. except KeyError:
  110. raise sphinx.errors.SphinxError(
  111. 'No such argument for {!r}: {!r}'.format(self.command, arg))
  112. def check_undocumented_arguments(self, ignored_options=None):
  113. """ Call this to check if any undocumented arguments are left.
  114. While the documentation talks about a
  115. 'SparseNodeVisitor.depart_document()' function, this function does
  116. not exists. (For details see implementation of
  117. :py:meth:`NodeVisitor.dispatch_departure()`) So we need to
  118. manually call this.
  119. """
  120. if ignored_options is None:
  121. ignored_options = set()
  122. left_over_args = self.args - ignored_options
  123. if left_over_args:
  124. raise sphinx.errors.SphinxError(
  125. 'Undocumented arguments for command {!r}: {!r}'.format(
  126. self.command, ', '.join(sorted(left_over_args))))
  127. class CommandCheckVisitor(docutils.nodes.SparseNodeVisitor):
  128. """ Checks if the visited sub command section nodes and the specified sub
  129. command args are in sync.
  130. """
  131. def __init__(self, command, sub_commands, document):
  132. docutils.nodes.SparseNodeVisitor.__init__(self, document)
  133. self.command = command
  134. self.sub_commands = sub_commands
  135. def visit_section(self, node):
  136. """ Checks if the visited sub-command section nodes exists and it
  137. options are in sync.
  138. Uses :py:class:`OptionsCheckVisitor` for checking
  139. sub-commands options
  140. """
  141. # pylint: disable=no-self-use
  142. title = str(node[0][0])
  143. if title.upper() == SUBCOMMANDS_TITLE:
  144. return
  145. sub_cmd = self.command + ' ' + title
  146. try:
  147. args = self.sub_commands[title]
  148. options_visitor = OptionsCheckVisitor(sub_cmd, args, self.document)
  149. node.walkabout(options_visitor)
  150. options_visitor.check_undocumented_arguments(
  151. {'--help', '--quiet', '--verbose', '-h', '-q', '-v'})
  152. del self.sub_commands[title]
  153. except KeyError:
  154. raise sphinx.errors.SphinxError(
  155. 'No such sub-command {!r}'.format(sub_cmd))
  156. def visit_Text(self, node):
  157. """ If the visited text node starts with 'alias: ', all the provided
  158. comma separted alias in this node, are removed from
  159. `self.sub_commands`
  160. """
  161. # pylint: disable=invalid-name
  162. text = str(node).strip()
  163. if text.startswith('aliases:'):
  164. aliases = {a.strip() for a in text.split('aliases:')[1].split(',')}
  165. for alias in aliases:
  166. assert alias in self.sub_commands
  167. del self.sub_commands[alias]
  168. def check_undocumented_sub_commands(self):
  169. """ Call this to check if any undocumented sub_commands are left.
  170. While the documentation talks about a
  171. 'SparseNodeVisitor.depart_document()' function, this function does
  172. not exists. (For details see implementation of
  173. :py:meth:`NodeVisitor.dispatch_departure()`) So we need to
  174. manually call this.
  175. """
  176. if self.sub_commands:
  177. raise sphinx.errors.SphinxError(
  178. 'Undocumented commands for {!r}: {!r}'.format(
  179. self.command, ', '.join(sorted(self.sub_commands.keys()))))
  180. class ManpageCheckVisitor(docutils.nodes.SparseNodeVisitor):
  181. """ Checks if the sub-commands and options specified in the 'COMMAND' and
  182. 'OPTIONS' (case insensitve) sections in sync the command parser.
  183. """
  184. def __init__(self, app, command, document):
  185. docutils.nodes.SparseNodeVisitor.__init__(self, document)
  186. try:
  187. parser = qubesadmin.tools.get_parser_for_command(command)
  188. except ImportError:
  189. msg = 'cannot import module for command'
  190. if log:
  191. log.warning(msg)
  192. else:
  193. # Handle legacy
  194. app.warn(msg)
  195. self.parser = None
  196. return
  197. except AttributeError:
  198. raise sphinx.errors.SphinxError('cannot find parser in module')
  199. self.command = command
  200. self.parser = parser
  201. self.options = set()
  202. self.sub_commands = {}
  203. self.app = app
  204. # pylint: disable=protected-access
  205. for action in parser._actions:
  206. if action.help == argparse.SUPPRESS:
  207. continue
  208. if issubclass(action.__class__,
  209. qubesadmin.tools.AliasedSubParsersAction):
  210. for cmd, cmd_parser in action._name_parser_map.items():
  211. self.sub_commands[cmd] = set()
  212. for sub_action in cmd_parser._actions:
  213. if sub_action.help != argparse.SUPPRESS:
  214. self.sub_commands[cmd].update(
  215. sub_action.option_strings)
  216. else:
  217. self.options.update(action.option_strings)
  218. def visit_section(self, node):
  219. """ If section title is OPTIONS or COMMANDS dispatch the apropriate
  220. `NodeVisitor`.
  221. """
  222. if self.parser is None:
  223. return
  224. section_title = str(node[0][0]).upper()
  225. if section_title == OPTIONS_TITLE:
  226. options_visitor = OptionsCheckVisitor(self.command, self.options,
  227. self.document)
  228. node.walkabout(options_visitor)
  229. options_visitor.check_undocumented_arguments()
  230. elif section_title == SUBCOMMANDS_TITLE:
  231. sub_cmd_visitor = CommandCheckVisitor(
  232. self.command, self.sub_commands, self.document)
  233. node.walkabout(sub_cmd_visitor)
  234. sub_cmd_visitor.check_undocumented_sub_commands()
  235. def check_man_args(app, doctree, docname):
  236. """ Checks the manpage for undocumented or obsolete sub-commands and
  237. options.
  238. """
  239. dirname, command = os.path.split(docname)
  240. if os.path.basename(dirname) != 'manpages':
  241. return
  242. msg = 'Checking arguments for {!r}'.format(command)
  243. if log:
  244. log.info(msg)
  245. else:
  246. # Handle legacy
  247. app.info(msg)
  248. doctree.walk(ManpageCheckVisitor(app, command, doctree))
  249. def break_to_pdb(app, *_dummy):
  250. """DEBUG"""
  251. if not app.config.break_to_pdb:
  252. return
  253. import pdb
  254. pdb.set_trace()
  255. def setup(app):
  256. """Setup Sphinx extension"""
  257. app.add_config_value('break_to_pdb', False, 'env')
  258. app.connect('doctree-resolved', break_to_pdb)
  259. app.connect('doctree-resolved', check_man_args)
  260. # vim: ts=4 sw=4 et