Skip to content

Commit 75e719d

Browse files
committed
test(core): parse API docs with docutils
1 parent 37cd68f commit 75e719d

2 files changed

Lines changed: 169 additions & 95 deletions

File tree

cuda_core/pixi.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ pytest-randomly = "*"
2121
pytest-repeat = "*"
2222
pytest-rerunfailures = "*"
2323
cloudpickle = "*"
24+
docutils = "*"
2425
psutil = "*"
2526
pyglet = "*"
2627

cuda_core/tests/test_api_docs_consistency.py

Lines changed: 168 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -6,25 +6,21 @@
66
77
Covers the flat ``cuda.core`` namespace and every public subpackage
88
(``graph``, ``system``, ``texture``, ``utils``, and any added later) discovered
9-
automatically from ``cuda.core.__path__``. For each, the exported ``__all__``
10-
is compared against the public entries in ``docs/source/api.rst``. Symbols
11-
documented in ``docs/source/api_private.rst`` (returned helpers users cannot
12-
instantiate) are accepted as documented.
13-
14-
Docs reference submodule symbols two ways: dotted entries such as
15-
``graph.Graph`` under the ``cuda.core`` module, and flat entries under a
16-
``.. currentmodule:: cuda.core.<sub>`` block. Both forms are collected. A
17-
subpackage documented outside ``api.rst`` (for example ``system``, whose
18-
reference lives in ``api_nvml.rst``) is still checked for a well-formed
19-
``__all__``, but its doc cross-check is skipped here.
9+
automatically from ``cuda.core.__path__``. For each public namespace, exported
10+
``__all__`` names must appear somewhere in ``cuda_core/docs/source``.
2011
"""
2112

13+
import collections
2214
import importlib
15+
import io
2316
import pathlib
2417
import pkgutil
2518
import re
2619

2720
import pytest
21+
from docutils import nodes
22+
from docutils.core import publish_doctree
23+
from docutils.parsers.rst import Directive, directives
2824

2925
import cuda.core
3026

@@ -41,77 +37,147 @@
4137
)
4238

4339

44-
def _iter_directive_blocks(text):
45-
"""Yield (module, directive, argument, body_lines) for each RST directive.
40+
class _ModuleNode(nodes.Element):
41+
pass
4642

47-
``module`` is the module active at the directive, tracked from
48-
``.. module::`` and ``.. currentmodule::`` directives. ``body_lines`` is
49-
the indented block that follows the directive.
50-
"""
51-
module = None
52-
lines = text.splitlines()
53-
i = 0
54-
while i < len(lines):
55-
line = lines[i]
56-
i += 1
57-
if not line.startswith(".. "):
58-
continue
59-
head, _, argument = line[3:].partition("::")
60-
directive = head.strip()
61-
argument = argument.strip()
62-
if directive in ("module", "currentmodule"):
63-
module = argument
64-
continue
65-
body = []
66-
while i < len(lines):
67-
body_line = lines[i]
68-
if body_line.strip() and not body_line.startswith(" "):
69-
break
70-
body.append(body_line)
71-
i += 1
72-
yield module, directive, argument, body
73-
74-
75-
def _documented_names(rst_path, module="cuda.core"):
76-
"""Collect names documented for ``module`` in an RST file.
77-
78-
Returns the entries of ``autosummary`` blocks plus ``.. data::``
79-
arguments that appear while ``module`` is the active module.
80-
"""
81-
names = set()
82-
for active_module, directive, argument, body in _iter_directive_blocks(rst_path.read_text()):
83-
if active_module != module:
84-
continue
85-
if directive == "data":
86-
names.add(argument)
87-
elif directive == "autosummary":
88-
for line in body:
89-
entry = line.strip()
90-
if not entry or entry.startswith(":"):
91-
continue
92-
names.add(entry)
93-
return names
43+
44+
class _AutosummaryNode(nodes.Element):
45+
pass
9446

9547

96-
def _flat_names(names):
97-
"""Filter out dotted (submodule-namespace) entries."""
98-
return {name for name in names if "." not in name}
48+
class _DataNode(nodes.Element):
49+
pass
9950

10051

101-
def _documented_subpackage_names(rst_path, sub):
102-
"""Collect the public names documented for subpackage ``sub`` in ``rst_path``.
52+
class _ModuleDirective(Directive):
53+
required_arguments = 1
54+
final_argument_whitespace = False
55+
has_content = True
56+
option_spec = {
57+
"deprecated": directives.unchanged,
58+
"no-index": directives.flag,
59+
"platform": directives.unchanged,
60+
"synopsis": directives.unchanged,
61+
}
62+
63+
def run(self):
64+
node = _ModuleNode()
65+
node["module"] = self.arguments[0].strip()
66+
self.state.nested_parse(self.content, self.content_offset, node)
67+
return [node]
68+
69+
70+
class _AutosummaryDirective(Directive):
71+
has_content = True
72+
option_spec = {
73+
"caption": directives.unchanged,
74+
"nosignatures": directives.flag,
75+
"recursive": directives.flag,
76+
"template": directives.unchanged,
77+
"toctree": directives.unchanged,
78+
}
10379

104-
Combines the two doc conventions: flat entries under
105-
``.. currentmodule:: cuda.core.<sub>`` and dotted ``<sub>.Name`` entries
106-
written under the ``cuda.core`` module.
107-
"""
108-
flat = _documented_names(rst_path, module=f"cuda.core.{sub}")
109-
dotted = {
110-
name.split(".", 1)[1]
111-
for name in _documented_names(rst_path, module="cuda.core")
112-
if name.startswith(f"{sub}.") and name.count(".") == 1
80+
def run(self):
81+
node = _AutosummaryNode()
82+
node["entries"] = [entry for line in self.content if (entry := line.strip()) and not entry.startswith(":")]
83+
return [node]
84+
85+
86+
class _DataDirective(Directive):
87+
required_arguments = 1
88+
final_argument_whitespace = True
89+
has_content = True
90+
option_spec = {
91+
"annotation": directives.unchanged,
92+
"no-index": directives.flag,
93+
"type": directives.unchanged,
94+
"value": directives.unchanged,
11395
}
114-
return flat | dotted
96+
97+
def run(self):
98+
node = _DataNode()
99+
node["name"] = self.arguments[0].strip()
100+
return [node]
101+
102+
103+
directives.register_directive("autosummary", _AutosummaryDirective)
104+
directives.register_directive("currentmodule", _ModuleDirective)
105+
directives.register_directive("data", _DataDirective)
106+
directives.register_directive("module", _ModuleDirective)
107+
108+
109+
def _iter_documented_entries(rst_path):
110+
"""Yield (module, entry) pairs from Sphinx directives in an RST file."""
111+
doctree = publish_doctree(
112+
rst_path.read_text(),
113+
source_path=str(rst_path),
114+
settings_overrides={
115+
"halt_level": 6,
116+
"report_level": 5,
117+
"warning_stream": io.StringIO(),
118+
},
119+
)
120+
module = None
121+
for node in doctree.findall():
122+
if isinstance(node, _ModuleNode):
123+
module = node["module"]
124+
elif isinstance(node, _AutosummaryNode):
125+
for entry in node["entries"]:
126+
yield module, entry
127+
elif isinstance(node, _DataNode):
128+
yield module, node["name"]
129+
130+
131+
def _public_doc_paths(docs_dir):
132+
return sorted(path for path in docs_dir.glob("*.rst") if path.name != "api_private.rst")
133+
134+
135+
def _add_documented_name(documented, module, entry):
136+
if not module or not module.startswith("cuda.core"):
137+
return
138+
if module == "cuda.core":
139+
if "." not in entry:
140+
documented[module].add(entry)
141+
return
142+
sub, name = entry.split(".", 1)
143+
if sub in PUBLIC_SUBPACKAGES and "." not in name:
144+
documented[f"cuda.core.{sub}"].add(name)
145+
return
146+
if module.startswith("cuda.core."):
147+
namespace = module
148+
if namespace in PUBLIC_NAMESPACES and "." not in entry:
149+
documented[namespace].add(entry)
150+
151+
152+
def _documented_names(paths):
153+
documented = collections.defaultdict(set)
154+
for rst_path in paths:
155+
for module, entry in _iter_documented_entries(rst_path):
156+
_add_documented_name(documented, module, entry)
157+
return documented
158+
159+
160+
def _private_documented_names(docs_dir):
161+
names = collections.defaultdict(set)
162+
private_path = docs_dir / "api_private.rst"
163+
if not private_path.is_file():
164+
return names
165+
for module, entry in _iter_documented_entries(private_path):
166+
if module == "cuda.core":
167+
if "." in entry:
168+
sub, name = entry.split(".", 1)
169+
if sub in PUBLIC_SUBPACKAGES:
170+
names[f"cuda.core.{sub}"].add(name.rsplit(".", 1)[-1])
171+
else:
172+
names[module].add(entry.rsplit(".", 1)[-1])
173+
else:
174+
names[module].add(entry)
175+
elif module in PUBLIC_NAMESPACES:
176+
names[module].add(entry.rsplit(".", 1)[-1])
177+
return names
178+
179+
180+
PUBLIC_NAMESPACES = ("cuda.core", *(f"cuda.core.{sub}" for sub in PUBLIC_SUBPACKAGES))
115181

116182

117183
@pytest.fixture(scope="module")
@@ -123,11 +189,21 @@ def exported():
123189

124190
@pytest.fixture(scope="module")
125191
def docs_dir():
126-
if not (DOCS_SOURCE_DIR / "api.rst").is_file():
192+
if not DOCS_SOURCE_DIR.is_dir():
127193
pytest.skip("docs sources not available (not running from a source checkout)")
128194
return DOCS_SOURCE_DIR
129195

130196

197+
@pytest.fixture(scope="module")
198+
def public_documented(docs_dir):
199+
return _documented_names(_public_doc_paths(docs_dir))
200+
201+
202+
@pytest.fixture(scope="module")
203+
def private_documented(docs_dir):
204+
return _private_documented_names(docs_dir)
205+
206+
131207
def test_public_subpackages_discovered():
132208
# Guards against a broken __path__ walk silently turning every
133209
# parametrized subpackage check into a no-op.
@@ -141,22 +217,20 @@ def test_all_exports_resolve():
141217
assert missing == [], f"cuda.core.__all__ lists names that do not resolve: {missing}"
142218

143219

144-
def test_public_symbols_are_documented(exported, docs_dir):
145-
documented = _flat_names(_documented_names(docs_dir / "api.rst"))
220+
def test_public_symbols_are_documented(exported, public_documented, private_documented):
221+
documented = public_documented["cuda.core"]
146222
# Returned helpers are deliberately documented in api_private.rst; accept
147-
# them by their trailing name (e.g. _device_resources.DeviceResources).
148-
private_documented = {name.rsplit(".", 1)[-1] for name in _documented_names(docs_dir / "api_private.rst")}
149-
undocumented = exported - documented - private_documented
150-
assert not undocumented, (
151-
f"public by cuda.core.__all__ but missing from public docs (api.rst): {sorted(undocumented)}"
152-
)
223+
# them by their trailing name.
224+
private = private_documented["cuda.core"]
225+
undocumented = exported - documented - private
226+
assert not undocumented, f"public by cuda.core.__all__ but missing from docs/source/*.rst: {sorted(undocumented)}"
153227

154228

155-
def test_documented_symbols_are_exported(exported, docs_dir):
156-
documented = _flat_names(_documented_names(docs_dir / "api.rst"))
229+
def test_documented_symbols_are_exported(exported, public_documented):
230+
documented = public_documented["cuda.core"]
157231
unexported = documented - exported
158232
assert not unexported, (
159-
f"documented as public in api.rst but not exported by cuda.core.__all__: {sorted(unexported)}"
233+
f"documented as public in docs/source/*.rst but not exported by cuda.core.__all__: {sorted(unexported)}"
160234
)
161235

162236

@@ -169,18 +243,17 @@ def test_subpackage_defines_all(sub):
169243

170244

171245
@pytest.mark.parametrize("sub", PUBLIC_SUBPACKAGES)
172-
def test_subpackage_exports_match_docs(sub, docs_dir):
173-
documented = _documented_subpackage_names(docs_dir / "api.rst", sub)
174-
if not documented:
175-
pytest.skip(f"cuda.core.{sub} is not documented in api.rst (its reference lives elsewhere)")
246+
def test_subpackage_exports_match_docs(sub, public_documented, private_documented):
247+
documented = public_documented[f"cuda.core.{sub}"]
176248
module = importlib.import_module(f"cuda.core.{sub}")
177249
exported = set(module.__all__)
178-
private_documented = {name.rsplit(".", 1)[-1] for name in _documented_names(docs_dir / "api_private.rst")}
179-
undocumented = exported - documented - private_documented
250+
private = private_documented[f"cuda.core.{sub}"]
251+
undocumented = exported - documented - private
180252
assert not undocumented, (
181-
f"public by cuda.core.{sub}.__all__ but missing from public docs (api.rst): {sorted(undocumented)}"
253+
f"public by cuda.core.{sub}.__all__ but missing from docs/source/*.rst: {sorted(undocumented)}"
182254
)
183255
unexported = documented - exported
184256
assert not unexported, (
185-
f"documented as public in api.rst under {sub} but not exported by cuda.core.{sub}.__all__: {sorted(unexported)}"
257+
f"documented as public in docs/source/*.rst under {sub} but not exported by cuda.core.{sub}.__all__: "
258+
f"{sorted(unexported)}"
186259
)

0 commit comments

Comments
 (0)