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())