|  | # coding=utf-8 | 
|  | # | 
|  | # QEMU qapidoc QAPI file parsing extension | 
|  | # | 
|  | # Copyright (c) 2020 Linaro | 
|  | # | 
|  | # This work is licensed under the terms of the GNU GPLv2 or later. | 
|  | # See the COPYING file in the top-level directory. | 
|  |  | 
|  | """ | 
|  | qapidoc is a Sphinx extension that implements the qapi-doc directive | 
|  |  | 
|  | The purpose of this extension is to read the documentation comments | 
|  | in QAPI schema files, and insert them all into the current document. | 
|  |  | 
|  | It implements one new rST directive, "qapi-doc::". | 
|  | Each qapi-doc:: directive takes one argument, which is the | 
|  | pathname of the schema file to process, relative to the source tree. | 
|  |  | 
|  | The docs/conf.py file must set the qapidoc_srctree config value to | 
|  | the root of the QEMU source tree. | 
|  |  | 
|  | The Sphinx documentation on writing extensions is at: | 
|  | https://www.sphinx-doc.org/en/master/development/index.html | 
|  | """ | 
|  |  | 
|  | import os | 
|  | import re | 
|  |  | 
|  | from docutils import nodes | 
|  | from docutils.statemachine import ViewList | 
|  | from docutils.parsers.rst import directives, Directive | 
|  | from sphinx.errors import ExtensionError | 
|  | from sphinx.util.nodes import nested_parse_with_titles | 
|  | import sphinx | 
|  | from qapi.gen import QAPISchemaVisitor | 
|  | from qapi.error import QAPIError, QAPISemError | 
|  | from qapi.schema import QAPISchema | 
|  |  | 
|  |  | 
|  | # Sphinx up to 1.6 uses AutodocReporter; 1.7 and later | 
|  | # use switch_source_input. Check borrowed from kerneldoc.py. | 
|  | Use_SSI = sphinx.__version__[:3] >= '1.7' | 
|  | if Use_SSI: | 
|  | from sphinx.util.docutils import switch_source_input | 
|  | else: | 
|  | from sphinx.ext.autodoc import AutodocReporter | 
|  |  | 
|  |  | 
|  | __version__ = '1.0' | 
|  |  | 
|  |  | 
|  | # Function borrowed from pydash, which is under the MIT license | 
|  | def intersperse(iterable, separator): | 
|  | """Yield the members of *iterable* interspersed with *separator*.""" | 
|  | iterable = iter(iterable) | 
|  | yield next(iterable) | 
|  | for item in iterable: | 
|  | yield separator | 
|  | yield item | 
|  |  | 
|  |  | 
|  | class QAPISchemaGenRSTVisitor(QAPISchemaVisitor): | 
|  | """A QAPI schema visitor which generates docutils/Sphinx nodes | 
|  |  | 
|  | This class builds up a tree of docutils/Sphinx nodes corresponding | 
|  | to documentation for the various QAPI objects. To use it, first | 
|  | create a QAPISchemaGenRSTVisitor object, and call its | 
|  | visit_begin() method.  Then you can call one of the two methods | 
|  | 'freeform' (to add documentation for a freeform documentation | 
|  | chunk) or 'symbol' (to add documentation for a QAPI symbol). These | 
|  | will cause the visitor to build up the tree of document | 
|  | nodes. Once you've added all the documentation via 'freeform' and | 
|  | 'symbol' method calls, you can call 'get_document_nodes' to get | 
|  | the final list of document nodes (in a form suitable for returning | 
|  | from a Sphinx directive's 'run' method). | 
|  | """ | 
|  | def __init__(self, sphinx_directive): | 
|  | self._cur_doc = None | 
|  | self._sphinx_directive = sphinx_directive | 
|  | self._top_node = nodes.section() | 
|  | self._active_headings = [self._top_node] | 
|  |  | 
|  | def _make_dlitem(self, term, defn): | 
|  | """Return a dlitem node with the specified term and definition. | 
|  |  | 
|  | term should be a list of Text and literal nodes. | 
|  | defn should be one of: | 
|  | - a string, which will be handed to _parse_text_into_node | 
|  | - a list of Text and literal nodes, which will be put into | 
|  | a paragraph node | 
|  | """ | 
|  | dlitem = nodes.definition_list_item() | 
|  | dlterm = nodes.term('', '', *term) | 
|  | dlitem += dlterm | 
|  | if defn: | 
|  | dldef = nodes.definition() | 
|  | if isinstance(defn, list): | 
|  | dldef += nodes.paragraph('', '', *defn) | 
|  | else: | 
|  | self._parse_text_into_node(defn, dldef) | 
|  | dlitem += dldef | 
|  | return dlitem | 
|  |  | 
|  | def _make_section(self, title): | 
|  | """Return a section node with optional title""" | 
|  | section = nodes.section(ids=[self._sphinx_directive.new_serialno()]) | 
|  | if title: | 
|  | section += nodes.title(title, title) | 
|  | return section | 
|  |  | 
|  | def _nodes_for_ifcond(self, ifcond, with_if=True): | 
|  | """Return list of Text, literal nodes for the ifcond | 
|  |  | 
|  | Return a list which gives text like ' (If: condition)'. | 
|  | If with_if is False, we don't return the "(If: " and ")". | 
|  | """ | 
|  |  | 
|  | doc = ifcond.docgen() | 
|  | if not doc: | 
|  | return [] | 
|  | doc = nodes.literal('', doc) | 
|  | if not with_if: | 
|  | return [doc] | 
|  |  | 
|  | nodelist = [nodes.Text(' ('), nodes.strong('', 'If: ')] | 
|  | nodelist.append(doc) | 
|  | nodelist.append(nodes.Text(')')) | 
|  | return nodelist | 
|  |  | 
|  | def _nodes_for_one_member(self, member): | 
|  | """Return list of Text, literal nodes for this member | 
|  |  | 
|  | Return a list of doctree nodes which give text like | 
|  | 'name: type (optional) (If: ...)' suitable for use as the | 
|  | 'term' part of a definition list item. | 
|  | """ | 
|  | term = [nodes.literal('', member.name)] | 
|  | if member.type.doc_type(): | 
|  | term.append(nodes.Text(': ')) | 
|  | term.append(nodes.literal('', member.type.doc_type())) | 
|  | if member.optional: | 
|  | term.append(nodes.Text(' (optional)')) | 
|  | if member.ifcond.is_present(): | 
|  | term.extend(self._nodes_for_ifcond(member.ifcond)) | 
|  | return term | 
|  |  | 
|  | def _nodes_for_variant_when(self, variants, variant): | 
|  | """Return list of Text, literal nodes for variant 'when' clause | 
|  |  | 
|  | Return a list of doctree nodes which give text like | 
|  | 'when tagname is variant (If: ...)' suitable for use in | 
|  | the 'variants' part of a definition list. | 
|  | """ | 
|  | term = [nodes.Text(' when '), | 
|  | nodes.literal('', variants.tag_member.name), | 
|  | nodes.Text(' is '), | 
|  | nodes.literal('', '"%s"' % variant.name)] | 
|  | if variant.ifcond.is_present(): | 
|  | term.extend(self._nodes_for_ifcond(variant.ifcond)) | 
|  | return term | 
|  |  | 
|  | def _nodes_for_members(self, doc, what, base=None, variants=None): | 
|  | """Return list of doctree nodes for the table of members""" | 
|  | dlnode = nodes.definition_list() | 
|  | for section in doc.args.values(): | 
|  | term = self._nodes_for_one_member(section.member) | 
|  | # TODO drop fallbacks when undocumented members are outlawed | 
|  | if section.text: | 
|  | defn = section.text | 
|  | elif (variants and variants.tag_member == section.member | 
|  | and not section.member.type.doc_type()): | 
|  | values = section.member.type.member_names() | 
|  | defn = [nodes.Text('One of ')] | 
|  | defn.extend(intersperse([nodes.literal('', v) for v in values], | 
|  | nodes.Text(', '))) | 
|  | else: | 
|  | defn = [nodes.Text('Not documented')] | 
|  |  | 
|  | dlnode += self._make_dlitem(term, defn) | 
|  |  | 
|  | if base: | 
|  | dlnode += self._make_dlitem([nodes.Text('The members of '), | 
|  | nodes.literal('', base.doc_type())], | 
|  | None) | 
|  |  | 
|  | if variants: | 
|  | for v in variants.variants: | 
|  | if v.type.is_implicit(): | 
|  | assert not v.type.base and not v.type.variants | 
|  | for m in v.type.local_members: | 
|  | term = self._nodes_for_one_member(m) | 
|  | term.extend(self._nodes_for_variant_when(variants, v)) | 
|  | dlnode += self._make_dlitem(term, None) | 
|  | else: | 
|  | term = [nodes.Text('The members of '), | 
|  | nodes.literal('', v.type.doc_type())] | 
|  | term.extend(self._nodes_for_variant_when(variants, v)) | 
|  | dlnode += self._make_dlitem(term, None) | 
|  |  | 
|  | if not dlnode.children: | 
|  | return [] | 
|  |  | 
|  | section = self._make_section(what) | 
|  | section += dlnode | 
|  | return [section] | 
|  |  | 
|  | def _nodes_for_enum_values(self, doc): | 
|  | """Return list of doctree nodes for the table of enum values""" | 
|  | seen_item = False | 
|  | dlnode = nodes.definition_list() | 
|  | for section in doc.args.values(): | 
|  | termtext = [nodes.literal('', section.member.name)] | 
|  | if section.member.ifcond.is_present(): | 
|  | termtext.extend(self._nodes_for_ifcond(section.member.ifcond)) | 
|  | # TODO drop fallbacks when undocumented members are outlawed | 
|  | if section.text: | 
|  | defn = section.text | 
|  | else: | 
|  | defn = [nodes.Text('Not documented')] | 
|  |  | 
|  | dlnode += self._make_dlitem(termtext, defn) | 
|  | seen_item = True | 
|  |  | 
|  | if not seen_item: | 
|  | return [] | 
|  |  | 
|  | section = self._make_section('Values') | 
|  | section += dlnode | 
|  | return [section] | 
|  |  | 
|  | def _nodes_for_arguments(self, doc, boxed_arg_type): | 
|  | """Return list of doctree nodes for the arguments section""" | 
|  | if boxed_arg_type: | 
|  | assert not doc.args | 
|  | section = self._make_section('Arguments') | 
|  | dlnode = nodes.definition_list() | 
|  | dlnode += self._make_dlitem( | 
|  | [nodes.Text('The members of '), | 
|  | nodes.literal('', boxed_arg_type.name)], | 
|  | None) | 
|  | section += dlnode | 
|  | return [section] | 
|  |  | 
|  | return self._nodes_for_members(doc, 'Arguments') | 
|  |  | 
|  | def _nodes_for_features(self, doc): | 
|  | """Return list of doctree nodes for the table of features""" | 
|  | seen_item = False | 
|  | dlnode = nodes.definition_list() | 
|  | for section in doc.features.values(): | 
|  | dlnode += self._make_dlitem([nodes.literal('', section.name)], | 
|  | section.text) | 
|  | seen_item = True | 
|  |  | 
|  | if not seen_item: | 
|  | return [] | 
|  |  | 
|  | section = self._make_section('Features') | 
|  | section += dlnode | 
|  | return [section] | 
|  |  | 
|  | def _nodes_for_example(self, exampletext): | 
|  | """Return list of doctree nodes for a code example snippet""" | 
|  | return [nodes.literal_block(exampletext, exampletext)] | 
|  |  | 
|  | def _nodes_for_sections(self, doc): | 
|  | """Return list of doctree nodes for additional sections""" | 
|  | nodelist = [] | 
|  | for section in doc.sections: | 
|  | if section.name and section.name == 'TODO': | 
|  | # Hide TODO: sections | 
|  | continue | 
|  | snode = self._make_section(section.name) | 
|  | if section.name and section.name.startswith('Example'): | 
|  | snode += self._nodes_for_example(section.text) | 
|  | else: | 
|  | self._parse_text_into_node(section.text, snode) | 
|  | nodelist.append(snode) | 
|  | return nodelist | 
|  |  | 
|  | def _nodes_for_if_section(self, ifcond): | 
|  | """Return list of doctree nodes for the "If" section""" | 
|  | nodelist = [] | 
|  | if ifcond.is_present(): | 
|  | snode = self._make_section('If') | 
|  | snode += nodes.paragraph( | 
|  | '', '', *self._nodes_for_ifcond(ifcond, with_if=False) | 
|  | ) | 
|  | nodelist.append(snode) | 
|  | return nodelist | 
|  |  | 
|  | def _add_doc(self, typ, sections): | 
|  | """Add documentation for a command/object/enum... | 
|  |  | 
|  | We assume we're documenting the thing defined in self._cur_doc. | 
|  | typ is the type of thing being added ("Command", "Object", etc) | 
|  |  | 
|  | sections is a list of nodes for sections to add to the definition. | 
|  | """ | 
|  |  | 
|  | doc = self._cur_doc | 
|  | snode = nodes.section(ids=[self._sphinx_directive.new_serialno()]) | 
|  | snode += nodes.title('', '', *[nodes.literal(doc.symbol, doc.symbol), | 
|  | nodes.Text(' (' + typ + ')')]) | 
|  | self._parse_text_into_node(doc.body.text, snode) | 
|  | for s in sections: | 
|  | if s is not None: | 
|  | snode += s | 
|  | self._add_node_to_current_heading(snode) | 
|  |  | 
|  | def visit_enum_type(self, name, info, ifcond, features, members, prefix): | 
|  | doc = self._cur_doc | 
|  | self._add_doc('Enum', | 
|  | self._nodes_for_enum_values(doc) | 
|  | + self._nodes_for_features(doc) | 
|  | + self._nodes_for_sections(doc) | 
|  | + self._nodes_for_if_section(ifcond)) | 
|  |  | 
|  | def visit_object_type(self, name, info, ifcond, features, | 
|  | base, members, variants): | 
|  | doc = self._cur_doc | 
|  | if base and base.is_implicit(): | 
|  | base = None | 
|  | self._add_doc('Object', | 
|  | self._nodes_for_members(doc, 'Members', base, variants) | 
|  | + self._nodes_for_features(doc) | 
|  | + self._nodes_for_sections(doc) | 
|  | + self._nodes_for_if_section(ifcond)) | 
|  |  | 
|  | def visit_alternate_type(self, name, info, ifcond, features, variants): | 
|  | doc = self._cur_doc | 
|  | self._add_doc('Alternate', | 
|  | self._nodes_for_members(doc, 'Members') | 
|  | + self._nodes_for_features(doc) | 
|  | + self._nodes_for_sections(doc) | 
|  | + self._nodes_for_if_section(ifcond)) | 
|  |  | 
|  | def visit_command(self, name, info, ifcond, features, arg_type, | 
|  | ret_type, gen, success_response, boxed, allow_oob, | 
|  | allow_preconfig, coroutine): | 
|  | doc = self._cur_doc | 
|  | self._add_doc('Command', | 
|  | self._nodes_for_arguments(doc, | 
|  | arg_type if boxed else None) | 
|  | + self._nodes_for_features(doc) | 
|  | + self._nodes_for_sections(doc) | 
|  | + self._nodes_for_if_section(ifcond)) | 
|  |  | 
|  | def visit_event(self, name, info, ifcond, features, arg_type, boxed): | 
|  | doc = self._cur_doc | 
|  | self._add_doc('Event', | 
|  | self._nodes_for_arguments(doc, | 
|  | arg_type if boxed else None) | 
|  | + self._nodes_for_features(doc) | 
|  | + self._nodes_for_sections(doc) | 
|  | + self._nodes_for_if_section(ifcond)) | 
|  |  | 
|  | def symbol(self, doc, entity): | 
|  | """Add documentation for one symbol to the document tree | 
|  |  | 
|  | This is the main entry point which causes us to add documentation | 
|  | nodes for a symbol (which could be a 'command', 'object', 'event', | 
|  | etc). We do this by calling 'visit' on the schema entity, which | 
|  | will then call back into one of our visit_* methods, depending | 
|  | on what kind of thing this symbol is. | 
|  | """ | 
|  | self._cur_doc = doc | 
|  | entity.visit(self) | 
|  | self._cur_doc = None | 
|  |  | 
|  | def _start_new_heading(self, heading, level): | 
|  | """Start a new heading at the specified heading level | 
|  |  | 
|  | Create a new section whose title is 'heading' and which is placed | 
|  | in the docutils node tree as a child of the most recent level-1 | 
|  | heading. Subsequent document sections (commands, freeform doc chunks, | 
|  | etc) will be placed as children of this new heading section. | 
|  | """ | 
|  | if len(self._active_headings) < level: | 
|  | raise QAPISemError(self._cur_doc.info, | 
|  | 'Level %d subheading found outside a ' | 
|  | 'level %d heading' | 
|  | % (level, level - 1)) | 
|  | snode = self._make_section(heading) | 
|  | self._active_headings[level - 1] += snode | 
|  | self._active_headings = self._active_headings[:level] | 
|  | self._active_headings.append(snode) | 
|  |  | 
|  | def _add_node_to_current_heading(self, node): | 
|  | """Add the node to whatever the current active heading is""" | 
|  | self._active_headings[-1] += node | 
|  |  | 
|  | def freeform(self, doc): | 
|  | """Add a piece of 'freeform' documentation to the document tree | 
|  |  | 
|  | A 'freeform' document chunk doesn't relate to any particular | 
|  | symbol (for instance, it could be an introduction). | 
|  |  | 
|  | If the freeform document starts with a line of the form | 
|  | '= Heading text', this is a section or subsection heading, with | 
|  | the heading level indicated by the number of '=' signs. | 
|  | """ | 
|  |  | 
|  | # QAPIDoc documentation says free-form documentation blocks | 
|  | # must have only a body section, nothing else. | 
|  | assert not doc.sections | 
|  | assert not doc.args | 
|  | assert not doc.features | 
|  | self._cur_doc = doc | 
|  |  | 
|  | text = doc.body.text | 
|  | if re.match(r'=+ ', text): | 
|  | # Section/subsection heading (if present, will always be | 
|  | # the first line of the block) | 
|  | (heading, _, text) = text.partition('\n') | 
|  | (leader, _, heading) = heading.partition(' ') | 
|  | self._start_new_heading(heading, len(leader)) | 
|  | if text == '': | 
|  | return | 
|  |  | 
|  | node = self._make_section(None) | 
|  | self._parse_text_into_node(text, node) | 
|  | self._add_node_to_current_heading(node) | 
|  | self._cur_doc = None | 
|  |  | 
|  | def _parse_text_into_node(self, doctext, node): | 
|  | """Parse a chunk of QAPI-doc-format text into the node | 
|  |  | 
|  | The doc comment can contain most inline rST markup, including | 
|  | bulleted and enumerated lists. | 
|  | As an extra permitted piece of markup, @var will be turned | 
|  | into ``var``. | 
|  | """ | 
|  |  | 
|  | # Handle the "@var means ``var`` case | 
|  | doctext = re.sub(r'@([\w-]+)', r'``\1``', doctext) | 
|  |  | 
|  | rstlist = ViewList() | 
|  | for line in doctext.splitlines(): | 
|  | # The reported line number will always be that of the start line | 
|  | # of the doc comment, rather than the actual location of the error. | 
|  | # Being more precise would require overhaul of the QAPIDoc class | 
|  | # to track lines more exactly within all the sub-parts of the doc | 
|  | # comment, as well as counting lines here. | 
|  | rstlist.append(line, self._cur_doc.info.fname, | 
|  | self._cur_doc.info.line) | 
|  | # Append a blank line -- in some cases rST syntax errors get | 
|  | # attributed to the line after one with actual text, and if there | 
|  | # isn't anything in the ViewList corresponding to that then Sphinx | 
|  | # 1.6's AutodocReporter will then misidentify the source/line location | 
|  | # in the error message (usually attributing it to the top-level | 
|  | # .rst file rather than the offending .json file). The extra blank | 
|  | # line won't affect the rendered output. | 
|  | rstlist.append("", self._cur_doc.info.fname, self._cur_doc.info.line) | 
|  | self._sphinx_directive.do_parse(rstlist, node) | 
|  |  | 
|  | def get_document_nodes(self): | 
|  | """Return the list of docutils nodes which make up the document""" | 
|  | return self._top_node.children | 
|  |  | 
|  |  | 
|  | class QAPISchemaGenDepVisitor(QAPISchemaVisitor): | 
|  | """A QAPI schema visitor which adds Sphinx dependencies each module | 
|  |  | 
|  | This class calls the Sphinx note_dependency() function to tell Sphinx | 
|  | that the generated documentation output depends on the input | 
|  | schema file associated with each module in the QAPI input. | 
|  | """ | 
|  | def __init__(self, env, qapidir): | 
|  | self._env = env | 
|  | self._qapidir = qapidir | 
|  |  | 
|  | def visit_module(self, name): | 
|  | if name != "./builtin": | 
|  | qapifile = self._qapidir + '/' + name | 
|  | self._env.note_dependency(os.path.abspath(qapifile)) | 
|  | super().visit_module(name) | 
|  |  | 
|  |  | 
|  | class QAPIDocDirective(Directive): | 
|  | """Extract documentation from the specified QAPI .json file""" | 
|  | required_argument = 1 | 
|  | optional_arguments = 1 | 
|  | option_spec = { | 
|  | 'qapifile': directives.unchanged_required | 
|  | } | 
|  | has_content = False | 
|  |  | 
|  | def new_serialno(self): | 
|  | """Return a unique new ID string suitable for use as a node's ID""" | 
|  | env = self.state.document.settings.env | 
|  | return 'qapidoc-%d' % env.new_serialno('qapidoc') | 
|  |  | 
|  | def run(self): | 
|  | env = self.state.document.settings.env | 
|  | qapifile = env.config.qapidoc_srctree + '/' + self.arguments[0] | 
|  | qapidir = os.path.dirname(qapifile) | 
|  |  | 
|  | try: | 
|  | schema = QAPISchema(qapifile) | 
|  |  | 
|  | # First tell Sphinx about all the schema files that the | 
|  | # output documentation depends on (including 'qapifile' itself) | 
|  | schema.visit(QAPISchemaGenDepVisitor(env, qapidir)) | 
|  |  | 
|  | vis = QAPISchemaGenRSTVisitor(self) | 
|  | vis.visit_begin(schema) | 
|  | for doc in schema.docs: | 
|  | if doc.symbol: | 
|  | vis.symbol(doc, schema.lookup_entity(doc.symbol)) | 
|  | else: | 
|  | vis.freeform(doc) | 
|  | return vis.get_document_nodes() | 
|  | except QAPIError as err: | 
|  | # Launder QAPI parse errors into Sphinx extension errors | 
|  | # so they are displayed nicely to the user | 
|  | raise ExtensionError(str(err)) | 
|  |  | 
|  | def do_parse(self, rstlist, node): | 
|  | """Parse rST source lines and add them to the specified node | 
|  |  | 
|  | Take the list of rST source lines rstlist, parse them as | 
|  | rST, and add the resulting docutils nodes as children of node. | 
|  | The nodes are parsed in a way that allows them to include | 
|  | subheadings (titles) without confusing the rendering of | 
|  | anything else. | 
|  | """ | 
|  | # This is from kerneldoc.py -- it works around an API change in | 
|  | # Sphinx between 1.6 and 1.7. Unlike kerneldoc.py, we use | 
|  | # sphinx.util.nodes.nested_parse_with_titles() rather than the | 
|  | # plain self.state.nested_parse(), and so we can drop the saving | 
|  | # of title_styles and section_level that kerneldoc.py does, | 
|  | # because nested_parse_with_titles() does that for us. | 
|  | if Use_SSI: | 
|  | with switch_source_input(self.state, rstlist): | 
|  | nested_parse_with_titles(self.state, rstlist, node) | 
|  | else: | 
|  | save = self.state.memo.reporter | 
|  | self.state.memo.reporter = AutodocReporter( | 
|  | rstlist, self.state.memo.reporter) | 
|  | try: | 
|  | nested_parse_with_titles(self.state, rstlist, node) | 
|  | finally: | 
|  | self.state.memo.reporter = save | 
|  |  | 
|  |  | 
|  | def setup(app): | 
|  | """ Register qapi-doc directive with Sphinx""" | 
|  | app.add_config_value('qapidoc_srctree', None, 'env') | 
|  | app.add_directive('qapi-doc', QAPIDocDirective) | 
|  |  | 
|  | return dict( | 
|  | version=__version__, | 
|  | parallel_read_safe=True, | 
|  | parallel_write_safe=True | 
|  | ) |