From fd4405d0161fc5f1f5a65c07a32acd0cc1460def Mon Sep 17 00:00:00 2001 From: tamnd <1218621+tamnd@users.noreply.github.com> Date: Tue, 18 Aug 2026 06:42:46 +0700 Subject: [PATCH] the Python client: connect, run a statement, read the rows This is the core of the Python SDK of dx/06. `zudb.connect` opens or creates a database, `execute` and `sql` run one statement with named parameters, and a result answers its columns, its rows, its length and its notices, iterates, and fetches one row at a time. The extension links the engine crates directly rather than going through `libzu`, which is ADR 0002 in the engine repository. The README said the opposite and now says what the code does, along with what of the client is built and what is still to come. Values cross both ways. Nulls, booleans, integers, floats, strings, lists and records go out as the Python objects they are and come back in as parameters; dates, times, datetimes with and without an offset, and durations map onto the `datetime` module, except for the one thing it cannot hold. A `timedelta` is microseconds and days, so it can carry neither a count of months nor a count of nanoseconds, and a `Duration` class carries both and converts to a `timedelta` when asked rather than silently. Graph values are classes: `Node`, `Rel` and `Path`, with the table named rather than left as the id a row carries. Every condition is an exception carrying the fields the error model promises. The classes live in `zudb/errors.py`, because a Python programmer catches by name and subclasses what they catch, and a class written in Python has a signature, a docstring and something a type checker can read. Rust owns the mapping instead: one class per GQLSTATUS class, so catching `zudb.DataError` catches every condition in class 22 without listing them, and `e.code`, `e.line`, `e.column`, `e.offset`, `e.excerpt`, `e.doc_url` and `e.retryable` are fields and never a regular expression over the message. The GIL goes down for the whole statement, waiting for the connection's own lock included. A thread that waited for the lock while holding the GIL would deadlock the thread inside the executor, which has to take the GIL back to return, and a statement that held the GIL would stop the signal handler running, which is what a `Ctrl-C` needs. Eighty-four tests cover the lot: opening and closing, the context manager, the rows, the parameters, the values, the conditions and the threads. Three of them are about the GIL, one by counting the turns the main thread gets while another runs statements. CI runs ruff, rustfmt and clippy, then installs the wheel and runs the suite on 3.11 and 3.14 across Linux, macOS and Windows. Two notes for whoever writes the next test. A table the graph has not got is made by an `INSERT` whose values are written out, so the first row of a table cannot be parameters, and a rel table cannot be made this way at all. And `id` is the name the v0 engine gives a node's offset, so a table carrying a property of its own by that name is one where `RETURN p.id` and `WHERE p.id = 1` disagree; the fixtures call it `uid`. --- .github/workflows/ci.yml | 50 ++ .gitignore | 14 + Cargo.lock | 1326 ++++++++++++++++++++++++++++++++++++++ Cargo.toml | 31 + README.md | 21 +- pyproject.toml | 58 ++ python/zudb/__init__.py | 49 ++ python/zudb/errors.py | 152 +++++ python/zudb/py.typed | 0 rust-toolchain.toml | 7 + src/conn.rs | 296 +++++++++ src/error.rs | 139 ++++ src/lib.rs | 56 ++ src/value.rs | 571 ++++++++++++++++ tests/conftest.py | 64 ++ tests/test_connection.py | 87 +++ tests/test_errors.py | 92 +++ tests/test_params.py | 83 +++ tests/test_query.py | 83 +++ tests/test_threads.py | 83 +++ tests/test_values.py | 76 +++ 21 files changed, 3329 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 pyproject.toml create mode 100644 python/zudb/__init__.py create mode 100644 python/zudb/errors.py create mode 100644 python/zudb/py.typed create mode 100644 rust-toolchain.toml create mode 100644 src/conn.rs create mode 100644 src/error.rs create mode 100644 src/lib.rs create mode 100644 src/value.rs create mode 100644 tests/conftest.py create mode 100644 tests/test_connection.py create mode 100644 tests/test_errors.py create mode 100644 tests/test_params.py create mode 100644 tests/test_query.py create mode 100644 tests/test_threads.py create mode 100644 tests/test_values.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..debdc2c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,50 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v6 + with: + python-version: "3.14" + - run: pip install ruff==0.16.3 + - run: ruff check . + - run: ruff format --check . + # rust-toolchain.toml is what picks the compiler, here and on + # every machine, so nothing installs one. + - run: cargo fmt --all --check + - run: cargo clippy --all-targets -- -D warnings + + test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + python: ["3.11", "3.14"] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python }} + - uses: Swatinem/rust-cache@v2 + # Installed as a wheel rather than developed in place, so what + # the suite imports is what a person who runs `pip install zudb` + # gets: the extension out of a wheel, the package out of + # site-packages, and nothing resolved out of the checkout. + - run: pip install . + - run: pip install pytest + - run: pytest diff --git a/.gitignore b/.gitignore index e43b0f9..8ede5c7 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,15 @@ .DS_Store +target/ +__pycache__/ +*.pyc +.venv/ +dist/ +.pytest_cache/ +.ruff_cache/ +.cargo/config.toml + +# The built extension, which maturin drops into the source tree when +# it develops the package in place. +*.so +*.pyd +*.dylib diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..1aa6476 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1326 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "windows-link", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "crc32c" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a47af21622d091a8f0fb295b88bc886ac74efcc613efc19f5d0b21de5c89e47" +dependencies = [ + "rustc_version", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-macro", + "futures-sink", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generator" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae" +dependencies = [ + "cc", + "cfg-if", + "libc", + "log", + "rustversion", + "windows-link", + "windows-result", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "humantime" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15" + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libsqlite3-sys" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "scoped-tls", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "object_store" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d354792e39fa5f0009e47623cf8b15b099bf9a652fa55c6f817fe28ac84fea50" +dependencies = [ + "async-trait", + "bytes", + "chrono", + "futures-channel", + "futures-core", + "futures-util", + "http", + "humantime", + "itertools", + "parking_lot", + "percent-encoding", + "thiserror", + "url", + "wasm-bindgen-futures", + "web-time", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pyo3" +version = "0.29.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4688ddedf473e32662b9b067670129a8afb8c18e351482c70d62ba4a88171e8b" +dependencies = [ + "libc", + "once_cell", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", +] + +[[package]] +name = "pyo3-build-config" +version = "0.29.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f41027e41b4bd03f6e60f9f417fe24a6341a6bb744edd62b6f709f2a52ea30e9" +dependencies = [ + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.29.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e591a95526fead067432c3b3a33fc74770b87b1e04e73671090d9c2055a2b327" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.29.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73225868fc1cd84eef2c3c230ddb91273bf1de46aeb8a4248da76d32a0924a1c" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.29.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "571575aa3749fa6216757dd47d2a3e7ef360f329a40f0666a9fbd14889024952" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rusqlite" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ruzstd" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7c1c839d570d835527c9a5e4db7cb2198683a988cb9d7293fc8674e6bd58fc8" +dependencies = [ + "twox-hash", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "twox-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8464ec13c3691491391d9fce00f6416c9a48e46972f72d7865688be2080192c9" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94b5c6b5976d66c1d703c4fd17d3f5e43c8cedaacf604961b171adc7130896d8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47402523226a02bfe5230160dc3ccc089aa6f6f19e7fcbb4e6f824bbb1b4aa62" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "zu" +version = "0.0.1" +source = "git+https://github.com/tamnd/zu?rev=67afd055032932eec36f4e373c2b82bdc4b188c9#67afd055032932eec36f4e373c2b82bdc4b188c9" +dependencies = [ + "zu-common", + "zu-encoding", + "zu-exec", + "zu-query", + "zu-s3", + "zu-sqlite", + "zu-storage", + "zu-vector", + "zu-zu1", +] + +[[package]] +name = "zu-common" +version = "0.0.1" +source = "git+https://github.com/tamnd/zu?rev=67afd055032932eec36f4e373c2b82bdc4b188c9#67afd055032932eec36f4e373c2b82bdc4b188c9" +dependencies = [ + "thiserror", +] + +[[package]] +name = "zu-encoding" +version = "0.0.1" +source = "git+https://github.com/tamnd/zu?rev=67afd055032932eec36f4e373c2b82bdc4b188c9#67afd055032932eec36f4e373c2b82bdc4b188c9" +dependencies = [ + "ruzstd", + "zu-common", +] + +[[package]] +name = "zu-exec" +version = "0.0.1" +source = "git+https://github.com/tamnd/zu?rev=67afd055032932eec36f4e373c2b82bdc4b188c9#67afd055032932eec36f4e373c2b82bdc4b188c9" +dependencies = [ + "zu-common", + "zu-query", + "zu-vector", +] + +[[package]] +name = "zu-query" +version = "0.0.1" +source = "git+https://github.com/tamnd/zu?rev=67afd055032932eec36f4e373c2b82bdc4b188c9#67afd055032932eec36f4e373c2b82bdc4b188c9" +dependencies = [ + "crossbeam-deque", + "zu-common", + "zu-storage", + "zu-vector", +] + +[[package]] +name = "zu-s3" +version = "0.0.1" +source = "git+https://github.com/tamnd/zu?rev=67afd055032932eec36f4e373c2b82bdc4b188c9#67afd055032932eec36f4e373c2b82bdc4b188c9" +dependencies = [ + "crc32c", + "object_store", + "zu-common", + "zu-storage", +] + +[[package]] +name = "zu-sqlite" +version = "0.0.1" +source = "git+https://github.com/tamnd/zu?rev=67afd055032932eec36f4e373c2b82bdc4b188c9#67afd055032932eec36f4e373c2b82bdc4b188c9" +dependencies = [ + "rusqlite", + "zu-common", + "zu-storage", +] + +[[package]] +name = "zu-storage" +version = "0.0.1" +source = "git+https://github.com/tamnd/zu?rev=67afd055032932eec36f4e373c2b82bdc4b188c9#67afd055032932eec36f4e373c2b82bdc4b188c9" +dependencies = [ + "zu-common", + "zu-encoding", +] + +[[package]] +name = "zu-vector" +version = "0.0.1" +source = "git+https://github.com/tamnd/zu?rev=67afd055032932eec36f4e373c2b82bdc4b188c9#67afd055032932eec36f4e373c2b82bdc4b188c9" +dependencies = [ + "zu-common", +] + +[[package]] +name = "zu-zu1" +version = "0.0.1" +source = "git+https://github.com/tamnd/zu?rev=67afd055032932eec36f4e373c2b82bdc4b188c9#67afd055032932eec36f4e373c2b82bdc4b188c9" +dependencies = [ + "crc32c", + "loom", + "zu-common", + "zu-encoding", +] + +[[package]] +name = "zudb-python" +version = "0.0.1" +dependencies = [ + "pyo3", + "zu", + "zu-common", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..8395b87 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "zudb-python" +version = "0.0.1" +edition = "2024" +rust-version = "1.97" +license = "Apache-2.0" +repository = "https://github.com/tamnd/zu-python" +authors = ["Tam Nguyen "] +publish = false + +[lib] +name = "_zudb" +crate-type = ["cdylib"] + +[dependencies] +# The engine, by revision rather than by version. Nothing is published +# yet, and a binding is built against the engine commit it will ship +# with (ADR 0002), so a revision is the honest way to say which one. +# A local checkout is used instead with a `paths` override in +# `.cargo/config.toml`, which is untracked on purpose. +zudb = { package = "zu", git = "https://github.com/tamnd/zu", rev = "67afd055032932eec36f4e373c2b82bdc4b188c9" } +zu-common = { git = "https://github.com/tamnd/zu", rev = "67afd055032932eec36f4e373c2b82bdc4b188c9" } +# `extension-module` is asked for by maturin, in pyproject.toml, and +# not here. Only the build backend knows how an extension is linked on +# the platform it is building for, and a crate that turns the feature +# on by default is a crate `cargo build` cannot link on its own. +pyo3 = { version = "0.29", features = ["abi3-py311"] } + +[profile.release] +lto = "thin" +strip = true diff --git a/README.md b/README.md index 215a984..14499e9 100644 --- a/README.md +++ b/README.md @@ -6,14 +6,11 @@ The Python client for [zu](https://github.com/tamnd/zu), an embedded property-gr import zudb with zudb.connect("social.zu1") as conn: - conn.execute("CREATE NODE TABLE Person(id INT64 PRIMARY KEY, name STRING)") - conn.load_csv("Person", "people.csv") - - df = conn.sql(""" - MATCH (p:Person)-[:Follows]->(f) - RETURN p.name AS name, count(*) AS n ORDER BY n DESC LIMIT 5 - """).to_pandas() -print(df) + conn.execute("INSERT (p:person {uid: 1, name: 'ada'})") + conn.execute("INSERT (p:person {uid: $uid, name: $name})", {"uid": 2, "name": "grace"}) + + for name, uid in conn.execute("MATCH (p:person) RETURN p.name AS name, p.uid AS uid"): + print(name, uid) ``` ``` @@ -24,7 +21,9 @@ No compiler, no `pkg-config`, no postinstall script. One wheel per platform with ## What this is -Built with PyO3 and maturin over `libzu`'s C ABI. The interesting parts: +Built with PyO3 and maturin, linked against the engine crates rather than against `libzu`'s C ABI. That is ADR 0002 in the engine repository, and it is about mechanism and not about contract: this client and every client that does go through `zu.h` answer the same conformance corpus, which is what says they agree. Linking the crates is what lets a query result reach Python without being flattened through C on the way. + +The interesting parts: - **Arrow all the way down.** `to_arrow()`, `to_pandas()`, `to_polars()`, and `record_batches()` go through the Arrow C Data Interface with no intermediate copy. `to_pandas()` hands back Arrow-backed dtypes, which is what pandas 3 wants anyway. - **`register()` replacement scans.** A DataFrame in your session becomes a table you can query by name, zero-copy for Arrow-backed frames. This is the DuckDB idea worth copying wholesale, and it deletes the write-to-disk-then-load step from every "load my data" tutorial. @@ -33,6 +32,10 @@ Built with PyO3 and maturin over `libzu`'s C ABI. The interesting parts: - **Complete `.pyi` stubs inside the wheel**, checked against the runtime in CI, so mypy and pyright and your editor all work with no extra install. - **Graph values are real classes.** `Node`, `Rel`, and `Path` have `.labels`, `.id`, `.properties`, and an HTML repr. Not dicts, because a dict cannot tell a property named `labels` apart from the label set. +## What works today + +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, every condition as an exception class carrying its code, its position and its documentation link, and the GIL released around every statement. Arrow, `register`, the stubs and the interrupt are next, and each one lands with the tests that say it works. + ## Wheels Three per platform, which is more than it sounds like it should be and is not optional. The free-threaded CPython build has no stable ABI until 3.15 and [PEP 803](https://peps.python.org/pep-0803/)'s `abi3t`, so 3.14t needs a version-specific wheel of its own. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9902091 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,58 @@ +[build-system] +requires = ["maturin>=1.14,<2.0"] +build-backend = "maturin" + +[project] +name = "zudb" +version = "0.0.1" +description = "zu: an embedded property-graph database, in your process" +readme = "README.md" +license = "Apache-2.0" +requires-python = ">=3.11" +authors = [{ name = "Tam Nguyen", email = "tamnd87@gmail.com" }] +keywords = ["graph", "database", "gql", "embedded", "analytics"] +classifiers = [ + "Development Status :: 2 - Pre-Alpha", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Rust", + "Topic :: Database :: Database Engines/Servers", + "Typing :: Typed", +] +# Nothing. The wheel has the engine in it, and a notebook that wants a +# DataFrame back brings its own pandas. +dependencies = [] + +[project.urls] +Homepage = "https://zu.dev" +Source = "https://github.com/tamnd/zu-python" +Engine = "https://github.com/tamnd/zu" +Issues = "https://github.com/tamnd/zu-python/issues" + +[project.optional-dependencies] +arrow = ["pyarrow>=14"] +pandas = ["pandas>=2.0", "pyarrow>=14"] +polars = ["polars>=1.0"] + +[dependency-groups] +dev = ["maturin>=1.14,<2.0", "pytest>=8", "ruff>=0.9"] + +[tool.maturin] +python-source = "python" +module-name = "zudb._zudb" +# Only maturin knows how to link an extension module on every platform +# it builds for, so the feature is asked for here rather than being on +# by default in Cargo.toml, where it would break a plain `cargo build`. +features = ["pyo3/extension-module"] +strip = true + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-q" + +[tool.ruff] +target-version = "py311" +line-length = 100 + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B"] diff --git a/python/zudb/__init__.py b/python/zudb/__init__.py new file mode 100644 index 0000000..6a41e80 --- /dev/null +++ b/python/zudb/__init__.py @@ -0,0 +1,49 @@ +"""zu for Python: an embedded property-graph database, in your process. + + import zudb + + with zudb.connect("social.zu1") as conn: + rows = conn.execute("MATCH (p:person) RETURN p.name AS name") + for (name,) in rows: + print(name) + +The engine is compiled into the wheel, so there is nothing to install, +nothing to run, and no server to connect to. Statements are ISO/IEC +39075 GQL. +""" + +from __future__ import annotations + +from ._zudb import Connection, Duration, Node, Path, Rel, Result, __abi_version__, connect +from .errors import ( + ConnectionError, + DataError, + Error, + InternalError, + Interrupted, + ProgrammingError, + SyntaxError, + TransactionError, +) + +__version__ = "0.0.1" + +__all__ = [ + "connect", + "Connection", + "Result", + "Node", + "Rel", + "Path", + "Duration", + "Error", + "ConnectionError", + "DataError", + "TransactionError", + "SyntaxError", + "ProgrammingError", + "InternalError", + "Interrupted", + "__version__", + "__abi_version__", +] diff --git a/python/zudb/errors.py b/python/zudb/errors.py new file mode 100644 index 0000000..f2cbc5e --- /dev/null +++ b/python/zudb/errors.py @@ -0,0 +1,152 @@ +"""The exceptions zu raises, and the fields they carry. + +Every failure the engine reports is a GQLSTATUS condition: a +five-character code from ISO/IEC 39075, a severity, and often the place +in the statement that raised it. All of that arrives here as fields on +the exception, so a caller reads ``e.code`` and never a regular +expression over ``str(e)``. + +There is one class per condition class, which is what the two +characters that open a code are for. Catching :class:`DataError` +catches every one of the forty-two conditions in class 22 without +listing them, and a condition zu adds to that class later is caught by +the same ``except``. +""" + +from __future__ import annotations + +__all__ = [ + "Error", + "ConnectionError", + "DataError", + "TransactionError", + "SyntaxError", + "ProgrammingError", + "InternalError", + "Interrupted", +] + + +class Error(Exception): + """What every zu failure is, and the class to catch to catch them all. + + The fields are ``None`` when the condition has no answer for them + rather than being filled with a guess. A division by zero happens + at runtime and has no token to point at, so it carries a code and + no position; a statement that failed to parse carries both. + """ + + #: The five-character GQLSTATUS code, ``"42001"`` for a syntax + #: error. ``None`` for the few failures the standard has no + #: condition for, such as a statement that was interrupted. + code: str | None + #: The standard's own words for the condition, never paraphrased, + #: for example ``"syntax error or access rule violation, invalid + #: syntax"``. + condition: str | None + #: ``"exception"``, ``"warning"``, ``"informational"``, + #: ``"no_data"`` or ``"success"``. + severity: str | None + #: Line of the statement the condition was raised at, counting from + #: one. ``None``, with ``column`` and ``offset``, when the + #: condition happened somewhere the text cannot name. + line: int | None + #: Column on that line, counting from one, and a valid index into + #: ``excerpt`` after subtracting that one. + column: int | None + #: Bytes into the statement, counting from zero, for a caller that + #: wants to slice the text rather than print it. + offset: int | None + #: The whole line the position is on, quoted out of the statement, + #: for the caller who has the error and no longer has the text. + excerpt: str | None + #: The page that documents this condition. + doc_url: str | None + #: Whether running the same statement again could succeed. True + #: only for a transaction that rolled back with nothing done. + retryable: bool + + def __init__( + self, + message: str = "", + *, + code: str | None = None, + condition: str | None = None, + severity: str | None = None, + line: int | None = None, + column: int | None = None, + offset: int | None = None, + excerpt: str | None = None, + doc_url: str | None = None, + retryable: bool = False, + ) -> None: + super().__init__(message) + self.code = code + self.condition = condition + self.severity = severity + self.line = line + self.column = column + self.offset = offset + self.excerpt = excerpt + self.doc_url = doc_url + self.retryable = retryable + + def caret(self) -> str | None: + """The excerpt with a caret under the column, ready to print. + + ``None`` when there is no excerpt to point at. This is the one + piece of formatting the library does, because every caller that + prints an error writes it otherwise and half of them count the + column wrong. + """ + if self.excerpt is None or self.column is None: + return None + return f"{self.excerpt}\n{' ' * (self.column - 1)}^" + + +class ConnectionError(Error): + """Class 08: the database could not be reached or could not be read.""" + + +class DataError(Error): + """Class 22: a value was wrong. Division by zero, a bad cast, a number that did not fit.""" + + +class TransactionError(Error): + """Classes 25, 2D and 40: the transaction, rather than the statement, is what went wrong. + + Check :attr:`Error.retryable` before running it again. A rollback + with nothing done can be retried; a statement whose completion is + unknown cannot, because a retry could do the work twice. + """ + + +class SyntaxError(Error): # noqa: A001 - the standard's name for class 42 + """Class 42: the statement could not be parsed, or named something that is not there.""" + + +class ProgrammingError(Error): + """The caller made a mistake in Python. + + A statement on a closed connection, a parameter of a type zu has no + place for. Nothing reached the engine, so nothing happened to the + database. + """ + + +class InternalError(Error): + """A failure the engine could not describe as a condition. + + A corrupt file, an assumption that did not hold. Worth reporting at + https://github.com/tamnd/zu/issues. + """ + + +class Interrupted(Error): + """The statement was asked to stop and did. + + Nothing failed and nothing was lost: the connection keeps its plans + and its caches and runs the next statement normally. A ``Ctrl-C`` + raises ``KeyboardInterrupt`` instead, since that is what a person + at a keyboard means by it. + """ diff --git a/python/zudb/py.typed b/python/zudb/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..45d0ca1 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,7 @@ +# The same compiler the engine pins, because this crate links the +# engine's crates directly and a binding built with a different one is +# a different build of the same code. Held to the version in +# tamnd/zu's toolchains.toml. +[toolchain] +channel = "1.97.1" +components = ["rustfmt", "clippy"] diff --git a/src/conn.rs b/src/conn.rs new file mode 100644 index 0000000..47c6b58 --- /dev/null +++ b/src/conn.rs @@ -0,0 +1,296 @@ +//! The connection and what a statement gives back. +//! +//! A connection is not thread-safe in the engine and every method +//! takes `&mut self` there, so the one held here sits behind a mutex. +//! That is not a way of making a connection concurrent: a statement +//! holds the mutex for as long as it runs, and two threads that want +//! to run statements at once want two connections. It is there so that +//! a program which shares one by accident waits rather than corrupts. + +use std::path::PathBuf; +use std::sync::Mutex; + +use pyo3::prelude::*; +use pyo3::types::{PyDict, PyList, PyTuple}; +use zudb::query::{QueryResult, Value}; +use zudb::{Config, Database}; + +use crate::error::{closed, to_py_err}; +use crate::value::{Names, from_py, to_py}; + +/// One connection to one database. +/// +/// Statements run on it in order, one at a time. It reads the database +/// as of when it was opened, which is why a program that wants to see +/// another writer's work takes a new connection rather than waiting on +/// this one. +#[pyclass(module = "zudb")] +pub struct Connection { + /// `None` once closed, which is what makes a second `close()` do + /// nothing and a statement after one an error rather than a crash. + inner: Mutex>, + #[pyo3(get)] + path: PathBuf, + #[pyo3(get)] + read_only: bool, +} + +#[pymethods] +impl Connection { + /// Runs one statement and gives back its rows. + /// + /// The parameters are named, never positional, because zuQL names + /// them: `$id` in the statement is `id` in the dictionary. A name + /// the statement does not use is an error from the engine rather + /// than a value quietly ignored. + #[pyo3(signature = (statement, params = None))] + fn execute( + &self, + py: Python<'_>, + statement: &str, + params: Option<&Bound<'_, PyDict>>, + ) -> PyResult { + let params = bind(params)?; + let borrowed: Vec<(&str, Value)> = params + .iter() + .map(|(name, value)| (name.as_str(), value.clone())) + .collect(); + // The GIL goes down for the whole statement, waiting for the + // connection's own lock included. That is the point of a + // compiled engine in a Python process: another thread runs + // while this one is inside the executor, and the signal + // handler gets to run too, which is what lets a `Ctrl-C` + // arrive at all. Waiting for the lock with the GIL held would + // be worse than slow: the thread inside the statement has to + // take the GIL back to return, and it could not. + let (result, names) = py + .detach(|| -> std::result::Result<_, Trouble> { + let mut held = self.inner.lock().map_err(|_| Trouble::Closed)?; + let conn = held.as_mut().ok_or(Trouble::Closed)?; + let names = Names::of(conn.session_mut().catalog()); + let result = conn.query_with(statement, &borrowed)?; + Ok((result, names)) + }) + .map_err(|trouble| trouble.raise(py))?; + Ok(Result { + result, + names, + next: Mutex::new(0), + }) + } + + /// The same call, named for the way it reads in a notebook: + /// `conn.sql(...).to_pandas()`. + #[pyo3(signature = (statement, params = None))] + fn sql( + &self, + py: Python<'_>, + statement: &str, + params: Option<&Bound<'_, PyDict>>, + ) -> PyResult { + self.execute(py, statement, params) + } + + /// Closes the connection and frees what it held. + /// + /// Doing it twice is not an error, because a `with` block that + /// closed early would otherwise fail on the way out. + fn close(&self, py: Python<'_>) { + // Released here too, because closing waits for the statement + // another thread is running and frees caches worth megabytes + // once it has. + py.detach(|| { + if let Ok(mut held) = self.inner.lock() { + drop(held.take()); + } + }); + } + + /// Whether this connection is still open. + #[getter] + fn closed(&self, py: Python<'_>) -> bool { + py.detach(|| self.inner.lock().map(|held| held.is_none()).unwrap_or(true)) + } + + fn __enter__(slf: PyRef<'_, Self>) -> PyRef<'_, Self> { + slf + } + + #[pyo3(signature = (*_exception))] + fn __exit__(&self, py: Python<'_>, _exception: &Bound<'_, PyTuple>) -> bool { + self.close(py); + // False, so an exception raised inside the block carries on + // out of it. A context manager that swallowed one would be a + // very quiet way to lose an error. + false + } + + fn __repr__(&self, py: Python<'_>) -> String { + let state = if self.closed(py) { ", closed" } else { "" }; + format!("", self.path.display()) + } +} + +impl Connection { + /// Opens `path`, creating a database there when there is none. + /// + /// Creating is what every Python database module does and what a + /// notebook expects, and it can only ever create where nothing + /// was: a path that holds a database is opened, and a read-only + /// connection never creates anything at all. + pub fn open( + py: Python<'_>, + path: PathBuf, + read_only: bool, + memory_limit: Option, + threads: Option, + ) -> PyResult { + let mut config = Config::new().read_only(read_only); + if let Some(bytes) = memory_limit { + config = config.memory_limit(bytes); + } + if let Some(threads) = threads { + config = config.threads(threads); + } + let missing = !path.exists(); + let opened = py.detach(|| { + if missing && !read_only { + Database::create_with(&path, config.clone()) + } else { + Database::open_with(&path, config.clone()) + } + .and_then(|db| db.connect()) + }); + Ok(Connection { + inner: Mutex::new(Some(opened.map_err(|err| to_py_err(py, err))?)), + path, + read_only, + }) + } +} + +/// What can go wrong inside a statement, with the GIL down and no way +/// to build a Python exception yet. +enum Trouble { + Closed, + Engine(zudb::ZuError), +} + +impl From for Trouble { + fn from(err: zudb::ZuError) -> Trouble { + Trouble::Engine(err) + } +} + +impl Trouble { + fn raise(self, py: Python<'_>) -> PyErr { + match self { + // A connection whose lock a panic left poisoned is a + // connection nothing can be run on again, which is the + // same fact as a closed one and reads better as one. + Trouble::Closed => closed(py, "this connection"), + Trouble::Engine(err) => to_py_err(py, err), + } + } +} + +/// The rows a statement gave back. +/// +/// Held as the engine produced them and turned into Python objects +/// when they are read, so a result that is about to become an Arrow +/// table never pays for the objects it would have made. +#[pyclass(module = "zudb")] +pub struct Result { + result: QueryResult, + names: Names, + /// Where `fetchone` has reached, which is the only cursor here. + /// Iterating does not move it, because two callers reading one + /// result should not be able to take each other's rows. + next: Mutex, +} + +#[pymethods] +impl Result { + /// The column names, in the order the statement projected them. + #[getter] + fn columns(&self) -> Vec { + self.result.columns.clone() + } + + /// Every row, as a list of tuples. + fn fetchall<'py>(&self, py: Python<'py>) -> PyResult> { + let rows = PyList::empty(py); + for row in &self.result.rows { + rows.append(self.row(py, row)?)?; + } + Ok(rows) + } + + /// The next row, or `None` when there are no more. + fn fetchone<'py>(&self, py: Python<'py>) -> PyResult>> { + let mut next = self.next.lock().map_err(|_| { + pyo3::exceptions::PyRuntimeError::new_err("this result was left locked by a panic") + })?; + let Some(row) = self.result.rows.get(*next) else { + return Ok(None); + }; + *next += 1; + self.row(py, row).map(Some) + } + + /// The warnings the statement raised, if it raised any. A notice + /// is a condition that did not stop the statement, so it arrives + /// beside the rows rather than instead of them. + #[getter] + fn notices<'py>(&self, py: Python<'py>) -> PyResult> { + let out = PyList::empty(py); + for notice in &self.result.notices { + let one = PyDict::new(py); + one.set_item("code", notice.status.code())?; + one.set_item("condition", notice.status.standard_text())?; + one.set_item("detail", ¬ice.detail)?; + out.append(one)?; + } + Ok(out) + } + + fn __len__(&self) -> usize { + self.result.rows.len() + } + + fn __iter__<'py>(slf: PyRef<'py, Self>) -> PyResult> { + let py = slf.py(); + slf.fetchall(py)?.call_method0("__iter__") + } + + fn __repr__(&self) -> String { + format!( + "", + self.result.columns.len(), + self.result.rows.len() + ) + } +} + +impl Result { + fn row<'py>(&self, py: Python<'py>, row: &[Value]) -> PyResult> { + PyTuple::new( + py, + row.iter() + .map(|value| to_py(py, value, &self.names)) + .collect::>>()?, + ) + } +} + +/// The parameter dictionary as the engine takes it. +fn bind(params: Option<&Bound<'_, PyDict>>) -> PyResult> { + let Some(params) = params else { + return Ok(Vec::new()); + }; + let mut out = Vec::with_capacity(params.len()); + for (name, value) in params.iter() { + out.push((name.extract::()?, from_py(&value)?)); + } + Ok(out) +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..38f4135 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,139 @@ +//! Raising an engine condition as a Python exception. +//! +//! The classes are defined in `zudb/errors.py` rather than here. A +//! Python programmer reads exception classes, subclasses them and +//! catches them by name, and a class written in Python has a +//! docstring, a readable signature and a stub the type checkers agree +//! about. What this module owns is the mapping: which class a +//! condition raises, and the fields it arrives with. +//! +//! Every field the error model gives a binding is a field here, and +//! none of them is parsed back out of a message. That is the whole +//! point of the model: `except zudb.SyntaxError as e: e.line` is what +//! a caller writes, never a regular expression over `str(e)`. + +use pyo3::prelude::*; +use pyo3::sync::PyOnceLock; +use pyo3::types::{PyDict, PyType}; +use zudb::ZuError; + +/// The `zudb.errors` module, imported once and kept. +/// +/// Imported lazily rather than at module init, because this extension +/// is imported *by* `zudb/__init__.py` and reaching back into a +/// package that is still executing its first line would be a circular +/// import. By the time anything fails, the package is there. +static ERRORS: PyOnceLock> = PyOnceLock::new(); + +fn errors(py: Python<'_>) -> PyResult<&Bound<'_, PyModule>> { + ERRORS + .get_or_try_init(py, || Ok(PyModule::import(py, "zudb.errors")?.unbind())) + .map(|module| module.bind(py)) +} + +/// Turns an engine error into the Python exception it is. +/// +/// A failure to build that exception is raised as itself, since a +/// broken import is a worse thing to hide than the condition that was +/// being reported. +pub fn to_py_err(py: Python<'_>, err: ZuError) -> PyErr { + // A statement that was stopped is reported as whatever stopped it. + // If a signal is what set the flag then Python has a + // `KeyboardInterrupt` waiting to be raised, and raising anything + // else would swallow the user's `Ctrl-C` and have them press it + // again, harder. + if matches!(err, ZuError::Interrupted) + && let Err(signal) = py.check_signals() + { + return signal; + } + match build(py, &err) { + Ok(raised) => raised, + Err(broken) => broken, + } +} + +fn build(py: Python<'_>, err: &ZuError) -> PyResult { + let class: Bound<'_, PyType> = errors(py)?.getattr(class_for(err))?.cast_into()?; + let fields = PyDict::new(py); + fields.set_item("retryable", err.retryable())?; + if let Some(status) = err.gqlstatus() { + fields.set_item("code", status.code())?; + fields.set_item("condition", status.standard_text())?; + fields.set_item("severity", severity(status.severity()))?; + fields.set_item("doc_url", status.doc_url())?; + } + if let Some(at) = err.position() { + fields.set_item("line", at.line)?; + fields.set_item("column", at.column)?; + fields.set_item("offset", at.offset)?; + } + if let Some(excerpt) = err.excerpt() { + fields.set_item("excerpt", excerpt)?; + } + Ok(PyErr::from_value( + class.call((err.to_string(),), Some(&fields))?, + )) +} + +/// The class a condition raises. +/// +/// One exception type per GQLSTATUS class, which is what the two +/// characters that open a code are for. A caller catching +/// `zudb.DataError` catches every one of the forty-two conditions in +/// class 22 without listing them, and a condition zu adds to that +/// class later is caught by the same `except`. +fn class_for(err: &ZuError) -> &'static str { + let Some(status) = err.gqlstatus() else { + return match err { + ZuError::Interrupted => "Interrupted", + ZuError::InvalidArgument(_) => "ProgrammingError", + ZuError::Conflict(_) => "TransactionError", + // A file that is not there, or not readable, is the same + // kind of thing as a database that cannot be reached, and + // class 08 is what the standard calls that. Reporting a + // missing path as an internal error would tell the caller + // to file a bug about their own typo. + ZuError::Io(_) => "ConnectionError", + _ => "InternalError", + }; + }; + match status.class() { + "08" => "ConnectionError", + "22" => "DataError", + "25" | "2D" | "40" => "TransactionError", + "42" => "SyntaxError", + _ => "Error", + } +} + +/// The severity letter's name, because `"exception"` is what a person +/// reading a traceback understands and `X` is what the standard's +/// table says. +fn severity(severity: zudb::Severity) -> &'static str { + match severity { + zudb::Severity::Success => "success", + zudb::Severity::NoData => "no_data", + zudb::Severity::Warning => "warning", + zudb::Severity::Informational => "informational", + zudb::Severity::Exception => "exception", + } +} + +/// What a call raises when it is made on a connection that is closed. +/// +/// A `ValueError` would be wrong and a segfault would be worse: the +/// caller made a mistake, in Python, and `zudb.ProgrammingError` is +/// the class for a mistake a program made rather than a condition the +/// engine raised. +pub fn closed(py: Python<'_>, what: &str) -> PyErr { + match errors(py).and_then(|errors| { + let class: Bound<'_, PyType> = errors.getattr("ProgrammingError")?.cast_into()?; + Ok(PyErr::from_value(class.call1((format!( + "{what} is closed, so there is nothing left to run a statement on" + ),))?)) + }) { + Ok(raised) => raised, + Err(broken) => broken, + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..54c33e7 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,56 @@ +//! The zu extension module. +//! +//! What Python imports is the package `zudb`, and what the package +//! imports is this. Everything with an engine call behind it lives +//! here; everything that is only Python, the exception classes above +//! all, lives in the package, where it has a signature a reader can +//! see and a stub a checker can use. +//! +//! This links the engine crates directly rather than going through +//! `libzu`'s C ABI, which is ADR 0002: the hard part of a Python +//! binding is the runtime, not the FFI, and PyO3 is years of exactly +//! that work. What it owes in return is the ABI's semantics, and the +//! conformance corpus is what says whether it paid. + +mod conn; +mod error; +mod value; + +use std::path::PathBuf; + +use pyo3::prelude::*; + +/// Opens the database at `path` and connects to it. +/// +/// Creates one when the path holds nothing, which is what every Python +/// database module does and what a notebook expects. A read-only +/// connection never creates anything, so a mistyped path there is an +/// error rather than an empty database. +/// +/// `memory_limit` is in bytes and `threads` is how many the executor +/// may use; both default to what the engine decides for the machine. +#[pyfunction] +#[pyo3(signature = (path, *, read_only = false, memory_limit = None, threads = None))] +fn connect( + py: Python<'_>, + path: PathBuf, + read_only: bool, + memory_limit: Option, + threads: Option, +) -> PyResult { + conn::Connection::open(py, path, read_only, memory_limit, threads) +} + +#[pymodule] +fn _zudb(module: &Bound<'_, PyModule>) -> PyResult<()> { + module.add("__engine_version__", env!("CARGO_PKG_VERSION"))?; + module.add("__abi_version__", zudb::C_ABI_VERSION)?; + module.add_function(wrap_pyfunction!(connect, module)?)?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + Ok(()) +} diff --git a/src/value.rs b/src/value.rs new file mode 100644 index 0000000..22eb7bd --- /dev/null +++ b/src/value.rs @@ -0,0 +1,571 @@ +//! Values, both ways across the boundary. +//! +//! A row that arrives as Python objects is the slow path and the one +//! every notebook starts with, so it is the one that has to be +//! obvious: nulls are `None`, integers are `int`, and a graph value is +//! a class with named fields rather than a tuple whose third element +//! is the ordinal if you remember the order. The fast path is Arrow, +//! and nothing here is on it. + +use std::collections::HashMap; + +use pyo3::prelude::*; +use pyo3::types::{PyBool, PyDate, PyDateTime, PyDelta, PyDict, PyList, PyTime, PyTuple, PyTzInfo}; +use zu_common::temporal::{NANOS_PER_DAY, NANOS_PER_MINUTE, civil_from_days, days_from_civil}; +use zu_common::{DurationKind, Temporal}; +use zudb::query::Value; +use zudb::zu1::catalog::Catalog; + +/// Microseconds in a day, which is the unit `datetime.timedelta` +/// normalizes to and the one this has to split a duration across. +const MICROS_PER_DAY: i64 = 86_400 * 1_000_000; + +/// What the tables in a result are called. +/// +/// A node value carries the id of the table it came from and nothing +/// else, because that is what a row holds. A person reading a result +/// wants the name, so the names are taken off the catalog once when a +/// statement runs and carried with the rows. A catalog holds tens of +/// tables, so this is a copy of a few short strings and not a +/// structure worth sharing. +#[derive(Default)] +pub struct Names { + nodes: HashMap, + rels: HashMap, +} + +impl Names { + pub fn of(catalog: &Catalog) -> Names { + Names { + nodes: catalog + .node_tables() + .iter() + .map(|table| (table.id, table.name.clone())) + .collect(), + rels: catalog + .rel_tables() + .iter() + .map(|table| (table.id, table.name.clone())) + .collect(), + } + } + + /// The table's name, or its id written out for a table the catalog + /// no longer has. A result outlives nothing here, but a name is + /// for reading and an unreadable one should still print. + fn node(&self, id: u32) -> String { + self.nodes + .get(&id) + .cloned() + .unwrap_or_else(|| format!("#{id}")) + } + + fn rel(&self, id: u32) -> String { + self.rels + .get(&id) + .cloned() + .unwrap_or_else(|| format!("#{id}")) + } +} + +/// One node of the graph. +/// +/// The table is the name a person wrote in the schema, and the offset +/// is the row it sits at in that table, which together are what +/// identifies a node in zu. Properties and the full label set arrive +/// on a later release; what is here is what a row carries. +#[pyclass(module = "zudb", frozen, eq, hash, skip_from_py_object)] +#[derive(PartialEq, Eq, Hash, Clone)] +pub struct Node { + #[pyo3(get)] + pub table: String, + #[pyo3(get)] + pub offset: u64, +} + +#[pymethods] +impl Node { + #[new] + fn new(table: String, offset: u64) -> Node { + Node { table, offset } + } + + fn __repr__(&self) -> String { + format!("Node({}, {})", self.table, self.offset) + } +} + +/// One edge of the graph. +/// +/// `ord` is which edge this is of the ones running from `src` to +/// `dst`, since a pair of endpoints can be joined more than once and +/// each of those edges carries its own properties. It is the field a +/// caller usually ignores and the one nothing else can replace. +#[pyclass(module = "zudb", frozen, eq, hash, skip_from_py_object)] +#[derive(PartialEq, Eq, Hash, Clone)] +pub struct Rel { + #[pyo3(get)] + pub table: String, + #[pyo3(get)] + pub src: u64, + #[pyo3(get)] + pub dst: u64, + #[pyo3(get)] + pub ord: u64, +} + +#[pymethods] +impl Rel { + #[new] + fn new(table: String, src: u64, dst: u64, ord: u64) -> Rel { + Rel { + table, + src, + dst, + ord, + } + } + + fn __repr__(&self) -> String { + format!("Rel({}, {} -> {})", self.table, self.src, self.dst) + } +} + +/// A walk: nodes and edges alternating, a node at each end. +/// +/// `len` is the number of edges, which is the length of a path +/// everywhere else it is spoken about, so a path of one node has +/// length zero and is the shortest path there is. +#[pyclass(module = "zudb", frozen, skip_from_py_object)] +pub struct Path { + #[pyo3(get)] + pub elements: Py, +} + +#[pymethods] +impl Path { + #[new] + fn new(elements: Py) -> Path { + Path { elements } + } + + /// The nodes of the walk, in the order it visits them. + #[getter] + fn nodes<'py>(&self, py: Python<'py>) -> PyResult> { + self.every(py, 0) + } + + /// The edges of the walk, in the order it crosses them. + #[getter] + fn rels<'py>(&self, py: Python<'py>) -> PyResult> { + self.every(py, 1) + } + + fn every<'py>(&self, py: Python<'py>, from: usize) -> PyResult> { + let all = self.elements.bind(py); + let picked = (from..all.len()) + .step_by(2) + .map(|ix| all.get_item(ix)) + .collect::>>()?; + PyList::new(py, picked) + } + + fn __len__(&self, py: Python<'_>) -> usize { + self.elements.bind(py).len() / 2 + } + + fn __repr__(&self, py: Python<'_>) -> String { + format!("Path({} hops)", self.__len__(py)) + } +} + +/// A duration, which Python has no type for. +/// +/// `datetime.timedelta` is a day-time duration rounded to +/// microseconds, so it can hold neither of the two things zu stores: a +/// year-month duration is a count of months and no number of days is a +/// month, and a day-time duration is counted in nanoseconds. The +/// conversion is offered rather than done, so a caller who wants a +/// `timedelta` asks for one and knows what they gave up. +#[pyclass(module = "zudb", frozen, eq, hash, from_py_object)] +#[derive(PartialEq, Eq, Hash, Clone, Copy)] +pub struct Duration { + /// Months, for a year-month duration. Zero for a day-time one. + #[pyo3(get)] + pub months: i64, + /// Nanoseconds, for a day-time duration. Zero for a year-month + /// one. + #[pyo3(get)] + pub nanoseconds: i64, +} + +#[pymethods] +impl Duration { + #[new] + #[pyo3(signature = (months = 0, nanoseconds = 0))] + fn new(months: i64, nanoseconds: i64) -> PyResult { + if months != 0 && nanoseconds != 0 { + return Err(pyo3::exceptions::PyValueError::new_err( + "a duration counts months or nanoseconds and never both, since no number of days is a month", + )); + } + Ok(Duration { + months, + nanoseconds, + }) + } + + /// `"year_month"` or `"day_time"`, which is the distinction the + /// standard draws and the reason the two counts do not mix. + #[getter] + fn kind(&self) -> &'static str { + if self.months == 0 { + "day_time" + } else { + "year_month" + } + } + + /// The same duration as a `datetime.timedelta`, rounded towards + /// zero to the microsecond it can hold. A year-month duration has + /// no answer and says so. + #[allow(clippy::wrong_self_convention)] // `to_timedelta` is the name Python readers expect + fn to_timedelta<'py>(&self, py: Python<'py>) -> PyResult> { + if self.months != 0 { + return Err(pyo3::exceptions::PyValueError::new_err( + "a year-month duration is not a number of days, so there is no timedelta for it", + )); + } + // Split across the three counts the constructor takes rather + // than handed over as microseconds, because each of them is a + // C int and an hour is already more microseconds than one + // holds. The remainders keep their sign, which is what makes + // a negative duration round towards zero the way a positive + // one does. + let micros = self.nanoseconds / 1_000; + let days = micros / MICROS_PER_DAY; + let rest = micros % MICROS_PER_DAY; + let seconds = rest / 1_000_000; + let micros = rest % 1_000_000; + let count = |value: i64| { + i32::try_from(value).map_err(|_| { + pyo3::exceptions::PyOverflowError::new_err( + "this duration is longer than a timedelta can hold", + ) + }) + }; + PyDelta::new(py, count(days)?, count(seconds)?, count(micros)?, true) + } + + fn __repr__(&self) -> String { + if self.months == 0 { + format!("Duration(nanoseconds={})", self.nanoseconds) + } else { + format!("Duration(months={})", self.months) + } + } +} + +/// One engine value as the Python object it is. +pub fn to_py<'py>(py: Python<'py>, value: &Value, names: &Names) -> PyResult> { + Ok(match value { + Value::Null => py.None().into_bound(py), + Value::Bool(b) => PyBool::new(py, *b).to_owned().into_any(), + Value::Int(n) => n.into_pyobject(py)?.into_any(), + Value::Float(f) => f.into_pyobject(py)?.into_any(), + Value::Str(s) => s.into_pyobject(py)?.into_any(), + Value::Node { table, offset } => Node { + table: names.node(*table), + offset: *offset, + } + .into_pyobject(py)? + .into_any(), + Value::Rel { + table, + src, + dst, + ord, + } => Rel { + table: names.rel(*table), + src: *src, + dst: *dst, + ord: *ord, + } + .into_pyobject(py)? + .into_any(), + Value::List(items) => { + let out = PyList::empty(py); + for item in items { + out.append(to_py(py, item, names)?)?; + } + out.into_any() + } + Value::Record(fields) => { + let out = PyDict::new(py); + for (name, item) in fields { + out.set_item(name, to_py(py, item, names)?)?; + } + out.into_any() + } + Value::Path(walk) => { + let out = PyList::empty(py); + for step in walk { + out.append(to_py(py, step, names)?)?; + } + Path { + elements: out.unbind(), + } + .into_pyobject(py)? + .into_any() + } + Value::Temporal(t) => temporal_to_py(py, *t)?, + // The executor settles a chain into its edge list before any + // value leaves the pipeline, so a result never holds one and + // seeing one here is a bug in the engine rather than something + // a caller did. + Value::Chain(_) => { + return Err(pyo3::exceptions::PyRuntimeError::new_err( + "an unsettled path chain reached a result, which is a bug: please report it at https://github.com/tamnd/zu/issues", + )); + } + }) +} + +fn temporal_to_py<'py>(py: Python<'py>, t: Temporal) -> PyResult> { + Ok(match t { + Temporal::Date(days) => { + let (y, m, d) = civil_from_days(days); + PyDate::new(py, y, m as u8, d as u8)?.into_any() + } + Temporal::LocalTime(nanos) => time_of(py, nanos, None)?.into_any(), + Temporal::ZonedTime { nanos, offset } => { + let zone = zone_of(py, offset)?; + time_of(py, nanos, Some(&zone))?.into_any() + } + Temporal::LocalDatetime(nanos) => datetime_of(py, nanos, None)?.into_any(), + Temporal::ZonedDatetime { nanos, offset } => { + // Stored as the instant in UTC with the offset it was + // written in, so the local reading is the instant moved + // back into that offset. Handing back the instant with a + // zone attached is what makes `.astimezone` and equality + // both answer what the writer meant. + let zone = zone_of(py, offset)?; + let local = nanos.saturating_add(i64::from(offset) * NANOS_PER_MINUTE); + datetime_of(py, local, Some(&zone))?.into_any() + } + Temporal::Duration(DurationKind::YearMonth, months) => Duration { + months, + nanoseconds: 0, + } + .into_pyobject(py)? + .into_any(), + Temporal::Duration(DurationKind::DayTime, nanos) => Duration { + months: 0, + nanoseconds: nanos, + } + .into_pyobject(py)? + .into_any(), + }) +} + +/// A nanosecond count since midnight as a `datetime.time`. +/// +/// Sub-microsecond digits are dropped, because `datetime` has no place +/// to put them. The Arrow path keeps every one of them, which is the +/// answer for a caller who cannot lose them. +fn time_of<'py>( + py: Python<'py>, + nanos: i64, + zone: Option<&Bound<'py, PyTzInfo>>, +) -> PyResult> { + let nanos = nanos.rem_euclid(NANOS_PER_DAY); + let micros = (nanos / 1_000) % 1_000_000; + let secs = nanos / 1_000_000_000; + PyTime::new( + py, + (secs / 3_600) as u8, + ((secs / 60) % 60) as u8, + (secs % 60) as u8, + micros as u32, + zone, + ) +} + +/// A nanosecond count since the epoch as a `datetime.datetime`. +fn datetime_of<'py>( + py: Python<'py>, + nanos: i64, + zone: Option<&Bound<'py, PyTzInfo>>, +) -> PyResult> { + let days = nanos.div_euclid(NANOS_PER_DAY); + let rest = nanos.rem_euclid(NANOS_PER_DAY); + let days = i32::try_from(days).map_err(|_| { + pyo3::exceptions::PyOverflowError::new_err("this datetime is outside the calendar") + })?; + let (y, m, d) = civil_from_days(days); + let micros = (rest / 1_000) % 1_000_000; + let secs = rest / 1_000_000_000; + PyDateTime::new( + py, + y, + m as u8, + d as u8, + (secs / 3_600) as u8, + ((secs / 60) % 60) as u8, + (secs % 60) as u8, + micros as u32, + zone, + ) +} + +/// An offset in minutes as a `datetime.timezone`. +fn zone_of(py: Python<'_>, offset: i16) -> PyResult> { + PyTzInfo::fixed_offset(py, PyDelta::new(py, 0, i32::from(offset) * 60, 0, true)?) +} + +/// One Python object as the engine value it is, for a parameter. +/// +/// The refusals are as much of the surface as the conversions. A +/// parameter zu cannot hold has to fail at the call rather than +/// arriving as a string, because a query that silently compared a +/// number against its own spelling would answer nothing and say +/// nothing. +pub fn from_py(value: &Bound<'_, PyAny>) -> PyResult { + if value.is_none() { + return Ok(Value::Null); + } + // Before the integer arm, because in Python every bool is an int + // and a parameter of `True` is not the parameter `1`. + if let Ok(b) = value.cast::() { + return Ok(Value::Bool(b.is_true())); + } + if let Ok(s) = value.extract::() { + return Ok(Value::Str(s)); + } + if let Ok(n) = value.extract::() { + return Ok(Value::Int(n)); + } + if let Ok(f) = value.extract::() { + return Ok(Value::Float(f)); + } + if let Ok(d) = value.extract::() { + return Ok(Value::Temporal(if d.months == 0 { + Temporal::Duration(DurationKind::DayTime, d.nanoseconds) + } else { + Temporal::Duration(DurationKind::YearMonth, d.months) + })); + } + // Datetime before date, since `datetime.datetime` is a subclass of + // `datetime.date` and reading one as the other would silently + // throw the time away. + if let Ok(dt) = value.cast::() { + return datetime_from_py(dt); + } + if let Ok(d) = value.cast::() { + return Ok(Value::Temporal(Temporal::Date(days_from_civil( + d.getattr("year")?.extract()?, + d.getattr("month")?.extract()?, + d.getattr("day")?.extract()?, + )))); + } + if let Ok(t) = value.cast::() { + return time_from_py(t); + } + if let Ok(delta) = value.cast::() { + let days: i64 = delta.getattr("days")?.extract()?; + let secs: i64 = delta.getattr("seconds")?.extract()?; + let micros: i64 = delta.getattr("microseconds")?.extract()?; + return Ok(Value::Temporal(Temporal::Duration( + DurationKind::DayTime, + days * NANOS_PER_DAY + secs * 1_000_000_000 + micros * 1_000, + ))); + } + if let Ok(items) = value.cast::() { + return Ok(Value::List( + items + .iter() + .map(|item| from_py(&item)) + .collect::>()?, + )); + } + if let Ok(items) = value.cast::() { + return Ok(Value::List( + items + .iter() + .map(|item| from_py(&item)) + .collect::>()?, + )); + } + if let Ok(fields) = value.cast::() { + let mut out = Vec::with_capacity(fields.len()); + for (name, item) in fields.iter() { + out.push((name.extract::()?, from_py(&item)?)); + } + return Ok(Value::record(out)); + } + Err(refused(value)) +} + +fn datetime_from_py(dt: &Bound<'_, PyDateTime>) -> PyResult { + let days = days_from_civil( + dt.getattr("year")?.extract()?, + dt.getattr("month")?.extract()?, + dt.getattr("day")?.extract()?, + ); + let nanos = i64::from(days) * NANOS_PER_DAY + clock_nanos(dt.as_any())?; + Ok(Value::Temporal(match offset_of(dt.as_any())? { + // Stored as the instant, which is the local reading with the + // offset taken back off it, and the offset kept beside it so + // the value still prints in the zone it was written in. + Some(offset) => Temporal::ZonedDatetime { + nanos: nanos - i64::from(offset) * NANOS_PER_MINUTE, + offset, + }, + None => Temporal::LocalDatetime(nanos), + })) +} + +fn time_from_py(t: &Bound<'_, PyTime>) -> PyResult { + let nanos = clock_nanos(t.as_any())?; + Ok(Value::Temporal(match offset_of(t.as_any())? { + Some(offset) => Temporal::ZonedTime { nanos, offset }, + None => Temporal::LocalTime(nanos), + })) +} + +/// The clock reading of a `time` or a `datetime`, in nanoseconds since +/// midnight. +fn clock_nanos(value: &Bound<'_, PyAny>) -> PyResult { + let hour: i64 = value.getattr("hour")?.extract()?; + let minute: i64 = value.getattr("minute")?.extract()?; + let second: i64 = value.getattr("second")?.extract()?; + let micro: i64 = value.getattr("microsecond")?.extract()?; + Ok(((hour * 60 + minute) * 60 + second) * 1_000_000_000 + micro * 1_000) +} + +/// The offset from UTC in minutes, for a value that carries one. +/// +/// A zone that is a rule rather than an offset, `ZoneInfo` above all, +/// is asked what its offset is for this value and stored as that. The +/// name is not stored, on purpose: a name is a rule that changes when +/// the zone database is updated, and a value that means something +/// different tomorrow is not a value. +fn offset_of(value: &Bound<'_, PyAny>) -> PyResult> { + let offset = value.call_method0("utcoffset")?; + if offset.is_none() { + return Ok(None); + } + let seconds: f64 = offset.call_method0("total_seconds")?.extract()?; + Ok(Some((seconds / 60.0) as i16)) +} + +fn refused(value: &Bound<'_, PyAny>) -> PyErr { + let name = value + .get_type() + .getattr("__name__") + .and_then(|name| name.extract::()) + .unwrap_or_else(|_| "that".to_string()); + pyo3::exceptions::PyTypeError::new_err(format!( + "a parameter cannot be a {name}: zu holds nulls, booleans, integers, floats, strings, lists, records, dates, times, datetimes and durations" + )) +} diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..3c8177b --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,64 @@ +"""Fixtures shared by the suite. + +Every test gets a database of its own under a temporary directory, so a +test that writes cannot change what another test reads and a failure +leaves the file behind for exactly as long as pytest keeps the +directory. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +import zudb + +PEOPLE = [(10, "ada", 36.5), (20, "grace", 45.0), (30, "kay", 22.25)] + + +@pytest.fixture +def empty(tmp_path: Path) -> zudb.Connection: + """A database with nothing in it.""" + conn = zudb.connect(tmp_path / "empty.zu1") + yield conn + conn.close() + + +@pytest.fixture +def social(tmp_path: Path) -> zudb.Connection: + """Three people, written the way the engine writes them. + + The property is `uid` rather than `id` because `id` is the name the + v0 engine gives a node's offset, and a table that carries one of + its own is a table where the two disagree. + + The first row is written out rather than parameterized, because the + table does not exist yet and a value that has to be worked out + first says nothing about the column it would go in. Every row after + it goes in as parameters, which is the way a program writes rows. + """ + conn = zudb.connect(tmp_path / "social.zu1") + first, rest = PEOPLE[0], PEOPLE[1:] + conn.execute(f"INSERT (p:person {{uid: {first[0]}, name: '{first[1]}', score: {first[2]}}})") + for uid, name, score in rest: + conn.execute( + "INSERT (p:person {uid: $uid, name: $name, score: $score})", + {"uid": uid, "name": name, "score": score}, + ) + yield conn + conn.close() + + +@pytest.fixture +def crowd(tmp_path: Path) -> zudb.Connection: + """Enough people that a statement over the pairs of them takes long + enough to time, which is what the threading tests need. + """ + conn = zudb.connect(tmp_path / "crowd.zu1") + conn.execute("INSERT (p:person {uid: 0, name: 'seed'})") + conn.execute( + "UNWIND $rows AS r INSERT (p:person {uid: r.uid, name: r.name})", + {"rows": [{"uid": uid, "name": f"p{uid}"} for uid in range(1, 150)]}, + ) + yield conn + conn.close() diff --git a/tests/test_connection.py b/tests/test_connection.py new file mode 100644 index 0000000..35c37e1 --- /dev/null +++ b/tests/test_connection.py @@ -0,0 +1,87 @@ +"""Opening, closing, and what a connection says about itself.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +import zudb + + +def test_connect_creates_a_database_that_was_not_there(tmp_path: Path) -> None: + path = tmp_path / "new.zu1" + assert not path.exists() + conn = zudb.connect(path) + assert path.exists() + assert conn.path == path + assert conn.read_only is False + conn.close() + + +def test_a_written_row_is_there_on_the_next_connection(tmp_path: Path) -> None: + path = tmp_path / "again.zu1" + with zudb.connect(path) as conn: + conn.execute("INSERT (p:person {uid: 1, name: 'ada'})") + with zudb.connect(path) as conn: + assert conn.execute("MATCH (p:person) RETURN p.name AS n").fetchall() == [("ada",)] + + +def test_a_read_only_connection_never_creates(tmp_path: Path) -> None: + path = tmp_path / "absent.zu1" + with pytest.raises(zudb.ConnectionError): + zudb.connect(path, read_only=True) + assert not path.exists() + + +def test_a_read_only_connection_reads(tmp_path: Path) -> None: + path = tmp_path / "frozen.zu1" + with zudb.connect(path) as conn: + conn.execute("INSERT (p:person {uid: 1, name: 'ada'})") + with zudb.connect(path, read_only=True) as conn: + assert conn.read_only is True + assert conn.execute("MATCH (p:person) RETURN p.name AS n").fetchall() == [("ada",)] + + +def test_closing_twice_is_not_an_error(empty: zudb.Connection) -> None: + assert empty.closed is False + empty.close() + assert empty.closed is True + empty.close() + assert empty.closed is True + + +def test_a_statement_after_close_says_so(empty: zudb.Connection) -> None: + empty.close() + with pytest.raises(zudb.ProgrammingError, match="closed"): + empty.execute("RETURN 1 AS one") + + +def test_the_block_closes_on_the_way_out(tmp_path: Path) -> None: + with zudb.connect(tmp_path / "block.zu1") as conn: + assert conn.closed is False + assert conn.closed is True + + +def test_an_exception_leaves_the_block_rather_than_being_swallowed(tmp_path: Path) -> None: + with pytest.raises(ValueError, match="mine"): + with zudb.connect(tmp_path / "raise.zu1") as conn: + raise ValueError("mine") + assert conn.closed is True + + +def test_repr_names_the_file_and_says_when_it_is_closed(tmp_path: Path) -> None: + conn = zudb.connect(tmp_path / "shown.zu1") + assert "shown.zu1" in repr(conn) + assert "closed" not in repr(conn) + conn.close() + assert "closed" in repr(conn) + + +def test_the_engine_and_abi_versions_are_reported(empty: zudb.Connection) -> None: + assert zudb.__version__ == "0.0.1" + assert zudb.__abi_version__.count(".") == 1 + + +def test_a_memory_limit_and_a_thread_count_are_accepted(tmp_path: Path) -> None: + with zudb.connect(tmp_path / "tuned.zu1", memory_limit=64 << 20, threads=2) as conn: + assert conn.execute("RETURN 1 AS one").fetchall() == [(1,)] diff --git a/tests/test_errors.py b/tests/test_errors.py new file mode 100644 index 0000000..2b7bc29 --- /dev/null +++ b/tests/test_errors.py @@ -0,0 +1,92 @@ +"""Conditions as exceptions, with the fields the error model promises.""" + +from __future__ import annotations + +import pytest +import zudb + + +def test_a_syntax_error_carries_its_code_and_its_place(empty: zudb.Connection) -> None: + with pytest.raises(zudb.SyntaxError) as raised: + empty.execute("MATCH (p:person) RETRUN p") + err = raised.value + assert err.code.startswith("42") + assert err.condition + assert err.severity == "exception" + assert err.line == 1 + assert err.column > 1 + assert err.offset > 0 + assert err.retryable is False + + +def test_the_doc_url_points_at_the_page_for_the_code(empty: zudb.Connection) -> None: + with pytest.raises(zudb.SyntaxError) as raised: + empty.execute("MATCH (p:person) RETRUN p") + err = raised.value + assert err.doc_url == f"https://zu.dev/docs/errors/{err.code.lower()}" + + +def test_an_excerpt_and_a_caret_point_at_the_token(empty: zudb.Connection) -> None: + statement = "MATCH (p:person) RETRUN p" + with pytest.raises(zudb.SyntaxError) as raised: + empty.execute(statement) + err = raised.value + assert err.excerpt == statement + assert statement[err.offset :].startswith("RETRUN") + quoted, pointer = err.caret().splitlines() + assert quoted == statement + assert pointer.index("^") == err.column - 1 + + +def test_a_label_nothing_declares_is_a_reference_error(empty: zudb.Connection) -> None: + with pytest.raises(zudb.Error) as raised: + empty.execute("MATCH (p:person)-[:follows]->(q:person) RETURN p") + assert raised.value.code == "42002" + + +def test_every_condition_is_catchable_as_the_base_class(empty: zudb.Connection) -> None: + with pytest.raises(zudb.Error): + empty.execute("this is not a statement") + + +def test_a_condition_is_an_exception_and_reads_like_one(empty: zudb.Connection) -> None: + with pytest.raises(zudb.Error) as raised: + empty.execute("MATCH (p:person) RETURN") + assert isinstance(raised.value, Exception) + assert str(raised.value) + + +def test_a_wrong_type_in_a_value_is_a_data_error(social: zudb.Connection) -> None: + with pytest.raises(zudb.DataError) as raised: + social.execute("INSERT (p:person {uid: 'ten', name: 'zoe', score: 1.0})") + assert raised.value.code.startswith("22") + + +def test_a_mistake_the_program_made_is_a_programming_error(empty: zudb.Connection) -> None: + empty.close() + with pytest.raises(zudb.ProgrammingError): + empty.execute("RETURN 1 AS one") + + +def test_a_missing_database_is_a_connection_error(tmp_path) -> None: + with pytest.raises(zudb.ConnectionError): + zudb.connect(tmp_path / "not-here.zu1", read_only=True) + + +def test_the_error_classes_are_zu_classes_and_not_the_builtins() -> None: + assert zudb.SyntaxError is not SyntaxError + assert issubclass(zudb.SyntaxError, zudb.Error) + assert issubclass(zudb.DataError, zudb.Error) + assert issubclass(zudb.ConnectionError, zudb.Error) + assert issubclass(zudb.TransactionError, zudb.Error) + assert issubclass(zudb.ProgrammingError, zudb.Error) + assert issubclass(zudb.InternalError, zudb.Error) + assert issubclass(zudb.Interrupted, zudb.Error) + + +def test_a_condition_that_never_happened_leaves_the_fields_empty() -> None: + err = zudb.Error("nothing in particular") + assert err.code is None + assert err.line is None + assert err.caret() is None + assert str(err) == "nothing in particular" diff --git a/tests/test_params.py b/tests/test_params.py new file mode 100644 index 0000000..47fc0e3 --- /dev/null +++ b/tests/test_params.py @@ -0,0 +1,83 @@ +"""Parameters, which are named and typed and never string formatting.""" + +from __future__ import annotations + +import datetime + +import pytest +import zudb + + +@pytest.mark.parametrize( + "value", + [ + None, + True, + False, + 0, + -7, + 1 << 40, + 1.5, + "ada", + "", + [1, 2, 3], + [1, "a", None], + [], + datetime.date(2020, 1, 2), + datetime.time(1, 2, 3), + datetime.time(1, 2, 3, 400_000), + datetime.datetime(2020, 1, 2, 3, 4, 5), + datetime.datetime(2020, 1, 2, 3, 4, 5, 6), + datetime.datetime(2020, 1, 2, 3, 4, 5, tzinfo=datetime.UTC), + zudb.Duration(months=14), + zudb.Duration(nanoseconds=90_061_000_000_000), + ], +) +def test_a_parameter_comes_back_as_what_went_in(empty: zudb.Connection, value: object) -> None: + assert empty.execute("RETURN $p AS p", {"p": value}).fetchone() == (value,) + + +def test_a_tuple_is_a_list_on_the_way_in(empty: zudb.Connection) -> None: + assert empty.execute("RETURN $p AS p", {"p": (1, 2)}).fetchone() == ([1, 2],) + + +def test_a_timedelta_becomes_a_day_time_duration(empty: zudb.Connection) -> None: + delta = datetime.timedelta(days=2, seconds=3, microseconds=4) + got = empty.execute("RETURN $p AS p", {"p": delta}).fetchone()[0] + assert got == zudb.Duration(nanoseconds=172_803_000_004_000) + assert got.to_timedelta() == delta + + +def test_several_parameters_are_told_apart_by_name(empty: zudb.Connection) -> None: + rows = empty.execute("RETURN $a AS a, $b AS b", {"a": 1, "b": "two"}) + assert rows.fetchall() == [(1, "two")] + + +def test_a_parameter_is_a_value_and_never_a_fragment_of_the_statement( + social: zudb.Connection, +) -> None: + hostile = "ada' RETURN 1 AS pwned MATCH (p:person) WHERE p.name = 'x" + rows = social.execute("MATCH (p:person) WHERE p.name = $n RETURN p.name AS n", {"n": hostile}) + assert rows.fetchall() == [] + + +def test_a_parameter_filters_the_same_way_a_literal_does(social: zudb.Connection) -> None: + by_name = social.execute( + "MATCH (p:person) WHERE p.name = $n RETURN p.uid AS uid", {"n": "grace"} + ) + assert by_name.fetchall() == [(20,)] + + +def test_no_parameters_at_all_is_fine(social: zudb.Connection) -> None: + assert social.execute("MATCH (p:person) RETURN count(p) AS n").fetchall() == [(3,)] + + +def test_a_type_zu_does_not_hold_is_refused_by_name(empty: zudb.Connection) -> None: + with pytest.raises(TypeError, match="cannot be a object"): + empty.execute("RETURN $p AS p", {"p": object()}) + + +def test_a_bool_is_a_bool_and_not_the_integer_one(empty: zudb.Connection) -> None: + got = empty.execute("RETURN $p AS p", {"p": True}).fetchone()[0] + assert got is True + assert type(got) is bool diff --git a/tests/test_query.py b/tests/test_query.py new file mode 100644 index 0000000..26e2e74 --- /dev/null +++ b/tests/test_query.py @@ -0,0 +1,83 @@ +"""Running a statement and reading the rows back.""" + +from __future__ import annotations + +import pytest +import zudb +from conftest import PEOPLE + + +def test_columns_come_back_in_the_order_they_were_projected(social: zudb.Connection) -> None: + rows = social.execute("MATCH (p:person) RETURN p.name AS name, p.uid AS uid") + assert rows.columns == ["name", "uid"] + + +def test_fetchall_gives_every_row_as_a_tuple(social: zudb.Connection) -> None: + rows = social.execute("MATCH (p:person) RETURN p.uid AS uid, p.name AS name ORDER BY uid") + assert rows.fetchall() == [(uid, name) for uid, name, _ in PEOPLE] + + +def test_fetchone_walks_the_rows_and_then_answers_none(social: zudb.Connection) -> None: + rows = social.execute("MATCH (p:person) RETURN p.uid AS uid ORDER BY uid") + assert rows.fetchone() == (10,) + assert rows.fetchone() == (20,) + assert rows.fetchone() == (30,) + assert rows.fetchone() is None + assert rows.fetchone() is None + + +def test_iterating_does_not_move_the_cursor(social: zudb.Connection) -> None: + rows = social.execute("MATCH (p:person) RETURN p.uid AS uid ORDER BY uid") + assert [uid for (uid,) in rows] == [10, 20, 30] + assert rows.fetchone() == (10,) + assert [uid for (uid,) in rows] == [10, 20, 30] + + +def test_a_result_knows_how_many_rows_it_holds(social: zudb.Connection) -> None: + rows = social.execute("MATCH (p:person) RETURN p.uid AS uid") + assert len(rows) == 3 + assert len(social.execute("MATCH (p:person) WHERE p.uid > 100 RETURN p.uid AS uid")) == 0 + + +def test_repr_says_the_shape(social: zudb.Connection) -> None: + rows = social.execute("MATCH (p:person) RETURN p.uid AS uid, p.name AS name") + assert repr(rows) == "" + + +def test_sql_is_the_same_call_under_the_name_a_notebook_uses(social: zudb.Connection) -> None: + assert social.sql("MATCH (p:person) RETURN count(p) AS n").fetchall() == [(3,)] + + +def test_aggregation_and_ordering_run(social: zudb.Connection) -> None: + rows = social.execute("MATCH (p:person) RETURN p.name AS name ORDER BY p.score DESC LIMIT 2") + assert rows.fetchall() == [("grace",), ("ada",)] + + +def test_a_statement_that_writes_answers_no_columns(empty: zudb.Connection) -> None: + rows = empty.execute("INSERT (p:person {uid: 1, name: 'ada'})") + assert rows.columns == [] + assert len(rows) == 0 + + +def test_a_statement_with_nothing_to_say_has_no_notices(social: zudb.Connection) -> None: + assert social.execute("MATCH (p:person) RETURN p.uid AS uid").notices == [] + + +def test_two_statements_on_one_connection_run_in_order(empty: zudb.Connection) -> None: + empty.execute("INSERT (p:person {uid: 1, name: 'ada'})") + empty.execute("INSERT (p:person {uid: 2, name: 'kay'})") + assert empty.execute("MATCH (p:person) RETURN count(p) AS n").fetchall() == [(2,)] + + +@pytest.mark.parametrize( + "statement,answer", + [ + ("RETURN 1 + 1 AS n", 2), + ("RETURN 'a' + 'b' AS s", "ab"), + ("UNWIND [1, 2, 3] AS n RETURN sum(n) AS total", 6), + ], +) +def test_expressions_answer_without_touching_the_graph( + empty: zudb.Connection, statement: str, answer: object +) -> None: + assert empty.execute(statement).fetchall() == [(answer,)] diff --git a/tests/test_threads.py b/tests/test_threads.py new file mode 100644 index 0000000..78964f7 --- /dev/null +++ b/tests/test_threads.py @@ -0,0 +1,83 @@ +"""What happens when more than one thread is involved. + +A connection is not concurrent and never claims to be: statements on +one run in the order they were asked for, one at a time. What these +check is that the lock which enforces that is taken with the GIL +released, since a thread waiting for the connection while holding the +GIL would stop the thread inside the engine from ever returning. +""" + +from __future__ import annotations + +import threading +from pathlib import Path + +import zudb + +# Every pair of people, filtered, which is a statement that runs for +# long enough to watch rather than one that is over before the other +# thread is scheduled. +WORK = "MATCH (a:person), (b:person) WHERE a.uid < b.uid RETURN count(a) AS n" +PAIRS = 150 * 149 // 2 + + +def test_two_threads_sharing_one_connection_both_finish(crowd: zudb.Connection) -> None: + answers: list[object] = [] + lock = threading.Lock() + + def run() -> None: + got = crowd.execute(WORK).fetchone() + with lock: + answers.append(got) + + threads = [threading.Thread(target=run) for _ in range(4)] + for thread in threads: + thread.start() + for thread in threads: + thread.join(timeout=120) + assert not thread.is_alive(), "a thread is still waiting for the connection" + assert answers == [(PAIRS,)] * 4 + + +def test_a_connection_per_thread_reads_the_same_database(tmp_path: Path) -> None: + path = tmp_path / "shared.zu1" + with zudb.connect(path) as conn: + conn.execute("INSERT (p:person {uid: 1, name: 'ada'})") + + answers: list[object] = [] + lock = threading.Lock() + + def run() -> None: + with zudb.connect(path, read_only=True) as conn: + got = conn.execute("MATCH (p:person) RETURN p.name AS n").fetchone() + with lock: + answers.append(got) + + threads = [threading.Thread(target=run) for _ in range(4)] + for thread in threads: + thread.start() + for thread in threads: + thread.join(timeout=120) + assert not thread.is_alive(), "a thread is still waiting for the database" + assert answers == [("ada",)] * 4 + + +def test_python_keeps_running_while_a_statement_does(crowd: zudb.Connection) -> None: + ticks = 0 + done = threading.Event() + + def run() -> None: + for _ in range(4): + crowd.execute(WORK) + done.set() + + worker = threading.Thread(target=run) + worker.start() + while not done.is_set(): + ticks += 1 + worker.join(timeout=120) + assert not worker.is_alive() + # A GIL held for the length of a statement would leave this loop + # the gaps between four of them, which is a handful of turns and + # not thousands of them. + assert ticks > 1000, f"the main thread only got {ticks} turns" diff --git a/tests/test_values.py b/tests/test_values.py new file mode 100644 index 0000000..a231e0a --- /dev/null +++ b/tests/test_values.py @@ -0,0 +1,76 @@ +"""What an engine value is once it is a Python object.""" + +from __future__ import annotations + +import datetime + +import pytest +import zudb + + +def test_a_node_carries_its_table_and_offset(social: zudb.Connection) -> None: + rows = social.execute("MATCH (p:person) RETURN p ORDER BY p.uid") + nodes = [node for (node,) in rows] + assert [node.table for node in nodes] == ["person"] * 3 + assert [node.offset for node in nodes] == [0, 1, 2] + assert repr(nodes[0]) == "Node(person, 0)" + + +def test_two_reads_of_one_node_are_equal_and_hash_alike(social: zudb.Connection) -> None: + one = social.execute("MATCH (p:person) WHERE p.uid = 10 RETURN p").fetchone()[0] + two = social.execute("MATCH (p:person) WHERE p.uid = 10 RETURN p").fetchone()[0] + assert one == two + assert len({one, two}) == 1 + + +@pytest.mark.parametrize( + "statement,answer", + [ + ("RETURN 1 AS v", 1), + ("RETURN 1.5 AS v", 1.5), + ("RETURN 'ada' AS v", "ada"), + ("RETURN true AS v", True), + ("RETURN false AS v", False), + ("RETURN null AS v", None), + ("RETURN [1, 2, 3] AS v", [1, 2, 3]), + ("RETURN [[1], [2, 3]] AS v", [[1], [2, 3]]), + ("RETURN [] AS v", []), + ], +) +def test_a_literal_reads_back_as_the_python_object_it_is( + empty: zudb.Connection, statement: str, answer: object +) -> None: + got = empty.execute(statement).fetchone()[0] + assert got == answer + assert type(got) is type(answer) + + +def test_a_duration_counts_months_or_nanoseconds_and_never_both() -> None: + with pytest.raises(ValueError, match="never both"): + zudb.Duration(months=1, nanoseconds=1) + + +def test_a_duration_says_which_kind_it_is() -> None: + assert zudb.Duration(months=14).kind == "year_month" + assert zudb.Duration(nanoseconds=1).kind == "day_time" + assert zudb.Duration().kind == "day_time" + + +def test_a_day_time_duration_converts_to_a_timedelta() -> None: + hour = zudb.Duration(nanoseconds=3_600_000_000_000) + assert hour.to_timedelta() == datetime.timedelta(hours=1) + + +def test_a_conversion_that_rounds_rounds_towards_zero() -> None: + assert zudb.Duration(nanoseconds=1_500).to_timedelta() == datetime.timedelta(microseconds=1) + assert zudb.Duration(nanoseconds=-1_500).to_timedelta() == datetime.timedelta(microseconds=-1) + + +def test_a_year_month_duration_has_no_timedelta() -> None: + with pytest.raises(ValueError, match="no timedelta"): + zudb.Duration(months=3).to_timedelta() + + +def test_a_duration_repr_names_the_count_it_carries() -> None: + assert repr(zudb.Duration(months=3)) == "Duration(months=3)" + assert repr(zudb.Duration(nanoseconds=3)) == "Duration(nanoseconds=3)"