Skip to content

stubs for the compiled module, and a gate that keeps them true - #4

Merged
tamnd merged 1 commit into
mainfrom
python-stubs
Aug 18, 2026
Merged

tamnd merged 1 commit into
mainfrom
python-stubs

Conversation

@tamnd

@tamnd tamnd commented Aug 18, 2026

Copy link
Copy Markdown
Owner

An extension module is a shared object and nothing can read a signature out of one, so mypy, pyright and an editor's completion all had nothing to go on. This is python/zudb/_zudb.pyi: every function, class, property and method, with the parameter names and defaults the module actually has. It ships in the wheel beside py.typed, which was already there.

zudb.Value comes with it, the union a row holds and a parameter takes, for code that passes rows around and wants to say so. It is written as a real module and not only in the stub, because a type alias a checker knows and the interpreter does not is a name that fails at the first from zudb import Value.

The gate

A stub is a promise no interpreter checks. Nothing fails when it names a method the engine does not have, or gives a parameter a name the engine does not answer to. What fails is the caller who believed the editor.

So tests/test_stubs.py checks it. griffe reads the stub as text and inspects the installed extension, and the two are compared on:

  • every public name in the module, both ways, so a stub cannot miss one or invent one
  • every public name on every class, and whether it is a value or a call
  • every parameter of every function and method: name, kind and default
  • every constructor
  • every dunder the stub declares, which has to exist at runtime

PyO3 writes a text signature for everything it exports, which is what makes the inspection half work at all: connect comes back as (path, *, read_only=False, memory_limit=None, threads=None) out of the built module.

Two things the comparison has to work around, both commented where they happen. griffe merges a stub into the extension beside it, so a merged stub would agree with the module by construction and a name missing from the stub would come back from the .so; the package is copied to a temporary directory without its binary first. And a constructor is exported as __new__ with (*args, **kwargs), with the real signature on the class, which is where inspect finds it and where the stub's __init__ is checked against it.

What is not compared is the types. No inspection of a compiled module can confirm that a column is a list[str], and the tests that call it are what say so.

Checked

Verified by breaking it both ways, a method the module does not have and a parameter renamed, each caught by the test that should catch it. Verified from the other end with mypy, which resolves the whole surface through the stub and reveals a row as tuple[None | bool | int | float | str | date | time | datetime | timedelta | Duration | Node | Rel | Path | list[...] | dict[str, ...], ...]. The wheel was built and listed: zudb/_zudb.pyi, zudb/py.typed and zudb/types.py are all in it.

25 tests for the stub, 176 in the client. CI installs griffe beside pytest so the check runs against the installed wheel rather than against the checkout it was built from.

Ticks the .pyi stubs line of tamnd/zu#168.

An extension module is a shared object and nothing can read a signature
out of one, so mypy, pyright and an editor's completion all had nothing
to go on. This is `_zudb.pyi`: every function, class, property and
method, with the parameter names and defaults the module actually has.
It ships in the wheel beside `py.typed`, which was already there.

`zudb.Value` comes with it, the union a row holds and a parameter takes,
for code that passes rows around and wants to say so. Written as a real
module rather than only in the stub, because a type alias a checker
knows and the interpreter does not is a name that fails at the first
`from zudb import Value`.

A stub is a promise no interpreter checks. Nothing fails when it names a
method the engine does not have or gives a parameter a name the engine
does not answer to; what fails is the caller who believed the editor. So
it is checked: griffe reads the stub as text, inspects the installed
extension, and the two are compared on every public name, every kind,
every parameter and every default. PyO3 writes a text signature for
everything it exports, which is what makes the second half possible.

Two things the comparison has to work around. griffe merges a stub into
the extension beside it, so a merged stub would agree with the module by
construction and a missing name would come back from the `.so`; the
package is copied to a temporary directory without its binary first.
And a constructor is exported as `__new__` with `(*args, **kwargs)`,
with the real signature on the class, where `inspect` finds it and
where the stub's `__init__` is checked against it.

What is not compared is the types. No inspection of a compiled module
can confirm that a column is a `list[str]`, and the tests that call it
are what say so.

25 tests for the stub, 176 in the client. Verified by breaking it both
ways: a method the module does not have and a parameter renamed, each
caught by the test that should catch it.
@tamnd
tamnd merged commit bc20cc0 into main Aug 18, 2026
7 checks passed
@tamnd
tamnd deleted the python-stubs branch August 18, 2026 00:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant