1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733
|
# coding=utf-8
#
# QEMU qapidoc QAPI file parsing extension
#
# Copyright (c) 2024-2025 Red Hat
# 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
"""
from __future__ import annotations
__version__ = "2.0"
from contextlib import contextmanager
import os
from pathlib import Path
import re
import sys
from typing import TYPE_CHECKING
from docutils import nodes
from docutils.parsers.rst import directives
from docutils.statemachine import StringList
from qapi.error import QAPIError
from qapi.parser import QAPIDoc
from qapi.schema import (
QAPISchema,
QAPISchemaArrayType,
QAPISchemaCommand,
QAPISchemaDefinition,
QAPISchemaEnumMember,
QAPISchemaEvent,
QAPISchemaFeature,
QAPISchemaMember,
QAPISchemaObjectType,
QAPISchemaObjectTypeMember,
QAPISchemaType,
QAPISchemaVisitor,
)
from qapi.source import QAPISourceInfo
from qapidoc_legacy import QAPISchemaGenRSTVisitor # type: ignore
from sphinx import addnodes
from sphinx.directives.code import CodeBlock
from sphinx.errors import ExtensionError
from sphinx.util import logging
from sphinx.util.docutils import SphinxDirective, switch_source_input
from sphinx.util.nodes import nested_parse_with_titles
if TYPE_CHECKING:
from typing import (
Any,
Generator,
List,
Optional,
Sequence,
Union,
)
from sphinx.application import Sphinx
from sphinx.util.typing import ExtensionMetadata
logger = logging.getLogger(__name__)
class Transmogrifier:
# pylint: disable=too-many-public-methods
# Field names used for different entity types:
field_types = {
"enum": "value",
"struct": "memb",
"union": "memb",
"event": "memb",
"command": "arg",
"alternate": "alt",
}
def __init__(self) -> None:
self._curr_ent: Optional[QAPISchemaDefinition] = None
self._result = StringList()
self.indent = 0
@property
def result(self) -> StringList:
return self._result
@property
def entity(self) -> QAPISchemaDefinition:
assert self._curr_ent is not None
return self._curr_ent
@property
def member_field_type(self) -> str:
return self.field_types[self.entity.meta]
# General-purpose rST generation functions
def get_indent(self) -> str:
return " " * self.indent
@contextmanager
def indented(self) -> Generator[None]:
self.indent += 1
try:
yield
finally:
self.indent -= 1
def add_line_raw(self, line: str, source: str, *lineno: int) -> None:
"""Append one line of generated reST to the output."""
# NB: Sphinx uses zero-indexed lines; subtract one.
lineno = tuple((n - 1 for n in lineno))
if line.strip():
# not a blank line
self._result.append(
self.get_indent() + line.rstrip("\n"), source, *lineno
)
else:
self._result.append("", source, *lineno)
def add_line(self, content: str, info: QAPISourceInfo) -> None:
# NB: We *require* an info object; this works out OK because we
# don't document built-in objects that don't have
# one. Everything else should.
self.add_line_raw(content, info.fname, info.line)
def add_lines(
self,
content: str,
info: QAPISourceInfo,
) -> None:
lines = content.splitlines(True)
for i, line in enumerate(lines):
self.add_line_raw(line, info.fname, info.line + i)
def ensure_blank_line(self) -> None:
# Empty document -- no blank line required.
if not self._result:
return
# Last line isn't blank, add one.
if self._result[-1].strip(): # pylint: disable=no-member
fname, line = self._result.info(-1)
assert isinstance(line, int)
# New blank line is credited to one-after the current last line.
# +2: correct for zero/one index, then increment by one.
self.add_line_raw("", fname, line + 2)
def add_field(
self,
kind: str,
name: str,
body: str,
info: QAPISourceInfo,
typ: Optional[str] = None,
) -> None:
if typ:
text = f":{kind} {typ} {name}: {body}"
else:
text = f":{kind} {name}: {body}"
self.add_lines(text, info)
def format_type(
self, ent: Union[QAPISchemaDefinition | QAPISchemaMember]
) -> Optional[str]:
if isinstance(ent, (QAPISchemaEnumMember, QAPISchemaFeature)):
return None
qapi_type = ent
optional = False
if isinstance(ent, QAPISchemaObjectTypeMember):
qapi_type = ent.type
optional = ent.optional
if isinstance(qapi_type, QAPISchemaArrayType):
ret = f"[{qapi_type.element_type.doc_type()}]"
else:
assert isinstance(qapi_type, QAPISchemaType)
tmp = qapi_type.doc_type()
assert tmp
ret = tmp
if optional:
ret += "?"
return ret
def generate_field(
self,
kind: str,
member: QAPISchemaMember,
body: str,
info: QAPISourceInfo,
) -> None:
typ = self.format_type(member)
self.add_field(kind, member.name, body, info, typ)
# Transmogrification helpers
def visit_paragraph(self, section: QAPIDoc.Section) -> None:
# Squelch empty paragraphs.
if not section.text:
return
self.ensure_blank_line()
self.add_lines(section.text, section.info)
self.ensure_blank_line()
def visit_member(self, section: QAPIDoc.ArgSection) -> None:
# FIXME: ifcond for members
# TODO: features for members (documented at entity-level,
# but sometimes defined per-member. Should we add such
# information to member descriptions when we can?)
assert section.member
self.generate_field(
self.member_field_type,
section.member,
# TODO drop fallbacks when undocumented members are outlawed
section.text if section.text else "Not documented",
section.info,
)
def visit_feature(self, section: QAPIDoc.ArgSection) -> None:
# FIXME - ifcond for features is not handled at all yet!
# Proposal: decorate the right-hand column with some graphical
# element to indicate conditional availability?
assert section.text # Guaranteed by parser.py
assert section.member
self.generate_field("feat", section.member, section.text, section.info)
def visit_returns(self, section: QAPIDoc.Section) -> None:
assert isinstance(self.entity, QAPISchemaCommand)
rtype = self.entity.ret_type
# q_empty can produce None, but we won't be documenting anything
# without an explicit return statement in the doc block, and we
# should not have any such explicit statements when there is no
# return value.
assert rtype
typ = self.format_type(rtype)
assert typ
assert section.text
self.add_field("return", typ, section.text, section.info)
def visit_errors(self, section: QAPIDoc.Section) -> None:
# FIXME: the formatting for errors may be inconsistent and may
# or may not require different newline placement to ensure
# proper rendering as a nested list.
self.add_lines(f":error:\n{section.text}", section.info)
def preamble(self, ent: QAPISchemaDefinition) -> None:
"""
Generate option lines for QAPI entity directives.
"""
if ent.doc and ent.doc.since:
assert ent.doc.since.kind == QAPIDoc.Kind.SINCE
# Generated from the entity's docblock; info location is exact.
self.add_line(f":since: {ent.doc.since.text}", ent.doc.since.info)
if ent.ifcond.is_present():
doc = ent.ifcond.docgen()
assert ent.info
# Generated from entity definition; info location is approximate.
self.add_line(f":ifcond: {doc}", ent.info)
# Hoist special features such as :deprecated: and :unstable:
# into the options block for the entity. If, in the future, new
# special features are added, qapi-domain will chirp about
# unrecognized options and fail until they are handled in
# qapi-domain.
for feat in ent.features:
if feat.is_special():
# FIXME: handle ifcond if present. How to display that
# information is TBD.
# Generated from entity def; info location is approximate.
assert feat.info
self.add_line(f":{feat.name}:", feat.info)
self.ensure_blank_line()
def _insert_member_pointer(self, ent: QAPISchemaDefinition) -> None:
def _get_target(
ent: QAPISchemaDefinition,
) -> Optional[QAPISchemaDefinition]:
if isinstance(ent, (QAPISchemaCommand, QAPISchemaEvent)):
return ent.arg_type
if isinstance(ent, QAPISchemaObjectType):
return ent.base
return None
target = _get_target(ent)
if target is not None and not target.is_implicit():
assert ent.info
self.add_field(
self.member_field_type,
"q_dummy",
f"The members of :qapi:type:`{target.name}`.",
ent.info,
"q_dummy",
)
if isinstance(ent, QAPISchemaObjectType) and ent.branches is not None:
for variant in ent.branches.variants:
if variant.type.name == "q_empty":
continue
assert ent.info
self.add_field(
self.member_field_type,
"q_dummy",
f" When ``{ent.branches.tag_member.name}`` is "
f"``{variant.name}``: "
f"The members of :qapi:type:`{variant.type.name}`.",
ent.info,
"q_dummy",
)
def visit_sections(self, ent: QAPISchemaDefinition) -> None:
sections = ent.doc.all_sections if ent.doc else []
# Determine the index location at which we should generate
# documentation for "The members of ..." pointers. This should
# go at the end of the members section(s) if any. Note that
# index 0 is assumed to be a plain intro section, even if it is
# empty; and that a members section if present will always
# immediately follow the opening PLAIN section.
gen_index = 1
if len(sections) > 1:
while sections[gen_index].kind == QAPIDoc.Kind.MEMBER:
gen_index += 1
if gen_index >= len(sections):
break
# Add sections in source order:
for i, section in enumerate(sections):
# @var is translated to ``var``:
section.text = re.sub(r"@([\w-]+)", r"``\1``", section.text)
if section.kind == QAPIDoc.Kind.PLAIN:
self.visit_paragraph(section)
elif section.kind == QAPIDoc.Kind.MEMBER:
assert isinstance(section, QAPIDoc.ArgSection)
self.visit_member(section)
elif section.kind == QAPIDoc.Kind.FEATURE:
assert isinstance(section, QAPIDoc.ArgSection)
self.visit_feature(section)
elif section.kind in (QAPIDoc.Kind.SINCE, QAPIDoc.Kind.TODO):
# Since is handled in preamble, TODO is skipped intentionally.
pass
elif section.kind == QAPIDoc.Kind.RETURNS:
self.visit_returns(section)
elif section.kind == QAPIDoc.Kind.ERRORS:
self.visit_errors(section)
else:
assert False
# Generate "The members of ..." entries if necessary:
if i == gen_index - 1:
self._insert_member_pointer(ent)
self.ensure_blank_line()
# Transmogrification core methods
def visit_module(self, path: str) -> None:
name = Path(path).stem
# module directives are credited to the first line of a module file.
self.add_line_raw(f".. qapi:module:: {name}", path, 1)
self.ensure_blank_line()
def visit_freeform(self, doc: QAPIDoc) -> None:
# TODO: Once the old qapidoc transformer is deprecated, freeform
# sections can be updated to pure rST, and this transformed removed.
#
# For now, translate our micro-format into rST. Code adapted
# from Peter Maydell's freeform().
assert len(doc.all_sections) == 1, doc.all_sections
body = doc.all_sections[0]
text = body.text
info = doc.info
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(" ")
# Implicit +1 for heading in the containing .rst doc
level = len(leader) + 1
# https://www.sphinx-doc.org/en/master/usage/restructuredtext/basics.html#sections
markers = ' #*=_^"'
overline = level <= 2
marker = markers[level]
self.ensure_blank_line()
# This credits all 2 or 3 lines to the single source line.
if overline:
self.add_line(marker * len(heading), info)
self.add_line(heading, info)
self.add_line(marker * len(heading), info)
self.ensure_blank_line()
# Eat blank line(s) and advance info
trimmed = text.lstrip("\n")
text = trimmed
info = info.next_line(len(text) - len(trimmed) + 1)
self.add_lines(text, info)
self.ensure_blank_line()
def visit_entity(self, ent: QAPISchemaDefinition) -> None:
assert ent.info
try:
self._curr_ent = ent
# Squish structs and unions together into an "object" directive.
meta = ent.meta
if meta in ("struct", "union"):
meta = "object"
# This line gets credited to the start of the /definition/.
self.add_line(f".. qapi:{meta}:: {ent.name}", ent.info)
with self.indented():
self.preamble(ent)
self.visit_sections(ent)
finally:
self._curr_ent = None
def set_namespace(self, namespace: str, source: str, lineno: int) -> None:
self.add_line_raw(
f".. qapi:namespace:: {namespace}", source, lineno + 1
)
self.ensure_blank_line()
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: Any, qapidir: str) -> None:
self._env = env
self._qapidir = qapidir
def visit_module(self, name: str) -> None:
if name != "./builtin":
qapifile = self._qapidir + "/" + name
self._env.note_dependency(os.path.abspath(qapifile))
super().visit_module(name)
class NestedDirective(SphinxDirective):
def run(self) -> Sequence[nodes.Node]:
raise NotImplementedError
def do_parse(self, rstlist: StringList, node: nodes.Node) -> None:
"""
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.
"""
with switch_source_input(self.state, rstlist):
nested_parse_with_titles(self.state, rstlist, node)
class QAPIDocDirective(NestedDirective):
"""Extract documentation from the specified QAPI .json file"""
required_argument = 1
optional_arguments = 1
option_spec = {
"qapifile": directives.unchanged_required,
"namespace": directives.unchanged,
"transmogrify": directives.flag,
}
has_content = False
def new_serialno(self) -> str:
"""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 transmogrify(self, schema: QAPISchema) -> nodes.Element:
logger.info("Transmogrifying QAPI to rST ...")
vis = Transmogrifier()
modules = set()
if "namespace" in self.options:
vis.set_namespace(
self.options["namespace"], *self.get_source_info()
)
for doc in schema.docs:
module_source = doc.info.fname
if module_source not in modules:
vis.visit_module(module_source)
modules.add(module_source)
if doc.symbol:
ent = schema.lookup_entity(doc.symbol)
assert isinstance(ent, QAPISchemaDefinition)
vis.visit_entity(ent)
else:
vis.visit_freeform(doc)
logger.info("Transmogrification complete.")
contentnode = nodes.section()
content = vis.result
titles_allowed = True
logger.info("Transmogrifier running nested parse ...")
with switch_source_input(self.state, content):
if titles_allowed:
node: nodes.Element = nodes.section()
node.document = self.state.document
nested_parse_with_titles(self.state, content, contentnode)
else:
node = nodes.paragraph()
node.document = self.state.document
self.state.nested_parse(content, 0, contentnode)
logger.info("Transmogrifier's nested parse completed.")
if self.env.app.verbosity >= 2 or os.environ.get("DEBUG"):
argname = "_".join(Path(self.arguments[0]).parts)
name = Path(argname).stem + ".ir"
self.write_intermediate(content, name)
sys.stdout.flush()
return contentnode
def write_intermediate(self, content: StringList, filename: str) -> None:
logger.info(
"writing intermediate rST for '%s' to '%s'",
self.arguments[0],
filename,
)
srctree = Path(self.env.app.config.qapidoc_srctree).resolve()
outlines = []
lcol_width = 0
for i, line in enumerate(content):
src, lineno = content.info(i)
srcpath = Path(src).resolve()
srcpath = srcpath.relative_to(srctree)
lcol = f"{srcpath}:{lineno:04d}"
lcol_width = max(lcol_width, len(lcol))
outlines.append((lcol, line))
with open(filename, "w", encoding="UTF-8") as outfile:
for lcol, rcol in outlines:
outfile.write(lcol.rjust(lcol_width))
outfile.write(" |")
if rcol:
outfile.write(f" {rcol}")
outfile.write("\n")
def legacy(self, schema: QAPISchema) -> nodes.Element:
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_node() # type: ignore
def run(self) -> Sequence[nodes.Node]:
env = self.state.document.settings.env
qapifile = env.config.qapidoc_srctree + "/" + self.arguments[0]
qapidir = os.path.dirname(qapifile)
transmogrify = "transmogrify" in self.options
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))
except QAPIError as err:
# Launder QAPI parse errors into Sphinx extension errors
# so they are displayed nicely to the user
raise ExtensionError(str(err)) from err
if transmogrify:
contentnode = self.transmogrify(schema)
else:
contentnode = self.legacy(schema)
return contentnode.children
class QMPExample(CodeBlock, NestedDirective):
"""
Custom admonition for QMP code examples.
When the :annotated: option is present, the body of this directive
is parsed as normal rST, but with any '::' code blocks set to use
the QMP lexer. Code blocks must be explicitly written by the user,
but this allows for intermingling explanatory paragraphs with
arbitrary rST syntax and code blocks for more involved examples.
When :annotated: is absent, the directive body is treated as a
simple standalone QMP code block literal.
"""
required_argument = 0
optional_arguments = 0
has_content = True
option_spec = {
"annotated": directives.flag,
"title": directives.unchanged,
}
def _highlightlang(self) -> addnodes.highlightlang:
"""Return the current highlightlang setting for the document"""
node = None
doc = self.state.document
if hasattr(doc, "findall"):
# docutils >= 0.18.1
for node in doc.findall(addnodes.highlightlang):
pass
else:
for elem in doc.traverse():
if isinstance(elem, addnodes.highlightlang):
node = elem
if node:
return node
# No explicit directive found, use defaults
node = addnodes.highlightlang(
lang=self.env.config.highlight_language,
force=False,
# Yes, Sphinx uses this value to effectively disable line
# numbers and not 0 or None or -1 or something. ¯\_(ツ)_/¯
linenothreshold=sys.maxsize,
)
return node
def admonition_wrap(self, *content: nodes.Node) -> List[nodes.Node]:
title = "Example:"
if "title" in self.options:
title = f"{title} {self.options['title']}"
admon = nodes.admonition(
"",
nodes.title("", title),
*content,
classes=["admonition", "admonition-example"],
)
return [admon]
def run_annotated(self) -> List[nodes.Node]:
lang_node = self._highlightlang()
content_node: nodes.Element = nodes.section()
# Configure QMP highlighting for "::" blocks, if needed
if lang_node["lang"] != "QMP":
content_node += addnodes.highlightlang(
lang="QMP",
force=False, # "True" ignores lexing errors
linenothreshold=lang_node["linenothreshold"],
)
self.do_parse(self.content, content_node)
# Restore prior language highlighting, if needed
if lang_node["lang"] != "QMP":
content_node += addnodes.highlightlang(**lang_node.attributes)
return content_node.children
def run(self) -> List[nodes.Node]:
annotated = "annotated" in self.options
if annotated:
content_nodes = self.run_annotated()
else:
self.arguments = ["QMP"]
content_nodes = super().run()
return self.admonition_wrap(*content_nodes)
def setup(app: Sphinx) -> ExtensionMetadata:
"""Register qapi-doc directive with Sphinx"""
app.setup_extension("qapi_domain")
app.add_config_value("qapidoc_srctree", None, "env")
app.add_directive("qapi-doc", QAPIDocDirective)
app.add_directive("qmp-example", QMPExample)
return {
"version": __version__,
"parallel_read_safe": True,
"parallel_write_safe": True,
}
|