Repository navigation
Expand file tree
/
Copy pathtest_docstrings.py
More file actions
246 lines (217 loc) · 10.2 KB
/
Copy pathtest_docstrings.py
File metadata and controls
246 lines (217 loc) · 10.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""Shape checks on the docstrings that ship in the wheel.
These are about *form*, not content: a docstring that has grown into an essay,
a documented API with no example, or a pointer at the guide that the rendered
HTML cannot follow. See "Python Function Docstrings" in ``AGENTS.md``.
Content is checked elsewhere. ``--doctest-modules`` executes the examples, and
Sphinx builds with ``--fail-on-warning`` so a broken ``:ref:`` fails the docs
build. Neither of those notices a docstring that is simply too long, or one
that never had an example to run.
"""
from __future__ import annotations
import ast
import inspect
import pathlib
import re
import datafusion
import pytest
from datafusion import SessionContext, extensions
PACKAGE_ROOT = pathlib.Path(datafusion.__file__).parent
REPO_ROOT = PACKAGE_ROOT.parents[1]
# The hook reference in the extension guide is a hand-written table of every
# `__datafusion_*__` name. Nothing about adding a hook forces it to be updated,
# so the table is compared against the names the package actually dispatches.
#
# "Dispatches" means a *site*, not an occurrence: the name spelled where the
# host looks the hook up or defines its own, not everywhere the name is
# written. Scanning raw text would make the table's contents depend on prose —
# a doc-comment contrasting a hook with one that was removed, or naming a
# hypothetical, would have to be either deleted or added to the table, and
# neither is right. Structure answers the question text cannot.
HOOK_REFERENCE = REPO_ROOT / "docs" / "source" / "extension-guide" / "index.md"
HOOK_NAME = re.compile(r"^__datafusion_[a-z_]+__$")
HOOK_TABLE_ROW = re.compile(r"^\| `(__datafusion_[a-z_]+__)`")
# A Rust dispatch site is a string literal holding nothing but the hook name —
# what `hasattr`, `getattr`, and `call_capsule_getter` are handed — or a `fn`
# of that name, which is a hook the host itself implements. An error message
# that merely embeds the name (`"__datafusion_scalar_udf__ does not exist"`)
# is prose and does not count; every such message sits beside a real lookup.
RUST_HOOK_SITE = re.compile(
r'"(__datafusion_[a-z_]+__)"|\bfn\s+(__datafusion_[a-z_]+__)\b'
)
# Above this, a docstring has stopped being a contract and become a
# narrative. Move the argument to a guide page under `docs/source/` and leave
# a one-line pointer. Raising this is not the fix.
#
# Deliberately has no waiver list: at the time of writing the longest
# docstring in the package is `udaf` at 92 lines, which is a legitimately
# long UDF-authoring reference with many worked examples. If a genuinely
# necessary docstring ever exceeds this, prefer splitting the API.
MAX_DOCSTRING_LINES = 95
# An *inclusion* list, to be grown — not an exclusion list to be shrunk.
# Enforcing "every public callable has an example" package-wide is a separate
# project; several hundred callables do not have one yet. These are the
# extension-protocol surface, where an undocumented method is the difference
# between an extension author succeeding and filing an issue.
DOCSTRINGS_REQUIRING_EXAMPLES: list[tuple[str, object]] = [
*[
(f"SessionContext.{name}", getattr(SessionContext, name))
for name in (
"with_extensions",
"set_query_planner",
"with_logical_extension_codec",
"with_physical_extension_codec",
"logical_extension_codec_ids",
"physical_extension_codec_ids",
"with_python_udf_inlining",
"__datafusion_codec_id__",
"__datafusion_logical_extension_codec__",
"__datafusion_physical_extension_codec__",
"__datafusion_query_planner__",
)
],
*[(name, getattr(extensions, name)) for name in extensions.__all__],
]
def _iter_docstrings() -> list[tuple[str, int]]:
"""Yield ``(location, line count)`` for every docstring under ``python/``."""
found = []
for path in sorted(PACKAGE_ROOT.rglob("*.py")):
tree = ast.parse(path.read_text(), filename=str(path))
for node in ast.walk(tree):
if not isinstance(
node, (ast.Module, ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)
):
continue
doc = ast.get_docstring(node, clean=True)
if not doc:
continue
name = getattr(node, "name", "<module>")
rel = path.relative_to(PACKAGE_ROOT.parent)
line = getattr(node, "lineno", 1)
found.append((f"{rel}:{line} {name}", len(doc.splitlines())))
return found
def test_no_docstring_is_an_essay() -> None:
"""No docstring exceeds the length ceiling."""
too_long = [
(where, count)
for where, count in _iter_docstrings()
if count > MAX_DOCSTRING_LINES
]
assert not too_long, (
"These docstrings exceed "
f"{MAX_DOCSTRING_LINES} lines and have become narrative rather than "
"contract:\n"
+ "\n".join(f" {where} — {count} lines" for where, count in too_long)
+ "\n\nMove the argument to a guide page under docs/source/ and leave a "
"one-line pointer with a :ref:. See 'One canonical home per claim' in "
"AGENTS.md."
)
@pytest.mark.parametrize(
("name", "obj"),
DOCSTRINGS_REQUIRING_EXAMPLES,
ids=[name for name, _ in DOCSTRINGS_REQUIRING_EXAMPLES],
)
def test_extension_api_has_a_doctest(name: str, obj: object) -> None:
"""Every extension-protocol member carries at least one example."""
doc = inspect.getdoc(obj)
assert doc, f"{name} has no docstring"
assert ">>>" in doc, (
f"{name} has a docstring but no example. Everything on the extension "
"protocol needs one, even if the realistic usage has to be marked "
"+SKIP — in which case a runnable example goes above it. See "
"'Examples that need a compiled extension' in AGENTS.md."
)
def _rust_hook_sites() -> set[str]:
"""Hook names Rust looks up or defines. See :data:`RUST_HOOK_SITE`."""
found: set[str] = set()
for path in sorted((REPO_ROOT / "crates").rglob("*.rs")):
for match in RUST_HOOK_SITE.finditer(path.read_text()):
found.add(match.group(1) or match.group(2))
return found
def _python_hook_sites() -> set[str]:
"""Hook names the Python wrappers call, declare, or look up by string.
Read off the syntax tree rather than the text, so a name is counted when
it is an attribute being accessed (``extension.__datafusion_x__(ctx)``), a
method being defined (the protocol stubs), or a string standing alone (a
``getattr`` argument) — and not when it merely appears inside a docstring.
"""
found: set[str] = set()
for path in sorted(PACKAGE_ROOT.rglob("*.py")):
tree = ast.parse(path.read_text(), filename=str(path))
for node in ast.walk(tree):
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
name = node.name
elif isinstance(node, ast.Attribute):
name = node.attr
elif isinstance(node, ast.Constant) and isinstance(node.value, str):
name = node.value
else:
continue
if HOOK_NAME.match(name):
found.add(name)
return found
def test_hook_reference_table_lists_every_hook() -> None:
"""The guide's hook table matches the hooks the package dispatches."""
if not HOOK_REFERENCE.is_file():
pytest.skip("running against an installed wheel, without docs/ or crates/")
dispatched = _rust_hook_sites() | _python_hook_sites()
documented = {
match.group(1)
for line in HOOK_REFERENCE.read_text().splitlines()
if (match := HOOK_TABLE_ROW.match(line))
}
problems = [
f" dispatched but not in the table: {name}"
for name in sorted(dispatched - documented)
] + [
f" in the table but dispatched from nowhere: {name}"
for name in sorted(documented - dispatched)
]
assert not problems, (
f"{HOOK_REFERENCE.relative_to(REPO_ROOT)} is out of sync with the "
"hooks in crates/ and python/datafusion/:\n"
+ "\n".join(problems)
+ "\n\nOnly dispatch sites count — a name looked up by string, an "
"attribute accessed, or a method defined. Naming a hook in prose does "
"not put it in this set, and does not belong in the table either."
)
def test_no_dead_pointers_at_the_guide() -> None:
"""A docstring naming the guide must link it, not just mention it."""
role = re.compile(r":(ref|doc|py:\w+):`")
dead = []
for path in sorted(PACKAGE_ROOT.rglob("*.py")):
tree = ast.parse(path.read_text(), filename=str(path))
for node in ast.walk(tree):
if not isinstance(
node, (ast.Module, ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)
):
continue
doc = ast.get_docstring(node, clean=True)
if not doc or "guide" not in doc.lower():
continue
if role.search(doc):
continue
rel = path.relative_to(PACKAGE_ROOT.parent)
name = getattr(node, "name", "<module>")
dead.append(f"{rel}:{getattr(node, 'lineno', 1)} {name}")
assert not dead, (
"These docstrings send the reader to a guide without a resolvable "
"link, which is a dead end in the rendered HTML:\n"
+ "\n".join(f" {where}" for where in dead)
+ "\n\nUse :ref:`some_label` naming the specific section."
)