Skip to content

Commit 5b24fa8

Browse files
committed
Generate the API reference from the wheel that ships
A reference written by hand beside the code is wrong by the second release, and wrong in the way that costs the most: it looks maintained. So this one is generated, by pdoc, from the installed package, and the release builds it from the wheel it is about to publish rather than from the checkout the wheel came out of. Half of this package is compiled and that is the one thing the inspection cannot see through. `zudb.connect` is a function object pyo3 built, so what pdoc reads off it is `(path, *, read_only=False, ...)` and the types are in `_zudb.pyi`, where the checkers read them. pdoc looks for a stub named after the module it is documenting, which is `zudb`, and the stub is named after the module the names were defined in, which is `zudb._zudb`. So `tools/reference.py` lays the stub where pdoc looks, under the `-stubs` name PEP 561 reserves for it, deriving it rather than writing it: there is no second declaration of the surface to keep in step, and the only edit is the one relative import, which a stub loaded outside the import machinery has no package to resolve. The check on that is the part worth having. Without the overlay ten public signatures lose every annotation, and the reference still builds, still looks finished, and is wrong about every one of them. Nobody reads their own generated docs closely enough to catch it, so the build fails instead, naming the signatures it would have published bare. `tags` now downloads the wheels by name instead of everything the run produced, because the rule it applies is that the directory holds the grid and nothing outside it, and the reference job puts a directory of HTML in the same run.
1 parent bc630a0 commit 5b24fa8

6 files changed

Lines changed: 338 additions & 2 deletions

File tree

.github/workflows/ci.yml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,8 +116,10 @@ jobs:
116116
- run: pip install ".[all]"
117117
# griffe reads the stub and inspects the installed extension, so
118118
# the check runs against the wheel rather than against the
119-
# checkout it was built from.
120-
- run: pip install pytest griffe ipython
119+
# checkout it was built from. pdoc is here for the same reason:
120+
# the reference is generated from the installed package, and the
121+
# test that says so has nothing to read without it.
122+
- run: pip install pytest griffe ipython pdoc==16.0.0
121123
- run: pytest
122124

123125
# The same suite again, over an extension built with AddressSanitizer.

.github/workflows/wheels.yml

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ on:
1414
- pyproject.toml
1515
- rust-toolchain.toml
1616
- tools/wheel_tags.py
17+
- tools/reference.py
1718

1819
concurrency:
1920
group: ${{ github.workflow }}-${{ github.ref }}
@@ -196,17 +197,62 @@ jobs:
196197
name: sdist
197198
path: dist/*.tar.gz
198199

200+
# The reference, built from the wheel this release is publishing and
201+
# not from the checkout it came out of. Half of this package is
202+
# compiled, so a reference generated from source would be a reference
203+
# of the half a reader can read already, and the interesting half
204+
# exists only once it is built.
205+
#
206+
# One wheel is enough and it is the stable-ABI Linux one, because
207+
# what is being read is the public surface and every wheel in the
208+
# grid has the same one. A grid of fourteen references would be
209+
# fourteen copies of one page and thirteen chances for a reader to
210+
# find the wrong one.
211+
reference:
212+
needs: wheel
213+
runs-on: ubuntu-latest
214+
steps:
215+
- uses: actions/checkout@v7
216+
- uses: actions/setup-python@v6
217+
with:
218+
python-version: "3.14"
219+
- uses: actions/download-artifact@v4
220+
with:
221+
name: wheel-abi3-manylinux_2_28_x86_64
222+
path: dist
223+
- run: pip install --no-index --find-links dist zudb
224+
# ipython because `zudb.magic` imports it at the top and a module
225+
# that cannot be imported cannot be documented, and it is the
226+
# `%%gql` half of this client rather than an extra.
227+
- run: pip install pdoc==16.0.0 ipython
228+
- run: python tools/reference.py reference
229+
- uses: actions/upload-artifact@v4
230+
with:
231+
name: reference
232+
path: reference/
233+
if-no-files-found: error
234+
199235
# The grid, checked as a grid. Every cell filled and nothing outside
200236
# it, which is the check a single row cannot do.
201237
tags:
202238
needs: [wheel, sdist]
203239
runs-on: ubuntu-latest
204240
steps:
205241
- uses: actions/checkout@v7
242+
# By name rather than everything this run produced, because the
243+
# check is that the directory holds the grid and nothing else,
244+
# and the reference job puts a directory of HTML in the same run.
245+
# A rule that says "nothing outside the grid" reads whatever it
246+
# is handed, so what it is handed has to be the grid.
206247
- uses: actions/download-artifact@v4
207248
with:
249+
pattern: wheel-*
208250
path: dist
209251
merge-multiple: true
252+
- uses: actions/download-artifact@v4
253+
with:
254+
name: sdist
255+
path: dist
210256
- uses: actions/setup-python@v6
211257
with:
212258
python-version: "3.14"

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,12 @@ The wheel carries `py.typed` and a stub for the compiled module, so mypy, pyrigh
221221

222222
The stub is checked against the module it describes in CI: griffe reads the stub as text and the installed extension by inspection, and the two have to agree on every name, every parameter and every default. A stub is a promise no interpreter checks, so something has to.
223223

224+
## Reference
225+
226+
Every name, its signature and what its docstring says, generated from the installed package rather than written by hand beside it. The release builds it from the wheel it is about to publish, so the pages describe that wheel and not the checkout it came out of, and `python tools/reference.py <directory>` builds the same pages here.
227+
228+
Half of this package is compiled, which is the one thing an inspection cannot see through: `zudb.connect` is a function object pyo3 built, and its types live in `_zudb.pyi` where the checkers read them. So the stub is laid where the generator looks for one, and the build fails if it stops landing, because a reference that quietly lost every annotation is a reference that still builds and is wrong about every signature on the page.
229+
224230
## What works today
225231

226232
The list above is what this client is for. What it does so far is the core of it: `connect`, `execute` and `sql` with named parameters, results that iterate and fetch, values as Python objects both ways including dates, times, datetimes and durations, `Node`, `Rel` and `Path` as classes, `load` for building a graph with edges in it, an appender for growing one, transactions as a context manager that commits at the end of a block and rolls back when it raises, every condition as an exception class carrying its code, its position and its documentation link, results as Arrow columns and as pandas and polars frames, `register` for putting a frame under a name a statement can match on and reading it where it lies, stubs inside the wheel with a gate that keeps them true, the GIL released around every statement, every load and every copy out, `Ctrl-C` and `interrupt()` stopping a statement without touching the connection under it, `zudb.aio` for the same calls awaited on an event loop, results, nodes, rels and paths that draw themselves in a notebook with `%gql` and `%%gql` to run statements in one, and `zudb.dbapi` for code written against PEP 249. Each one landed with the tests that say it works.

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@ dev = [
4747
# Reads the stub as text and the built extension by inspection, which
4848
# is what checks one against the other.
4949
"griffe>=2",
50+
# The reference generator. `tools/reference.py` drives it, and the
51+
# release builds the pages from the wheel it is about to publish.
52+
"pdoc>=16",
5053
# For `%gql` and `%%gql`, which are tested against a real shell
5154
# rather than against a stub of one. Not a dependency of the wheel:
5255
# anyone with a notebook has IPython already, and nobody else needs

tests/test_reference.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
"""The generated reference, and the one thing about it worth asserting.
2+
3+
Most of what a documentation generator does is not this suite's
4+
business: pdoc's templates are pdoc's, and a test that counted headings
5+
would fail on the week pdoc renders one differently. What is this
6+
client's business is that the reference says what the package says, and
7+
half of the package is compiled, so the interesting failure is the
8+
quiet one: pdoc reads a pyo3 function object, finds no annotations on
9+
it, and publishes a whole reference in which nothing has a type.
10+
11+
That is a reference that builds, looks finished, and is wrong about
12+
every signature on the page. So the check is in the tool, and this is
13+
the check on the check: it fires when the stub overlay is missing and
14+
it passes when it is there.
15+
"""
16+
17+
from __future__ import annotations
18+
19+
from pathlib import Path
20+
21+
import pytest
22+
import reference
23+
24+
pdoc = pytest.importorskip("pdoc", reason="the reference generator, a dev dependency")
25+
26+
27+
def test_without_the_overlay_the_signatures_have_no_types_on_them() -> None:
28+
"""The failure the tool exists to catch, uncaught."""
29+
assert reference.bare("zudb"), "pdoc read types off a compiled module by itself"
30+
31+
32+
def test_with_the_overlay_every_public_signature_is_typed() -> None:
33+
"""And caught. The overlay is what the tool puts on sys.path."""
34+
with reference.stubs():
35+
assert reference.bare("zudb") == []
36+
37+
38+
def test_the_overlay_is_the_stub_the_type_checkers_read(tmp_path: Path) -> None:
39+
"""Derived from `_zudb.pyi` and not a second copy of it.
40+
41+
Every line but the one relative import, which a stub loaded outside
42+
the import machinery has no package to resolve.
43+
"""
44+
stub = (package() / "_zudb.pyi").read_text(encoding="utf-8").splitlines()
45+
written = (
46+
(reference.overlay(package(), tmp_path) / "__init__.pyi")
47+
.read_text(encoding="utf-8")
48+
.splitlines()
49+
)
50+
assert len(written) == len(stub)
51+
moved = [(one, two) for one, two in zip(stub, written, strict=True) if one != two]
52+
assert moved == [("from .types import Value", "from zudb.types import Value")]
53+
54+
55+
def test_the_reference_covers_what_a_reader_can_import(tmp_path: Path) -> None:
56+
"""One page per module, and the compiled module is not one.
57+
58+
`zudb._zudb` is where the classes are defined and it is not a name
59+
anybody imports, so it is not in the list. Its names are documented
60+
on the `zudb` page, which is where they are exported from.
61+
"""
62+
import zudb
63+
64+
reference.build(tmp_path)
65+
for name in reference.MODULES:
66+
assert (tmp_path / (name.replace(".", "/") + ".html")).is_file(), name
67+
assert "zudb._zudb" not in reference.MODULES
68+
assert set(reference.MODULES) - {"zudb"} == {
69+
f"zudb.{name}" for name in ("aio", "dbapi", "errors", "magic", "types")
70+
}
71+
# The version the wheel says it is, on the page the wheel produced.
72+
assert zudb.__version__ in (tmp_path / "zudb.html").read_text(encoding="utf-8")
73+
74+
75+
def test_the_reference_replaces_what_was_there(tmp_path: Path) -> None:
76+
"""A page for a module that went away is worse than no page.
77+
78+
It is a name a reader can still find, still in the search index
79+
built beside it, and gone from the package.
80+
"""
81+
stale = tmp_path / "zudb" / "gone.html"
82+
stale.parent.mkdir(parents=True)
83+
stale.write_text("a module this package used to have", encoding="utf-8")
84+
reference.build(tmp_path)
85+
assert not stale.exists()
86+
87+
88+
def package() -> Path:
89+
"""Where the installed package is, which is what is documented."""
90+
import zudb
91+
92+
return Path(zudb.__file__ or "").parent

tools/reference.py

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
"""The API reference, generated from the package that ships.
2+
3+
A reference written by hand beside the code is a reference that is
4+
wrong by the second release, and wrong in the way that costs the most:
5+
it looks maintained. So this one is built from the installed package,
6+
by inspection, and the release builds it from the same wheel it is
7+
about to publish.
8+
9+
python tools/reference.py docs/api
10+
11+
pdoc is the generator because it reads a package the way the
12+
interpreter does and asks for nothing in return: no configuration file,
13+
no theme to keep, no second copy of the module list. What it produces
14+
is HTML that opens from disk, which matters for a client whose docs go
15+
out as an artifact of the release rather than as a deployment.
16+
17+
Half of this package is compiled, and that is the one thing the
18+
inspection cannot see through. `zudb.connect` is a function object
19+
pyo3 built, so its signature is `(path, *, read_only=False, ...)` and
20+
the types are in `_zudb.pyi`, where the type checkers read them. pdoc
21+
knows about stub files and looks for one named after the module it is
22+
documenting, which is `zudb`, and the stub is named after the module
23+
the names were defined in, which is `zudb._zudb`. So the stub is put
24+
where pdoc looks, under the `-stubs` name PEP 561 reserves for exactly
25+
this, and the one relative import in it is rewritten because a stub
26+
loaded outside the import machinery has no package to be relative to.
27+
28+
Derived rather than written, which is the point: there is no second
29+
declaration of the surface to keep in step, and `bare()` below fails
30+
the build if the overlay stops landing. Without that check a reference
31+
that quietly lost every type annotation would still be a reference that
32+
built, and nobody reads their own generated docs closely enough to
33+
notice.
34+
"""
35+
36+
from __future__ import annotations
37+
38+
import contextlib
39+
import shutil
40+
import sys
41+
import tempfile
42+
from collections.abc import Iterator
43+
from pathlib import Path
44+
45+
#: What the reference covers, which is what a reader can import. The
46+
#: compiled module is not in the list: it is an implementation detail
47+
#: with an underscore on it, and every name it defines is re-exported
48+
#: from `zudb` and documented there.
49+
MODULES = [
50+
"zudb",
51+
"zudb.aio",
52+
"zudb.dbapi",
53+
"zudb.errors",
54+
"zudb.magic",
55+
"zudb.types",
56+
]
57+
58+
#: The name pdoc looks under, and PEP 561's name for a stub package
59+
#: distributed apart from the package it describes. `zudb/__init__.pyi`
60+
#: beside the real `__init__.py` would work as well and would be a
61+
#: second file in the wheel claiming to be the public surface, which is
62+
#: the thing this whole file exists to avoid.
63+
STUBS = "zudb-stubs"
64+
65+
66+
def overlay(package: Path, into: Path) -> Path:
67+
"""Write the stub pdoc will find for `zudb`, and answer where.
68+
69+
The content is `_zudb.pyi`, unchanged except for its one relative
70+
import. pdoc loads a stub with a loader of its own rather than
71+
through `import`, so the module has no package and `from .types
72+
import Value` raises before anything is read; naming the package
73+
absolutely is the whole edit.
74+
"""
75+
stub = (package / "_zudb.pyi").read_text(encoding="utf-8")
76+
written = into / STUBS
77+
written.mkdir(parents=True, exist_ok=True)
78+
(written / "__init__.pyi").write_text(
79+
stub.replace("from .types import", "from zudb.types import"), encoding="utf-8"
80+
)
81+
return written
82+
83+
84+
@contextlib.contextmanager
85+
def stubs() -> Iterator[Path]:
86+
"""The overlay, on `sys.path`, for as long as the block runs.
87+
88+
pdoc remembers where a module's stub was and where it was not, and
89+
the answer changes here twice in one process. So the caches that
90+
hold it are dropped on the way in and on the way out, which is what
91+
lets a test ask the question both ways and get both answers.
92+
"""
93+
import pdoc.doc
94+
import pdoc.doc_pyi
95+
import zudb
96+
97+
def forget() -> None:
98+
pdoc.doc_pyi.find_stub_file.cache_clear()
99+
pdoc.doc.Module.from_name.cache_clear()
100+
101+
with tempfile.TemporaryDirectory() as scratch:
102+
written = overlay(Path(zudb.__file__ or "").parent, Path(scratch))
103+
sys.path.insert(0, str(written.parent))
104+
forget()
105+
try:
106+
yield written
107+
finally:
108+
sys.path.remove(str(written.parent))
109+
forget()
110+
111+
112+
def bare(module: str) -> list[str]:
113+
"""The names on `module` whose signature carries no annotation.
114+
115+
Which is the check that the overlay landed. Everything the compiled
116+
module defines is annotated in the stub, so after the overlay every
117+
parameter of every public function has a type on it, and a name
118+
here is a name pdoc documented from the object instead. An empty
119+
list is a reference worth publishing.
120+
121+
What a reader reads is what is counted: the members a namespace
122+
declares itself, under names with no underscore on the front.
123+
Inherited ones are somebody else's, and `object.__eq__` takes an
124+
untyped `value` on every class in the language.
125+
"""
126+
import pdoc.doc
127+
128+
def functions(namespace: pdoc.doc.Namespace) -> list[pdoc.doc.Function]:
129+
found = []
130+
for doc in namespace.own_members:
131+
if doc.name.startswith("_"):
132+
continue
133+
if isinstance(doc, pdoc.doc.Function):
134+
found.append(doc)
135+
elif isinstance(doc, pdoc.doc.Class):
136+
found.extend(functions(doc))
137+
return found
138+
139+
untyped = []
140+
for one in functions(pdoc.doc.Module.from_name(module)):
141+
wanted = [
142+
name
143+
for name, parameter in one.signature.parameters.items()
144+
if name not in ("self", "cls") and parameter.annotation is parameter.empty
145+
]
146+
if wanted:
147+
untyped.append(f"{one.fullname}({', '.join(wanted)})")
148+
return untyped
149+
150+
151+
def build(into: Path) -> None:
152+
"""The reference, in `into`, replacing whatever was there.
153+
154+
Replacing rather than merging, because a page for a module that was
155+
removed is worse than no page at all: it is a name a reader can
156+
still find, still linked from the index of a search built beside
157+
it, and gone from the package.
158+
"""
159+
import pdoc
160+
161+
with stubs():
162+
untyped = bare("zudb")
163+
if untyped:
164+
raise SystemExit(
165+
"the stub overlay did not land, so the reference would publish "
166+
f"{len(untyped)} name(s) with no types on them: {', '.join(sorted(untyped))}"
167+
)
168+
shutil.rmtree(into, ignore_errors=True)
169+
pdoc.pdoc(*MODULES, output_directory=into)
170+
171+
172+
def main(argv: list[str]) -> int:
173+
if len(argv) != 2:
174+
print(f"usage: {argv[0]} <directory>", file=sys.stderr)
175+
return 2
176+
into = Path(argv[1])
177+
build(into)
178+
pages = sorted(path for path in into.rglob("*.html"))
179+
missing = [name for name in MODULES if not (into / (name.replace(".", "/") + ".html")).exists()]
180+
for name in missing:
181+
print(f"{name} was documented and has no page", file=sys.stderr)
182+
print(f"{len(pages)} pages in {into}, {len(MODULES)} modules")
183+
return 1 if missing else 0
184+
185+
186+
if __name__ == "__main__":
187+
raise SystemExit(main(sys.argv))

0 commit comments

Comments
 (0)