From ef891dc114de1338852e7ca3bea11d218234672e Mon Sep 17 00:00:00 2001 From: Tam Nguyen Duc <1218621+tamnd@users.noreply.github.com> Date: Wed, 19 Aug 2026 08:41:57 +0700 Subject: [PATCH] Install what a release publishes, on a machine that has nothing on it Every job in this repository runs on a hosted image, and a hosted image is the least representative computer in the world. It has a compiler, a Rust toolchain, a git, a Python somebody chose deliberately, and a few hundred libraries a wheel can quietly link against and get away with. So the failures that only a user's machine sees are exactly the ones nothing here looks for. A file the build put in the tree and left out of the wheel, read out of the checkout instead and never missed. A stub or a py.typed that the sdist ships and the wheel does not. An extension linked against a symbol version the manylinux image had and a slim image has not. A dependency that arrived because something else in the job had already pulled it in. So: a nightly that builds the two Linux wheels a release would build, inside the pypa images and against the oldest interpreter the stable ABI covers, and then installs each of them in a container that holds an interpreter, a package manager and nothing else. Three rows, which are the floor this package supports, the newest release, and musl, where a wheel tagged wrongly installs anyway and then fails to import. The index is off, so nothing can arrive to cover for a mistake, and pip has to find everything in the one directory the wheel is in. What runs inside is tools/smoke.py. Standard library only, no pytest, no fixtures, no checkout: it asserts that the package it imported came out of site-packages rather than out of a tree, that the compiled half and the stub and the py.typed are all beside it, and then it does what a reader of the README would do on their first afternoon. A graph on a file, reopened afterwards so that a wheel which only ever wrote to a page cache is caught. Both bulk paths. A statement that cannot parse, which has to come back with the standard's own code on it. The DB-API front door and the asyncio one, since both are submodules and a wheel that shipped one of them and not the other passes everything else. Last, the check only a machine like that one can make: that importing zudb brought no dataframe library with it and that asking for a DataFrame without pandas is a refusal naming the extra to install. The images are checked rather than believed. Every row asserts that cc, gcc, clang, rustc, cargo, make and git are all absent, because the day a base image starts shipping a compiler is the day this job silently stops being about anything. The gate is validated the way the other gates here are: the failure it exists to catch has to fail it. The same program in the same image with nothing installed, which is what a wheel that did not build, did not upload or did not install looks like from inside the container, and which is also the only way to know the job is running the program at all. A bind mount pointing at nothing and a container whose exit code went nowhere both look exactly like success. tools/smoke.py also runs in the suite, piece by piece, because a container is the least convenient place to discover that a line of it went stale. What the suite cannot run is the no-dependencies check, which needs a machine with no pandas on it, and the came-from-an-install check when the working copy is a maturin develop rather than a wheel. Both say so. Nightly rather than on every push. What this catches is drift in things outside this repository, a base image whose glibc moved, a manylinux policy that went forward, a pip that changed how it reads a tag, and none of that arrives in a diff anybody here writes. --- .github/workflows/install.yml | 164 ++++++++++++++++++++++++++++ tests/test_smoke.py | 68 ++++++++++++ tools/smoke.py | 194 ++++++++++++++++++++++++++++++++++ 3 files changed, 426 insertions(+) create mode 100644 .github/workflows/install.yml create mode 100644 tests/test_smoke.py create mode 100644 tools/smoke.py diff --git a/.github/workflows/install.yml b/.github/workflows/install.yml new file mode 100644 index 0000000..56756a4 --- /dev/null +++ b/.github/workflows/install.yml @@ -0,0 +1,164 @@ +name: Install + +# `pip install zudb`, on a machine that has nothing else on it. +# +# Every other job in this repository runs on a hosted image, and a +# hosted image is the least representative computer in the world: it +# has a compiler, a Rust toolchain, a git, a Python built by somebody +# who knew what was going to be built against it, and a hundred +# libraries that a wheel can quietly link to and get away with. The +# failures that only a user's machine sees are the ones nothing here +# looks for. A file left out of the wheel and read out of the checkout +# instead. A stub or a py.typed that the sdist has and the wheel does +# not. An extension linked against a symbol version the build image had +# and a slim image has not. A dependency that arrived because something +# else in the job had pulled it in. +# +# So this builds the wheel the release would build and installs it in a +# container that holds an interpreter, a package manager and nothing +# else, with the index turned off so that nothing can arrive to cover +# for a mistake. tools/smoke.py is the program it runs there: standard +# library only, no pytest, no fixtures, no checkout. +# +# Nightly rather than on every push, because what it catches is drift +# in things outside this repository. A base image whose glibc moved, a +# manylinux policy that went forward, a pip that changed how it reads a +# tag. None of that is in a diff anybody here writes, and all of it +# arrives on its own schedule. + +on: + schedule: + # Late enough that the day's merges are in, and not on the hour, + # where every scheduled job on the service is queued behind every + # other one. + - cron: "23 5 * * *" + workflow_dispatch: + # The workflow and the program it runs are exercised on the pull + # request that changes them, since a nightly that broke is a nightly + # nobody reads for a week. + pull_request: + paths: + - .github/workflows/install.yml + - tools/smoke.py + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + +jobs: + # The two Linux wheels that a container can install, built the way the + # release builds them: inside the pypa images, against the oldest + # interpreter the stable ABI covers. Release and not debug, because + # what is being installed has to be what would be published, and a + # debug extension is a different program with different link edges. + wheel: + name: ${{ matrix.libc }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - libc: manylinux + manylinux: "2_28" + image: quay.io/pypa/manylinux_2_28_x86_64 + - libc: musllinux + manylinux: musllinux_1_2 + image: quay.io/pypa/musllinux_1_2_x86_64 + steps: + - uses: actions/checkout@v7 + - uses: PyO3/maturin-action@v1 + with: + target: x86_64 + manylinux: ${{ matrix.manylinux }} + container: ${{ matrix.image }} + # Named rather than left to the toolchain file, which the + # build container does not read. + rust-toolchain: 1.97.1 + args: --release --out dist -i /opt/python/cp311-cp311/bin/python + # A shared object cannot have a static C runtime linked into + # it, which is the musl default and a no-op everywhere else. + before-script-linux: | + export RUSTFLAGS="-C target-feature=-crt-static" + - uses: actions/upload-artifact@v4 + with: + name: wheel-${{ matrix.libc }} + path: dist/*.whl + if-no-files-found: error + + # And the install, in a container that is the whole point of the job. + # + # docker run rather than a job container, because a job container has + # the runner's own Node mounted into it and half the reason to use a + # slim image is that nothing is mounted into it. This way the only + # things inside are the image, one wheel and one file. + # + # Three rows: the floor this package supports, the newest release, and + # musl, which is the platform where a wheel that was tagged wrongly + # installs anyway and then fails to import. + clean: + name: ${{ matrix.image }} + needs: wheel + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - image: python:3.11-slim + wheel: manylinux + - image: python:3.14-slim + wheel: manylinux + - image: python:3.14-alpine + wheel: musllinux + steps: + - uses: actions/checkout@v7 + - uses: actions/download-artifact@v4 + with: + name: wheel-${{ matrix.wheel }} + path: dist + - name: An install, on a machine with only the language runtime + run: | + docker run --rm \ + -v "$PWD/dist:/dist:ro" \ + -v "$PWD/tools/smoke.py:/smoke.py:ro" \ + -w /tmp \ + ${{ matrix.image }} sh -c ' + set -eu + # What the image is claimed to be, checked rather than + # believed, because the day a base image starts shipping a + # compiler is the day this job silently stops being about + # anything. + for tool in cc gcc clang rustc cargo make git; do + if command -v "$tool" >/dev/null 2>&1; then + echo "this image has $tool on it, so it is not the machine this job is about" + exit 1 + fi + done + # --no-index is what makes the install a test. Without it + # a wheel that failed to build would be papered over by + # whatever the index has, and a dependency that crept in + # would arrive rather than fail. + python -m pip install --no-index --find-links /dist zudb + python -m pip check + python /smoke.py + ' + # The gate is validated the only way a gate can be: the failure it + # exists to catch has to fail it. The same program in the same + # image with nothing installed, which is what a wheel that did not + # build, did not upload or did not install looks like from in + # here. It is also the check that this job is running the program + # at all, since a bind mount pointing at nothing and a container + # whose exit code went nowhere both look exactly like success. + - name: The failure the job is meant to catch, caught + run: | + set +e + docker run --rm \ + -v "$PWD/tools/smoke.py:/smoke.py:ro" \ + -w /tmp \ + ${{ matrix.image }} python /smoke.py + test $? -ne 0 || { echo "the smoke program passed without zudb installed"; exit 1; } diff --git a/tests/test_smoke.py b/tests/test_smoke.py new file mode 100644 index 0000000..e5d3eca --- /dev/null +++ b/tests/test_smoke.py @@ -0,0 +1,68 @@ +"""The clean-machine program, run here as well. + +`tools/smoke.py` is what the nightly install job runs inside a container +that holds an interpreter, a package manager and nothing else, and that +container is the least convenient place in the world to find out that a +line of it went stale. So each piece runs here too, against the install +this suite already has, where a failure arrives with a traceback and a +name attached rather than as a red square once a day. + +The one piece that cannot run here is the one that is about the machine +rather than about the client: this machine has pandas on it, because the +rest of the suite needs pandas. +""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path + +import pytest +import smoke +import zudb + +# `maturin develop`, which is how this is worked on, puts the checkout +# on the path instead of installing anything, so the one check that is +# about where the package came from has nothing to say here. Every job +# in CI installs the wheel, which is where it does. +WHERE = Path(zudb.__file__).resolve() +INSTALLED = "site-packages" in WHERE.parts or "dist-packages" in WHERE.parts + + +@pytest.mark.skipif(not INSTALLED, reason=f"zudb here is a checkout at {WHERE}, not an install") +def test_the_package_under_test_is_an_install() -> None: + smoke.imported_from_an_install() + + +def test_the_quickstart_runs(tmp_path) -> None: + smoke.a_graph(tmp_path / "social.zu1") + + +def test_the_bulk_paths_run(tmp_path) -> None: + smoke.the_bulk_path(tmp_path / "roads.zu1") + + +def test_a_failure_is_a_failure(tmp_path) -> None: + path = tmp_path / "social.zu1" + smoke.a_graph(path) + smoke.a_failure(path) + + +def test_the_dbapi_front_door_runs(tmp_path) -> None: + path = tmp_path / "social.zu1" + smoke.a_graph(path) + smoke.pep_249(path) + + +def test_the_event_loop_front_door_runs(tmp_path) -> None: + path = tmp_path / "social.zu1" + smoke.a_graph(path) + smoke.an_event_loop(path) + + +@pytest.mark.skipif( + importlib.util.find_spec("pandas") is not None, + reason="this check is about a machine with no pandas on it, and this one has pandas", +) +def test_nothing_arrived_with_the_wheel() -> None: + smoke.nothing_else_installed() diff --git a/tools/smoke.py b/tools/smoke.py new file mode 100644 index 0000000..4e16af9 --- /dev/null +++ b/tools/smoke.py @@ -0,0 +1,194 @@ +"""What a person gets, run on a machine that has nothing else on it. + + python tools/smoke.py + +The suite is the thing that says this client is correct, and it needs a +compiler, a Rust toolchain, pytest and a checkout to say it. None of +those is on the machine of the person who ran `pip install zudb`, and +the failures that only that machine sees are the ones nothing else +looks for: a wheel whose extension links against a library the build +image had and the user's image does not, a file that the build put in +the tree and left out of the wheel, a stub that ships without the +package data that makes it readable, an import that works because the +checkout was on the path. + +So this is written to run against an installed wheel and nothing else. +Standard library only, no pytest, no fixtures, no checkout: point an +interpreter at it in a container that holds an interpreter and a +package manager, and what it exercises is what a reader of the README +would do on their first afternoon. + +It is not a second test suite and must not grow into one. Every claim +here is already covered somewhere in tests/, and it is repeated here +because the interesting variable is the machine rather than the claim. +""" + +from __future__ import annotations + +import asyncio +import sys +import tempfile +from pathlib import Path + + +def imported_from_an_install() -> None: + """The package under test is the installed one, not a checkout. + + A smoke test that imported the source tree beside it would pass on + a machine where the wheel had never been built, which is the one + thing this cannot be allowed to do. + """ + import zudb + + where = Path(zudb.__file__).resolve() + assert "site-packages" in where.parts or "dist-packages" in where.parts, ( + f"zudb was imported from {where}, which is not an install" + ) + + # The compiled half, which is the half a wheel exists to carry, and + # the stub beside it, which is what an editor reads. + from zudb import _zudb + + assert Path(_zudb.__file__).parent == where.parent + assert (where.parent / "_zudb.pyi").is_file(), "the wheel shipped no stub" + assert (where.parent / "py.typed").is_file(), "the wheel shipped no py.typed" + + assert isinstance(zudb.__version__, str) + assert isinstance(zudb.__abi_version__, str) + + +def a_graph(path: Path) -> None: + """The quickstart, on a file, with the file reopened afterwards.""" + import zudb + + with zudb.connect(path) as conn: + conn.execute("INSERT (p:person {uid: 1, name: 'ada'})") + conn.execute( + "INSERT (p:person {uid: $uid, name: $name})", + {"uid": 2, "name": "grace"}, + ) + + people = conn.execute("MATCH (p:person) RETURN p.name AS name, p.uid AS uid") + assert people.columns == ["name", "uid"] + assert sorted(name for name, _ in people) == ["ada", "grace"] + + # A second open of the same file, because a wheel that writes to a + # page cache and never to a disk passes everything above. + with zudb.connect(path, read_only=True) as conn: + again = conn.execute("MATCH (p:person) RETURN p.name AS name") + assert len(again) == 2 + + +def the_bulk_path(path: Path) -> None: + """`load`, then the appender: the two ways rows arrive in bulk.""" + import zudb + + zudb.load( + path, + nodes="city", + rels="road", + columns={"uid": [1, 2, 3], "name": ["hanoi", "kyoto", "lima"]}, + edges=[(0, 1), (1, 2)], + ) + + with zudb.connect(path) as conn: + with conn.appender("city") as appender: + appender.append_row([4, "oslo"]) + assert appender.close() == 1 + + cities = conn.execute("MATCH (c:city) RETURN c.name AS name") + assert len(cities) == 4 + + roads = conn.execute("MATCH (:city)-[r:road]->(:city) RETURN r") + assert len(roads) == 2 + + +def a_failure(path: Path) -> None: + """An error arrives as an error, with the standard's code on it.""" + import zudb + + with zudb.connect(path) as conn: + try: + conn.execute("MATCH (") + except zudb.SyntaxError as failure: + assert failure.code == "42001", failure.code + assert failure.condition + assert failure.doc_url + assert failure.retryable is False + else: + raise AssertionError("a statement that cannot parse parsed") + + +def pep_249(path: Path) -> None: + """The other front door, which is a submodule and not imported above.""" + import zudb.dbapi + + with zudb.dbapi.connect(path) as conn: + cursor = conn.cursor() + cursor.execute("MATCH (p:person) RETURN p.name AS name ORDER BY p.name") + assert cursor.description is not None + assert cursor.description[0][0] == "name" + assert cursor.fetchall() == [("ada",), ("grace",)] + cursor.close() + + +def an_event_loop(path: Path) -> None: + """And the third, which runs the same engine off the loop's thread.""" + import zudb.aio + + async def run() -> int: + async with zudb.aio.connect(path, read_only=True) as conn: + rows = await conn.execute("MATCH (p:person) RETURN p.uid AS uid") + return len(rows) + + assert asyncio.run(run()) == 2 + + +def nothing_else_installed() -> None: + """No dependencies, and the refusal says which one to install. + + `pip install zudb` brings nothing with it, which is a claim the + README makes and which only a machine like this one can check: a + developer's machine has pandas on it for some other reason. + """ + import zudb + + for module in ("pandas", "polars", "pyarrow"): + assert module not in sys.modules, f"{module} was imported by importing zudb" + + with tempfile.TemporaryDirectory() as where: + with zudb.connect(Path(where) / "empty.zu1") as conn: + rows = conn.execute("RETURN 1 AS n") + try: + rows.to_pandas() + except ImportError as refusal: + assert "zudb[pandas]" in str(refusal), refusal + else: + raise AssertionError("to_pandas worked without pandas") + + +def main() -> int: + imported_from_an_install() + + with tempfile.TemporaryDirectory() as where: + # Somewhere that is not the checkout and not the current + # directory, because a client that only works when the database + # is beside the program is a client with a bug in it. + root = Path(where) + a_graph(root / "social.zu1") + the_bulk_path(root / "roads.zu1") + a_failure(root / "social.zu1") + pep_249(root / "social.zu1") + an_event_loop(root / "social.zu1") + + nothing_else_installed() + + import zudb + + here = sys.version.split()[0] + print(f"zudb {zudb.__version__}, abi {zudb.__abi_version__}, on Python {here}: it works") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())