qapidoc.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330
  1. # coding=utf-8
  2. #
  3. # QEMU qapidoc QAPI file parsing extension
  4. #
  5. # Copyright (c) 2020 Linaro
  6. #
  7. # This work is licensed under the terms of the GNU GPLv2 or later.
  8. # See the COPYING file in the top-level directory.
  9. """
  10. qapidoc is a Sphinx extension that implements the qapi-doc directive
  11. The purpose of this extension is to read the documentation comments
  12. in QAPI schema files, and insert them all into the current document.
  13. It implements one new rST directive, "qapi-doc::".
  14. Each qapi-doc:: directive takes one argument, which is the
  15. pathname of the schema file to process, relative to the source tree.
  16. The docs/conf.py file must set the qapidoc_srctree config value to
  17. the root of the QEMU source tree.
  18. The Sphinx documentation on writing extensions is at:
  19. https://www.sphinx-doc.org/en/master/development/index.html
  20. """
  21. from __future__ import annotations
  22. from contextlib import contextmanager
  23. import os
  24. from pathlib import Path
  25. import sys
  26. from typing import TYPE_CHECKING
  27. from docutils import nodes
  28. from docutils.parsers.rst import Directive, directives
  29. from docutils.statemachine import StringList
  30. from qapi.error import QAPIError
  31. from qapi.schema import QAPISchema, QAPISchemaVisitor
  32. from qapi.source import QAPISourceInfo
  33. from qapidoc_legacy import QAPISchemaGenRSTVisitor # type: ignore
  34. from sphinx import addnodes
  35. from sphinx.directives.code import CodeBlock
  36. from sphinx.errors import ExtensionError
  37. from sphinx.util.docutils import switch_source_input
  38. from sphinx.util.nodes import nested_parse_with_titles
  39. if TYPE_CHECKING:
  40. from typing import (
  41. Any,
  42. Generator,
  43. List,
  44. Sequence,
  45. )
  46. from sphinx.application import Sphinx
  47. from sphinx.util.typing import ExtensionMetadata
  48. __version__ = "1.0"
  49. class Transmogrifier:
  50. def __init__(self) -> None:
  51. self._result = StringList()
  52. self.indent = 0
  53. # General-purpose rST generation functions
  54. def get_indent(self) -> str:
  55. return " " * self.indent
  56. @contextmanager
  57. def indented(self) -> Generator[None]:
  58. self.indent += 1
  59. try:
  60. yield
  61. finally:
  62. self.indent -= 1
  63. def add_line_raw(self, line: str, source: str, *lineno: int) -> None:
  64. """Append one line of generated reST to the output."""
  65. # NB: Sphinx uses zero-indexed lines; subtract one.
  66. lineno = tuple((n - 1 for n in lineno))
  67. if line.strip():
  68. # not a blank line
  69. self._result.append(
  70. self.get_indent() + line.rstrip("\n"), source, *lineno
  71. )
  72. else:
  73. self._result.append("", source, *lineno)
  74. def add_line(self, content: str, info: QAPISourceInfo) -> None:
  75. # NB: We *require* an info object; this works out OK because we
  76. # don't document built-in objects that don't have
  77. # one. Everything else should.
  78. self.add_line_raw(content, info.fname, info.line)
  79. def add_lines(
  80. self,
  81. content: str,
  82. info: QAPISourceInfo,
  83. ) -> None:
  84. lines = content.splitlines(True)
  85. for i, line in enumerate(lines):
  86. self.add_line_raw(line, info.fname, info.line + i)
  87. def ensure_blank_line(self) -> None:
  88. # Empty document -- no blank line required.
  89. if not self._result:
  90. return
  91. # Last line isn't blank, add one.
  92. if self._result[-1].strip(): # pylint: disable=no-member
  93. fname, line = self._result.info(-1)
  94. assert isinstance(line, int)
  95. # New blank line is credited to one-after the current last line.
  96. # +2: correct for zero/one index, then increment by one.
  97. self.add_line_raw("", fname, line + 2)
  98. # Transmogrification core methods
  99. def visit_module(self, path: str) -> None:
  100. name = Path(path).stem
  101. # module directives are credited to the first line of a module file.
  102. self.add_line_raw(f".. qapi:module:: {name}", path, 1)
  103. self.ensure_blank_line()
  104. class QAPISchemaGenDepVisitor(QAPISchemaVisitor):
  105. """A QAPI schema visitor which adds Sphinx dependencies each module
  106. This class calls the Sphinx note_dependency() function to tell Sphinx
  107. that the generated documentation output depends on the input
  108. schema file associated with each module in the QAPI input.
  109. """
  110. def __init__(self, env: Any, qapidir: str) -> None:
  111. self._env = env
  112. self._qapidir = qapidir
  113. def visit_module(self, name: str) -> None:
  114. if name != "./builtin":
  115. qapifile = self._qapidir + "/" + name
  116. self._env.note_dependency(os.path.abspath(qapifile))
  117. super().visit_module(name)
  118. class NestedDirective(Directive):
  119. def run(self) -> Sequence[nodes.Node]:
  120. raise NotImplementedError
  121. def do_parse(self, rstlist: StringList, node: nodes.Node) -> None:
  122. """
  123. Parse rST source lines and add them to the specified node
  124. Take the list of rST source lines rstlist, parse them as
  125. rST, and add the resulting docutils nodes as children of node.
  126. The nodes are parsed in a way that allows them to include
  127. subheadings (titles) without confusing the rendering of
  128. anything else.
  129. """
  130. with switch_source_input(self.state, rstlist):
  131. nested_parse_with_titles(self.state, rstlist, node)
  132. class QAPIDocDirective(NestedDirective):
  133. """Extract documentation from the specified QAPI .json file"""
  134. required_argument = 1
  135. optional_arguments = 1
  136. option_spec = {
  137. "qapifile": directives.unchanged_required,
  138. "transmogrify": directives.flag,
  139. }
  140. has_content = False
  141. def new_serialno(self) -> str:
  142. """Return a unique new ID string suitable for use as a node's ID"""
  143. env = self.state.document.settings.env
  144. return "qapidoc-%d" % env.new_serialno("qapidoc")
  145. def transmogrify(self, schema: QAPISchema) -> nodes.Element:
  146. raise NotImplementedError
  147. def legacy(self, schema: QAPISchema) -> nodes.Element:
  148. vis = QAPISchemaGenRSTVisitor(self)
  149. vis.visit_begin(schema)
  150. for doc in schema.docs:
  151. if doc.symbol:
  152. vis.symbol(doc, schema.lookup_entity(doc.symbol))
  153. else:
  154. vis.freeform(doc)
  155. return vis.get_document_node() # type: ignore
  156. def run(self) -> Sequence[nodes.Node]:
  157. env = self.state.document.settings.env
  158. qapifile = env.config.qapidoc_srctree + "/" + self.arguments[0]
  159. qapidir = os.path.dirname(qapifile)
  160. transmogrify = "transmogrify" in self.options
  161. try:
  162. schema = QAPISchema(qapifile)
  163. # First tell Sphinx about all the schema files that the
  164. # output documentation depends on (including 'qapifile' itself)
  165. schema.visit(QAPISchemaGenDepVisitor(env, qapidir))
  166. except QAPIError as err:
  167. # Launder QAPI parse errors into Sphinx extension errors
  168. # so they are displayed nicely to the user
  169. raise ExtensionError(str(err)) from err
  170. if transmogrify:
  171. contentnode = self.transmogrify(schema)
  172. else:
  173. contentnode = self.legacy(schema)
  174. return contentnode.children
  175. class QMPExample(CodeBlock, NestedDirective):
  176. """
  177. Custom admonition for QMP code examples.
  178. When the :annotated: option is present, the body of this directive
  179. is parsed as normal rST, but with any '::' code blocks set to use
  180. the QMP lexer. Code blocks must be explicitly written by the user,
  181. but this allows for intermingling explanatory paragraphs with
  182. arbitrary rST syntax and code blocks for more involved examples.
  183. When :annotated: is absent, the directive body is treated as a
  184. simple standalone QMP code block literal.
  185. """
  186. required_argument = 0
  187. optional_arguments = 0
  188. has_content = True
  189. option_spec = {
  190. "annotated": directives.flag,
  191. "title": directives.unchanged,
  192. }
  193. def _highlightlang(self) -> addnodes.highlightlang:
  194. """Return the current highlightlang setting for the document"""
  195. node = None
  196. doc = self.state.document
  197. if hasattr(doc, "findall"):
  198. # docutils >= 0.18.1
  199. for node in doc.findall(addnodes.highlightlang):
  200. pass
  201. else:
  202. for elem in doc.traverse():
  203. if isinstance(elem, addnodes.highlightlang):
  204. node = elem
  205. if node:
  206. return node
  207. # No explicit directive found, use defaults
  208. node = addnodes.highlightlang(
  209. lang=self.env.config.highlight_language,
  210. force=False,
  211. # Yes, Sphinx uses this value to effectively disable line
  212. # numbers and not 0 or None or -1 or something. ¯\_(ツ)_/¯
  213. linenothreshold=sys.maxsize,
  214. )
  215. return node
  216. def admonition_wrap(self, *content: nodes.Node) -> List[nodes.Node]:
  217. title = "Example:"
  218. if "title" in self.options:
  219. title = f"{title} {self.options['title']}"
  220. admon = nodes.admonition(
  221. "",
  222. nodes.title("", title),
  223. *content,
  224. classes=["admonition", "admonition-example"],
  225. )
  226. return [admon]
  227. def run_annotated(self) -> List[nodes.Node]:
  228. lang_node = self._highlightlang()
  229. content_node: nodes.Element = nodes.section()
  230. # Configure QMP highlighting for "::" blocks, if needed
  231. if lang_node["lang"] != "QMP":
  232. content_node += addnodes.highlightlang(
  233. lang="QMP",
  234. force=False, # "True" ignores lexing errors
  235. linenothreshold=lang_node["linenothreshold"],
  236. )
  237. self.do_parse(self.content, content_node)
  238. # Restore prior language highlighting, if needed
  239. if lang_node["lang"] != "QMP":
  240. content_node += addnodes.highlightlang(**lang_node.attributes)
  241. return content_node.children
  242. def run(self) -> List[nodes.Node]:
  243. annotated = "annotated" in self.options
  244. if annotated:
  245. content_nodes = self.run_annotated()
  246. else:
  247. self.arguments = ["QMP"]
  248. content_nodes = super().run()
  249. return self.admonition_wrap(*content_nodes)
  250. def setup(app: Sphinx) -> ExtensionMetadata:
  251. """Register qapi-doc directive with Sphinx"""
  252. app.add_config_value("qapidoc_srctree", None, "env")
  253. app.add_directive("qapi-doc", QAPIDocDirective)
  254. app.add_directive("qmp-example", QMPExample)
  255. return {
  256. "version": __version__,
  257. "parallel_read_safe": True,
  258. "parallel_write_safe": True,
  259. }