• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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