xref: /qemu/docs/sphinx/compat.py (revision d64db833d6e3cbe9ea5f36342480f920f3675cea)
1"""
2Sphinx cross-version compatibility goop
3"""
4
5import re
6from typing import (
7    TYPE_CHECKING,
8    Any,
9    Callable,
10    Optional,
11    Type,
12)
13
14from docutils import nodes
15from docutils.nodes import Element, Node, Text
16from docutils.statemachine import StringList
17
18import sphinx
19from sphinx import addnodes, util
20from sphinx.directives import ObjectDescription
21from sphinx.environment import BuildEnvironment
22from sphinx.roles import XRefRole
23from sphinx.util import docfields
24from sphinx.util.docutils import (
25    ReferenceRole,
26    SphinxDirective,
27    switch_source_input,
28)
29from sphinx.util.typing import TextlikeNode
30
31
32MAKE_XREF_WORKAROUND = sphinx.version_info[:3] < (4, 1, 0)
33
34
35SpaceNode: Callable[[str], Node]
36KeywordNode: Callable[[str, str], Node]
37
38if sphinx.version_info[:3] >= (4, 0, 0):
39    SpaceNode = addnodes.desc_sig_space
40    KeywordNode = addnodes.desc_sig_keyword
41else:
42    SpaceNode = Text
43    KeywordNode = addnodes.desc_annotation
44
45
46def nested_parse_with_titles(
47    directive: SphinxDirective, content_node: Element
48) -> None:
49    """
50    This helper preserves error parsing context across sphinx versions.
51    """
52
53    # necessary so that the child nodes get the right source/line set
54    content_node.document = directive.state.document
55
56    try:
57        # Modern sphinx (6.2.0+) supports proper offsetting for
58        # nested parse error context management
59        util.nodes.nested_parse_with_titles(
60            directive.state,
61            directive.content,
62            content_node,
63            content_offset=directive.content_offset,
64        )
65    except TypeError:
66        # No content_offset argument. Fall back to SSI method.
67        with switch_source_input(directive.state, directive.content):
68            util.nodes.nested_parse_with_titles(
69                directive.state, directive.content, content_node
70            )
71
72
73# ###########################################
74# xref compatibility hacks for Sphinx < 4.1 #
75# ###########################################
76
77# When we require >= Sphinx 4.1, the following function and the
78# subsequent 3 compatibility classes can be removed. Anywhere in
79# qapi_domain that uses one of these Compat* types can be switched to
80# using the garden-variety lib-provided classes with no trickery.
81
82
83def _compat_make_xref(  # pylint: disable=unused-argument
84    self: sphinx.util.docfields.Field,
85    rolename: str,
86    domain: str,
87    target: str,
88    innernode: Type[TextlikeNode] = addnodes.literal_emphasis,
89    contnode: Optional[Node] = None,
90    env: Optional[BuildEnvironment] = None,
91    inliner: Any = None,
92    location: Any = None,
93) -> Node:
94    """
95    Compatibility workaround for Sphinx versions prior to 4.1.0.
96
97    Older sphinx versions do not use the domain's XRefRole for parsing
98    and formatting cross-references, so we need to perform this magick
99    ourselves to avoid needing to write the parser/formatter in two
100    separate places.
101
102    This workaround isn't brick-for-brick compatible with modern Sphinx
103    versions, because we do not have access to the parent directive's
104    state during this parsing like we do in more modern versions.
105
106    It's no worse than what pre-Sphinx 4.1.0 does, so... oh well!
107    """
108
109    # Yes, this function is gross. Pre-4.1 support is a miracle.
110    # pylint: disable=too-many-locals
111
112    assert env
113    # Note: Sphinx's own code ignores the type warning here, too.
114    if not rolename:
115        return contnode or innernode(target, target)  # type: ignore[call-arg]
116
117    # Get the role instance, but don't *execute it* - we lack the
118    # correct state to do so. Instead, we'll just use its public
119    # methods to do our reference formatting, and emulate the rest.
120    role = env.get_domain(domain).roles[rolename]
121    assert isinstance(role, XRefRole)
122
123    # XRefRole features not supported by this compatibility shim;
124    # these were not supported in Sphinx 3.x either, so nothing of
125    # value is really lost.
126    assert not target.startswith("!")
127    assert not re.match(ReferenceRole.explicit_title_re, target)
128    assert not role.lowercase
129    assert not role.fix_parens
130
131    # Code below based mostly on sphinx.roles.XRefRole; run() and
132    # create_xref_node()
133    options = {
134        "refdoc": env.docname,
135        "refdomain": domain,
136        "reftype": rolename,
137        "refexplicit": False,
138        "refwarn": role.warn_dangling,
139    }
140    refnode = role.nodeclass(target, **options)
141    title, target = role.process_link(env, refnode, False, target, target)
142    refnode["reftarget"] = target
143    classes = ["xref", domain, f"{domain}-{rolename}"]
144    refnode += role.innernodeclass(target, title, classes=classes)
145
146    # This is the very gross part of the hack. Normally,
147    # result_nodes takes a document object to which we would pass
148    # self.inliner.document. Prior to Sphinx 4.1, we don't *have* an
149    # inliner to pass, so we have nothing to pass here. However, the
150    # actual implementation of role.result_nodes in this case
151    # doesn't actually use that argument, so this winds up being
152    # ... fine. Rest easy at night knowing this code only runs under
153    # old versions of Sphinx, so at least it won't change in the
154    # future on us and lead to surprising new failures.
155    # Gross, I know.
156    result_nodes, _messages = role.result_nodes(
157        None,  # type: ignore
158        env,
159        refnode,
160        is_ref=True,
161    )
162    return nodes.inline(target, "", *result_nodes)
163
164
165class CompatField(docfields.Field):
166    if MAKE_XREF_WORKAROUND:
167        make_xref = _compat_make_xref
168
169
170class CompatGroupedField(docfields.GroupedField):
171    if MAKE_XREF_WORKAROUND:
172        make_xref = _compat_make_xref
173
174
175class CompatTypedField(docfields.TypedField):
176    if MAKE_XREF_WORKAROUND:
177        make_xref = _compat_make_xref
178
179
180# ################################################################
181# Nested parsing error location fix for Sphinx 5.3.0 < x < 6.2.0 #
182# ################################################################
183
184# When we require Sphinx 4.x, the TYPE_CHECKING hack where we avoid
185# subscripting ObjectDescription at runtime can be removed in favor of
186# just always subscripting the class.
187
188# When we require Sphinx > 6.2.0, the rest of this compatibility hack
189# can be dropped and QAPIObject can just inherit directly from
190# ObjectDescription[Signature].
191
192SOURCE_LOCATION_FIX = (5, 3, 0) <= sphinx.version_info[:3] < (6, 2, 0)
193
194Signature = str
195
196
197if TYPE_CHECKING:
198    _BaseClass = ObjectDescription[Signature]
199else:
200    _BaseClass = ObjectDescription
201
202
203class ParserFix(_BaseClass):
204
205    _temp_content: StringList
206    _temp_offset: int
207    _temp_node: Optional[addnodes.desc_content]
208
209    def before_content(self) -> None:
210        # Work around a sphinx bug and parse the content ourselves.
211        self._temp_content = self.content
212        self._temp_offset = self.content_offset
213        self._temp_node = None
214
215        if SOURCE_LOCATION_FIX:
216            self._temp_node = addnodes.desc_content()
217            self.state.nested_parse(
218                self.content, self.content_offset, self._temp_node
219            )
220            # Sphinx will try to parse the content block itself,
221            # Give it nothingness to parse instead.
222            self.content = StringList()
223            self.content_offset = 0
224
225    def transform_content(self, content_node: addnodes.desc_content) -> None:
226        # Sphinx workaround: Inject our parsed content and restore state.
227        if self._temp_node:
228            content_node += self._temp_node.children
229            self.content = self._temp_content
230            self.content_offset = self._temp_offset
231