1"""Support annotations for C API elements. 2 3* Reference count annotations for C API functions. 4* Stable ABI annotations 5* Limited API annotations 6 7Configuration: 8* Set ``refcount_file`` to the path to the reference count data file. 9* Set ``stable_abi_file`` to the path to stable ABI list. 10""" 11 12from __future__ import annotations 13 14import csv 15import dataclasses 16from pathlib import Path 17from typing import TYPE_CHECKING 18 19import sphinx 20from docutils import nodes 21from docutils.statemachine import StringList 22from sphinx import addnodes 23from sphinx.locale import _ as sphinx_gettext 24from sphinx.util.docutils import SphinxDirective 25 26if TYPE_CHECKING: 27 from sphinx.application import Sphinx 28 from sphinx.util.typing import ExtensionMetadata 29 30ROLE_TO_OBJECT_TYPE = { 31 "func": "function", 32 "macro": "macro", 33 "member": "member", 34 "type": "type", 35 "data": "var", 36} 37 38 39@dataclasses.dataclass(slots=True) 40class RefCountEntry: 41 # Name of the function. 42 name: str 43 # List of (argument name, type, refcount effect) tuples. 44 # (Currently not used. If it was, a dataclass might work better.) 45 args: list = dataclasses.field(default_factory=list) 46 # Return type of the function. 47 result_type: str = "" 48 # Reference count effect for the return value. 49 result_refs: int | None = None 50 51 52@dataclasses.dataclass(frozen=True, slots=True) 53class StableABIEntry: 54 # Role of the object. 55 # Source: Each [item_kind] in stable_abi.toml is mapped to a C Domain role. 56 role: str 57 # Name of the object. 58 # Source: [<item_kind>.*] in stable_abi.toml. 59 name: str 60 # Version when the object was added to the stable ABI. 61 # (Source: [<item_kind>.*.added] in stable_abi.toml. 62 added: str 63 # An explananatory blurb for the ifdef. 64 # Source: ``feature_macro.*.doc`` in stable_abi.toml. 65 ifdef_note: str 66 # Defines how much of the struct is exposed. Only relevant for structs. 67 # Source: [<item_kind>.*.struct_abi_kind] in stable_abi.toml. 68 struct_abi_kind: str 69 70 71def read_refcount_data(refcount_filename: Path) -> dict[str, RefCountEntry]: 72 refcount_data = {} 73 refcounts = refcount_filename.read_text(encoding="utf8") 74 for line in refcounts.splitlines(): 75 line = line.strip() 76 if not line or line.startswith("#"): 77 # blank lines and comments 78 continue 79 80 # Each line is of the form 81 # function ':' type ':' [param name] ':' [refcount effect] ':' [comment] 82 parts = line.split(":", 4) 83 if len(parts) != 5: 84 raise ValueError(f"Wrong field count in {line!r}") 85 function, type, arg, refcount, _comment = parts 86 87 # Get the entry, creating it if needed: 88 try: 89 entry = refcount_data[function] 90 except KeyError: 91 entry = refcount_data[function] = RefCountEntry(function) 92 if not refcount or refcount == "null": 93 refcount = None 94 else: 95 refcount = int(refcount) 96 # Update the entry with the new parameter 97 # or the result information. 98 if arg: 99 entry.args.append((arg, type, refcount)) 100 else: 101 entry.result_type = type 102 entry.result_refs = refcount 103 104 return refcount_data 105 106 107def read_stable_abi_data(stable_abi_file: Path) -> dict[str, StableABIEntry]: 108 stable_abi_data = {} 109 with open(stable_abi_file, encoding="utf8") as fp: 110 for record in csv.DictReader(fp): 111 name = record["name"] 112 stable_abi_data[name] = StableABIEntry(**record) 113 114 return stable_abi_data 115 116 117def add_annotations(app: Sphinx, doctree: nodes.document) -> None: 118 state = app.env.domaindata["c_annotations"] 119 refcount_data = state["refcount_data"] 120 stable_abi_data = state["stable_abi_data"] 121 for node in doctree.findall(addnodes.desc_content): 122 par = node.parent 123 if par["domain"] != "c": 124 continue 125 if not par[0].get("ids", None): 126 continue 127 name = par[0]["ids"][0].removeprefix("c.") 128 objtype = par["objtype"] 129 130 # Stable ABI annotation. 131 if record := stable_abi_data.get(name): 132 if ROLE_TO_OBJECT_TYPE[record.role] != objtype: 133 msg = ( 134 f"Object type mismatch in limited API annotation for {name}: " 135 f"{ROLE_TO_OBJECT_TYPE[record.role]!r} != {objtype!r}" 136 ) 137 raise ValueError(msg) 138 annotation = _stable_abi_annotation(record) 139 node.insert(0, annotation) 140 141 # Unstable API annotation. 142 if name.startswith("PyUnstable"): 143 annotation = _unstable_api_annotation() 144 node.insert(0, annotation) 145 146 # Return value annotation 147 if objtype != "function": 148 continue 149 if name not in refcount_data: 150 continue 151 entry = refcount_data[name] 152 if not entry.result_type.endswith("Object*"): 153 continue 154 annotation = _return_value_annotation(entry.result_refs) 155 node.insert(0, annotation) 156 157 158def _stable_abi_annotation(record: StableABIEntry) -> nodes.emphasis: 159 """Create the Stable ABI annotation. 160 161 These have two forms: 162 Part of the `Stable ABI <link>`_. 163 Part of the `Stable ABI <link>`_ since version X.Y. 164 For structs, there's some more info in the message: 165 Part of the `Limited API <link>`_ (as an opaque struct). 166 Part of the `Stable ABI <link>`_ (including all members). 167 Part of the `Limited API <link>`_ (Only some members are part 168 of the stable ABI.). 169 ... all of which can have "since version X.Y" appended. 170 """ 171 stable_added = record.added 172 message = sphinx_gettext("Part of the") 173 message = message.center(len(message) + 2) 174 emph_node = nodes.emphasis(message, message, classes=["stableabi"]) 175 ref_node = addnodes.pending_xref( 176 "Stable ABI", 177 refdomain="std", 178 reftarget="stable", 179 reftype="ref", 180 refexplicit="False", 181 ) 182 struct_abi_kind = record.struct_abi_kind 183 if struct_abi_kind in {"opaque", "members"}: 184 ref_node += nodes.Text(sphinx_gettext("Limited API")) 185 else: 186 ref_node += nodes.Text(sphinx_gettext("Stable ABI")) 187 emph_node += ref_node 188 if struct_abi_kind == "opaque": 189 emph_node += nodes.Text(" " + sphinx_gettext("(as an opaque struct)")) 190 elif struct_abi_kind == "full-abi": 191 emph_node += nodes.Text( 192 " " + sphinx_gettext("(including all members)") 193 ) 194 if record.ifdef_note: 195 emph_node += nodes.Text(f" {record.ifdef_note}") 196 if stable_added == "3.2": 197 # Stable ABI was introduced in 3.2. 198 pass 199 else: 200 emph_node += nodes.Text( 201 " " + sphinx_gettext("since version %s") % stable_added 202 ) 203 emph_node += nodes.Text(".") 204 if struct_abi_kind == "members": 205 msg = " " + sphinx_gettext( 206 "(Only some members are part of the stable ABI.)" 207 ) 208 emph_node += nodes.Text(msg) 209 return emph_node 210 211 212def _unstable_api_annotation() -> nodes.admonition: 213 ref_node = addnodes.pending_xref( 214 "Unstable API", 215 nodes.Text(sphinx_gettext("Unstable API")), 216 refdomain="std", 217 reftarget="unstable-c-api", 218 reftype="ref", 219 refexplicit="False", 220 ) 221 emph_node = nodes.emphasis( 222 "This is ", 223 sphinx_gettext("This is") + " ", 224 ref_node, 225 nodes.Text( 226 sphinx_gettext( 227 ". It may change without warning in minor releases." 228 ) 229 ), 230 ) 231 return nodes.admonition( 232 "", 233 emph_node, 234 classes=["unstable-c-api", "warning"], 235 ) 236 237 238def _return_value_annotation(result_refs: int | None) -> nodes.emphasis: 239 classes = ["refcount"] 240 if result_refs is None: 241 rc = sphinx_gettext("Return value: Always NULL.") 242 classes.append("return_null") 243 elif result_refs: 244 rc = sphinx_gettext("Return value: New reference.") 245 classes.append("return_new_ref") 246 else: 247 rc = sphinx_gettext("Return value: Borrowed reference.") 248 classes.append("return_borrowed_ref") 249 return nodes.emphasis(rc, rc, classes=classes) 250 251 252class LimitedAPIList(SphinxDirective): 253 has_content = False 254 required_arguments = 0 255 optional_arguments = 0 256 final_argument_whitespace = True 257 258 def run(self) -> list[nodes.Node]: 259 state = self.env.domaindata["c_annotations"] 260 content = [ 261 f"* :c:{record.role}:`{record.name}`" 262 for record in state["stable_abi_data"].values() 263 ] 264 node = nodes.paragraph() 265 self.state.nested_parse(StringList(content), 0, node) 266 return [node] 267 268 269def init_annotations(app: Sphinx) -> None: 270 # Using domaindata is a bit hack-ish, 271 # but allows storing state without a global variable or closure. 272 app.env.domaindata["c_annotations"] = state = {} 273 state["refcount_data"] = read_refcount_data( 274 Path(app.srcdir, app.config.refcount_file) 275 ) 276 state["stable_abi_data"] = read_stable_abi_data( 277 Path(app.srcdir, app.config.stable_abi_file) 278 ) 279 280 281def setup(app: Sphinx) -> ExtensionMetadata: 282 app.add_config_value("refcount_file", "", "env", types={str}) 283 app.add_config_value("stable_abi_file", "", "env", types={str}) 284 app.add_directive("limited-api-list", LimitedAPIList) 285 app.connect("builder-inited", init_annotations) 286 app.connect("doctree-read", add_annotations) 287 288 if sphinx.version_info[:2] < (7, 2): 289 from docutils.parsers.rst import directives 290 from sphinx.domains.c import CObject 291 292 # monkey-patch C object... 293 CObject.option_spec |= { 294 "no-index-entry": directives.flag, 295 "no-contents-entry": directives.flag, 296 } 297 298 return { 299 "version": "1.0", 300 "parallel_read_safe": True, 301 "parallel_write_safe": True, 302 } 303