blob: 9ac1f0b76b2da741de0f974532644b1b1ee916bb [file]
# 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."
)