| 1 | """ |
| 2 | Sphinx cross-version compatibility goop |
| 3 | """ |
| 4 | |
| 5 | import re |
| 6 | from typing import ( |
| 7 | TYPE_CHECKING, |
| 8 | Any, |
| 9 | Callable, |
| 10 | Optional, |
| 11 | Type, |
| 12 | ) |
| 13 | |
| 14 | from docutils import nodes |
| 15 | from docutils.nodes import Element, Node, Text |
| 16 | from docutils.statemachine import StringList |
| 17 | |
| 18 | import sphinx |
| 19 | from sphinx import addnodes, util |
| 20 | from sphinx.directives import ObjectDescription |
| 21 | from sphinx.environment import BuildEnvironment |
| 22 | from sphinx.roles import XRefRole |
| 23 | from sphinx.util import docfields |
| 24 | from sphinx.util.docutils import ( |
| 25 | ReferenceRole, |
| 26 | SphinxDirective, |
| 27 | switch_source_input, |
| 28 | ) |
| 29 | from sphinx.util.typing import TextlikeNode |
| 30 | |
| 31 | |
| 32 | MAKE_XREF_WORKAROUND = sphinx.version_info[:3] < (4, 1, 0) |
| 33 | |
| 34 | |
| 35 | SpaceNode: Callable[[str], Node] |
| 36 | KeywordNode: Callable[[str, str], Node] |
| 37 | |
| 38 | if sphinx.version_info[:3] >= (4, 0, 0): |
| 39 | SpaceNode = addnodes.desc_sig_space |
| 40 | KeywordNode = addnodes.desc_sig_keyword |
| 41 | else: |
| 42 | SpaceNode = Text |
| 43 | KeywordNode = addnodes.desc_annotation |
| 44 | |
| 45 | |
| 46 | def 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 | |
| 83 | def _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 | |
| 165 | class CompatField(docfields.Field): |
| 166 | if MAKE_XREF_WORKAROUND: |
| 167 | make_xref = _compat_make_xref |
| 168 | |
| 169 | |
| 170 | class CompatGroupedField(docfields.GroupedField): |
| 171 | if MAKE_XREF_WORKAROUND: |
| 172 | make_xref = _compat_make_xref |
| 173 | |
| 174 | |
| 175 | class 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 | |
| 192 | SOURCE_LOCATION_FIX = (5, 3, 0) <= sphinx.version_info[:3] < (6, 2, 0) |
| 193 | |
| 194 | Signature = str |
| 195 | |
| 196 | |
| 197 | if TYPE_CHECKING: |
| 198 | _BaseClass = ObjectDescription[Signature] |
| 199 | else: |
| 200 | _BaseClass = ObjectDescription |
| 201 | |
| 202 | |
| 203 | class 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 |