dochelpers.py 16 KB

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