From 7a897f91178df44438a30b39f184f520a4999c8a Mon Sep 17 00:00:00 2001 From: inas sarhan Date: Wed, 15 Apr 2026 22:05:03 +0300 Subject: [PATCH 1/2] Add Python SDK (PyO3 + maturin) Wraps the existing Rust kalam-client core via PyO3 (FFI), matching the approach used by the Dart SDK. Published as the kalamdb pip package. Includes: KalamClient with query/query_rows/query_with_files/insert/delete; parameterized queries; live subscriptions via async iterator; topic consumers with explicit mark_processed + commit; run_agent helper for AI agent loops (retry + backoff); auto-refresh of JWT on TOKEN_EXPIRED; lazy login (constructor does no network I/O); async context managers on Client/Subscription/Consumer; typed exception hierarchy; type stubs; client options (timeout/retries). Ships with a README and a GitHub Actions workflow that runs tests on Linux/macOS/Windows x Python 3.9-3.12, builds cross-platform wheels, and offers a manually-triggered PyPI publish. 61 tests passing against Docker rc2, covering full pub/sub loop end-to-end, file uploads, auto-refresh (incl. concurrent reauth), and real value/error assertions (not smoke tests). --- .github/workflows/python-sdk.yml | 139 ++ link/sdks/python/.gitignore | 9 + link/sdks/python/Cargo.lock | 2177 ++++++++++++++++++ link/sdks/python/Cargo.toml | 21 + link/sdks/python/README.md | 257 +++ link/sdks/python/kalamdb/__init__.py | 30 + link/sdks/python/kalamdb/_native.pyi | 190 ++ link/sdks/python/kalamdb/agent.py | 150 ++ link/sdks/python/pyproject.toml | 29 + link/sdks/python/src/lib.rs | 1053 +++++++++ link/sdks/python/tests/conftest.py | 79 + link/sdks/python/tests/test_agent.py | 75 + link/sdks/python/tests/test_auth.py | 21 + link/sdks/python/tests/test_auth_flows.py | 81 + link/sdks/python/tests/test_auth_refresh.py | 42 + link/sdks/python/tests/test_consumer.py | 51 + link/sdks/python/tests/test_crud.py | 133 ++ link/sdks/python/tests/test_exceptions.py | 31 + link/sdks/python/tests/test_files.py | 83 + link/sdks/python/tests/test_options.py | 39 + link/sdks/python/tests/test_pubsub.py | 124 + link/sdks/python/tests/test_query.py | 97 + link/sdks/python/tests/test_repr.py | 33 + link/sdks/python/tests/test_subscriptions.py | 149 ++ 24 files changed, 5093 insertions(+) create mode 100644 .github/workflows/python-sdk.yml create mode 100644 link/sdks/python/.gitignore create mode 100644 link/sdks/python/Cargo.lock create mode 100644 link/sdks/python/Cargo.toml create mode 100644 link/sdks/python/README.md create mode 100644 link/sdks/python/kalamdb/__init__.py create mode 100644 link/sdks/python/kalamdb/_native.pyi create mode 100644 link/sdks/python/kalamdb/agent.py create mode 100644 link/sdks/python/pyproject.toml create mode 100644 link/sdks/python/src/lib.rs create mode 100644 link/sdks/python/tests/conftest.py create mode 100644 link/sdks/python/tests/test_agent.py create mode 100644 link/sdks/python/tests/test_auth.py create mode 100644 link/sdks/python/tests/test_auth_flows.py create mode 100644 link/sdks/python/tests/test_auth_refresh.py create mode 100644 link/sdks/python/tests/test_consumer.py create mode 100644 link/sdks/python/tests/test_crud.py create mode 100644 link/sdks/python/tests/test_exceptions.py create mode 100644 link/sdks/python/tests/test_files.py create mode 100644 link/sdks/python/tests/test_options.py create mode 100644 link/sdks/python/tests/test_pubsub.py create mode 100644 link/sdks/python/tests/test_query.py create mode 100644 link/sdks/python/tests/test_repr.py create mode 100644 link/sdks/python/tests/test_subscriptions.py diff --git a/.github/workflows/python-sdk.yml b/.github/workflows/python-sdk.yml new file mode 100644 index 00000000..f3b6da52 --- /dev/null +++ b/.github/workflows/python-sdk.yml @@ -0,0 +1,139 @@ +name: Python SDK + +on: + push: + branches: [main, dev] + paths: + - 'link/sdks/python/**' + - 'link/kalam-client/**' + - 'link/link-common/**' + - '.github/workflows/python-sdk.yml' + pull_request: + paths: + - 'link/sdks/python/**' + - 'link/kalam-client/**' + - 'link/link-common/**' + - '.github/workflows/python-sdk.yml' + workflow_dispatch: + inputs: + publish: + description: "Publish wheels to PyPI" + type: boolean + required: false + default: false + +env: + CARGO_TERM_COLOR: always + +jobs: + test: + name: Test (${{ matrix.os }}, Python ${{ matrix.python-version }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + python-version: ["3.9", "3.10", "3.11", "3.12"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + toolchain: "1.92.0" + + - name: Cache Cargo + uses: Swatinem/rust-cache@v2 + with: + workspaces: link/sdks/python -> target + + - name: Install maturin and pytest + run: pip install maturin pytest pytest-asyncio + + - name: Build SDK in develop mode + working-directory: link/sdks/python + run: maturin develop --release + + - name: Run unit tests (no server required) + working-directory: link/sdks/python + run: pytest tests/test_auth.py tests/test_exceptions.py tests/test_agent.py -v + + build-wheels: + name: Build wheels (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + needs: test + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + + steps: + - uses: actions/checkout@v4 + + - name: Build wheels + uses: PyO3/maturin-action@v1 + with: + working-directory: link/sdks/python + command: build + args: --release --strip --out dist + rust-toolchain: "1.92.0" + + - name: Upload wheels + uses: actions/upload-artifact@v4 + with: + name: wheels-${{ matrix.os }} + path: link/sdks/python/dist/*.whl + + build-sdist: + name: Build source distribution + runs-on: ubuntu-latest + needs: test + + steps: + - uses: actions/checkout@v4 + + - name: Build sdist + uses: PyO3/maturin-action@v1 + with: + working-directory: link/sdks/python + command: sdist + args: --out dist + + - name: Upload sdist + uses: actions/upload-artifact@v4 + with: + name: sdist + path: link/sdks/python/dist/*.tar.gz + + publish: + name: Publish to PyPI + runs-on: ubuntu-latest + needs: [build-wheels, build-sdist] + if: github.event_name == 'workflow_dispatch' && github.event.inputs.publish == 'true' + environment: pypi + permissions: + id-token: write + + steps: + - name: Download all wheels + uses: actions/download-artifact@v4 + with: + path: dist + pattern: wheels-* + merge-multiple: true + + - name: Download sdist + uses: actions/download-artifact@v4 + with: + name: sdist + path: dist + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: dist diff --git a/link/sdks/python/.gitignore b/link/sdks/python/.gitignore new file mode 100644 index 00000000..3d8f6be5 --- /dev/null +++ b/link/sdks/python/.gitignore @@ -0,0 +1,9 @@ +target/ +.venv/ +__pycache__/ +*.pyc +*.pyd +*.so +dist/ +build/ +*.egg-info/ diff --git a/link/sdks/python/Cargo.lock b/link/sdks/python/Cargo.lock new file mode 100644 index 00000000..76b36bcb --- /dev/null +++ b/link/sdks/python/Cargo.lock @@ -0,0 +1,2177 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "aws-lc-rs" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83a25cf98105baa966497416dbd42565ce3a8cf8dbfd59803ec9ad46f3126399" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[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 = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2b52f86d1d4bc0d6b4e6826d960b1b333217e07d36b882dca570a5e1c48895b" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iri-string" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "kalam-client" +version = "0.4.2-rc2" +dependencies = [ + "link-common", +] + +[[package]] +name = "kalamdb-python" +version = "0.1.0" +dependencies = [ + "kalam-client", + "pyo3", + "pyo3-async-runtimes", + "serde", + "serde_json", + "tokio", +] + +[[package]] +name = "libc" +version = "0.2.185" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" + +[[package]] +name = "link-common" +version = "0.4.2-rc2" +dependencies = [ + "base64", + "bytes", + "futures-util", + "http-body", + "http-body-util", + "log", + "miniz_oxide", + "reqwest", + "rmp-serde", + "serde", + "serde_json", + "tokio", + "tokio-tungstenite", +] + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[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.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[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 = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pyo3" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8970a78afe0628a3e3430376fc5fd76b6b45c4d43360ffd6cdd40bdde72b682a" +dependencies = [ + "indoc", + "libc", + "memoffset", + "once_cell", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", + "unindent", +] + +[[package]] +name = "pyo3-async-runtimes" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d73cc6b1b7d8b3cef02101d37390dbdfe7e450dfea14921cae80a9534ba59ef2" +dependencies = [ + "futures", + "once_cell", + "pin-project-lite", + "pyo3", + "tokio", +] + +[[package]] +name = "pyo3-build-config" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "458eb0c55e7ece017adeba38f2248ff3ac615e53660d7c71a238d7d2a01c7598" +dependencies = [ + "once_cell", + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7114fe5457c61b276ab77c5055f206295b812608083644a5c5b2640c3102565c" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8725c0a622b374d6cb051d11a0983786448f7785336139c3c94f5aa6bef7e50" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4109984c22491085343c05b0dbc54ddc405c3cf7b4374fc533f5c3313a572ccc" +dependencies = [ + "heck", + "proc-macro2", + "pyo3-build-config", + "quote", + "syn", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "reqwest" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustls" +version = "0.23.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20a6af516fea4b20eccceaf166e8aa666ac996208e8a644ce3ef5aa783bc7cd4" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.51.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", + "webpki-roots 0.26.11", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[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", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror 2.0.18", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unindent" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[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 = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.68" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[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 = "webpki-root-certs" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.6", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[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 = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +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", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +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", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/link/sdks/python/Cargo.toml b/link/sdks/python/Cargo.toml new file mode 100644 index 00000000..473b08c8 --- /dev/null +++ b/link/sdks/python/Cargo.toml @@ -0,0 +1,21 @@ +[workspace] + +[package] +name = "kalamdb-python" +version = "0.1.0" +edition = "2024" +license = "Apache-2.0" +description = "Python SDK for KalamDB — SQL-first realtime database" +keywords = ["kalamdb", "database", "python", "sdk"] + +[lib] +name = "_native" +crate-type = ["cdylib"] + +[dependencies] +kalam-client = { path = "../../kalam-client", default-features = false, features = ["native-sdk", "consumer", "file-uploads"] } +pyo3 = { version = "0.25", features = ["extension-module"] } +pyo3-async-runtimes = { version = "0.25", features = ["tokio-runtime"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros"] } +serde = "1" +serde_json = "1" diff --git a/link/sdks/python/README.md b/link/sdks/python/README.md new file mode 100644 index 00000000..3ba1bb9d --- /dev/null +++ b/link/sdks/python/README.md @@ -0,0 +1,257 @@ +# KalamDB Python SDK + +Official Python SDK for [KalamDB](https://github.com/kalamstack/KalamDB) — a SQL-first realtime database for AI agents, chat products, and multi-tenant SaaS. + +Built on top of the Rust `kalam-client` core via PyO3, so the networking, reconnection, and protocol logic is shared with the TypeScript and Dart SDKs. + +## Installation + +```bash +pip install kalamdb +``` + +## Quick Start + +```python +import asyncio +from kalamdb import KalamClient, Auth + +async def main(): + async with KalamClient("http://localhost:8080", Auth.basic("admin", "password")) as client: + # Run a SQL query + rows = await client.query_rows("SELECT * FROM app.users LIMIT 10") + for row in rows: + print(row) + + # Insert a row + await client.insert("app.messages", { + "role": "user", + "content": "hello from python" + }) + + # Subscribe to live changes + async with await client.subscribe("SELECT * FROM app.messages") as sub: + async for event in sub: + print("change:", event) + if event.get("change_type") == "insert": + break + +asyncio.run(main()) +``` + +## Authentication + +```python +# Basic auth (username/password) — auto-exchanges for JWT on connect +client = KalamClient(url, Auth.basic("admin", "password")) + +# JWT token (if you already have one) +client = KalamClient(url, Auth.jwt("eyJhbG...")) +``` + +## Queries + +```python +# Full query response (schema + rows + metadata) +response = await client.query("SELECT id, name FROM app.users") + +# Just the rows as a list of dicts +rows = await client.query_rows("SELECT id, name FROM app.users") + +# Parameterized queries (recommended for user input) +rows = await client.query_rows( + "SELECT * FROM app.users WHERE id = $1 AND active = $2", + [user_id, True], +) + +# Insert via convenience method (auto-builds SQL) +await client.insert("app.messages", { + "role": "user", + "content": "hello" +}) + +# Delete by ID +await client.delete("app.messages", 12345) +``` + +## File Uploads + +KalamDB tables can have `FILE` columns. Reference uploaded files in SQL with `FILE("placeholder")`: + +```python +with open("avatar.png", "rb") as f: + data = f.read() + +await client.query_with_files( + "INSERT INTO app.users (name, avatar) VALUES ($1, FILE('avatar'))", + {"avatar": ("avatar.png", data, "image/png")}, + ["alice"], +) +``` + +The `files` dict maps each placeholder to a tuple of `(filename, bytes)` or `(filename, bytes, mime_type)`. + +## Error Handling + +The SDK raises typed exceptions you can catch individually: + +```python +from kalamdb import ( + KalamError, # base class — catches everything + KalamConnectionError, # network, websocket, timeout + KalamAuthError, # bad credentials, expired token + KalamServerError, # server returned an error response + KalamConfigError, # invalid client configuration +) + +try: + rows = await client.query_rows("SELECT * FROM nonexistent") +except KalamServerError as e: + print("server rejected query:", e) +except KalamAuthError: + print("login failed — check credentials") +except KalamConnectionError: + print("network unreachable") +``` + +## Topic Consumers (Pub/Sub) + +For Kafka-style topic consumption, use `client.consume()`. Tracks per-group offsets so each consumer group reads independently. + +```python +async with await client.consume( + topic="blog.posts", + group_id="summarizer-v1", + start="earliest", # or "latest" +) as consumer: + while True: + records = await consumer.poll() + if not records: + await asyncio.sleep(0.5) + continue + + for record in records: + try: + print("got record:", record["offset"], record["payload"]) + await consumer.mark_processed(record) + except Exception as e: + print("failed:", e) + # Don't mark — it'll be redelivered on the next poll. + break + + await consumer.commit() # ack all marked records +``` + +`mark_processed()` is separate from `poll()` so a mid-batch failure doesn't silently advance the offset past unhandled records. For typical AI agent use, prefer `run_agent` below which handles this for you. + +### High-level Agent Helper + +For most AI agent use cases, use `run_agent` instead — it handles the polling loop, retries with exponential backoff, and commits automatically. + +```python +from kalamdb import KalamClient, Auth, run_agent + +async with KalamClient(url, Auth.basic("admin", "pass")) as client: + async def summarize(record): + summary = await llm.summarize(record["payload"]) + await save_summary(record["message_id"], summary) + + async def on_failed(record, error): + print("permanent failure:", record["offset"], error) + + await run_agent( + client=client, + topic="blog.posts", + group_id="summarizer-v1", + on_record=summarize, + on_failed=on_failed, + start="earliest", + max_attempts=3, + initial_backoff_ms=250, + max_backoff_ms=2000, + ack_on_failed=True, # commit even on permanent failure (so it doesn't replay) + ) +``` + +Stop the loop with an `asyncio.Event`: + +```python +stop = asyncio.Event() +asyncio.create_task(server.serve()) # something else +# later: +stop.set() +``` + +## Live Subscriptions + +```python +sub = await client.subscribe("SELECT * FROM chat.messages WHERE thread_id = 1") + +async for event in sub: + kind = event.get("type") + if kind == "subscription_ack": + print("subscribed, total rows:", event["total_rows"]) + elif kind == "initial_data_batch": + for row in event["rows"]: + print("initial:", row) + elif kind == "change": + print(event["change_type"], event.get("rows")) + elif kind == "error": + print("error:", event["message"]) + break + +await sub.close() +``` + +## Building from source + +Requirements: +- Rust 1.92+ +- Python 3.9+ +- [maturin](https://github.com/PyO3/maturin) + +```bash +cd link/sdks/python +python -m venv .venv +source .venv/bin/activate # on Windows: .venv\Scripts\activate +pip install maturin +maturin develop +``` + +## Running the tests + +Tests expect a KalamDB server running at `http://localhost:8088`. Start one with Docker: + +```bash +docker compose -f ../../../docker/run/single/docker-compose.yml up -d +``` + +Then run the tests: + +```bash +pip install -e ".[dev]" +pytest +``` + +Configure the test server via environment variables: + +```bash +KALAMDB_TEST_URL=http://localhost:8088 \ +KALAMDB_TEST_USER=admin \ +KALAMDB_TEST_PASSWORD=yourpass \ + pytest +``` + +## Architecture + +The SDK wraps the existing `kalam-client` Rust crate via [PyO3](https://pyo3.rs) (Python's standard FFI bridge to Rust). The compiled Rust core ships as a platform-specific wheel (`.whl`) on PyPI — no Rust toolchain needed at install time. + +``` +Python code → kalamdb (PyO3) → kalam-client (Rust) → network +``` + +This mirrors the architecture of the Dart SDK (also FFI) and differs from the TypeScript SDK (WASM, required by browsers). + +## License + +Apache-2.0 diff --git a/link/sdks/python/kalamdb/__init__.py b/link/sdks/python/kalamdb/__init__.py new file mode 100644 index 00000000..16355599 --- /dev/null +++ b/link/sdks/python/kalamdb/__init__.py @@ -0,0 +1,30 @@ +"""KalamDB Python SDK. + +A SQL-first realtime database client for AI agents and multi-tenant SaaS. +""" + +from ._native import ( + Auth, + KalamClient, + Subscription, + Consumer, + KalamError, + KalamConnectionError, + KalamAuthError, + KalamServerError, + KalamConfigError, +) +from .agent import run_agent + +__all__ = [ + "Auth", + "KalamClient", + "Subscription", + "Consumer", + "KalamError", + "KalamConnectionError", + "KalamAuthError", + "KalamServerError", + "KalamConfigError", + "run_agent", +] diff --git a/link/sdks/python/kalamdb/_native.pyi b/link/sdks/python/kalamdb/_native.pyi new file mode 100644 index 00000000..41ca3200 --- /dev/null +++ b/link/sdks/python/kalamdb/_native.pyi @@ -0,0 +1,190 @@ +"""Type stubs for the kalamdb Python SDK.""" + +from typing import Any + +class KalamError(Exception): + """Base class for all KalamDB SDK errors.""" + +class KalamConnectionError(KalamError): + """Network, WebSocket, or timeout error.""" + +class KalamAuthError(KalamError): + """Authentication failure (bad credentials, expired token).""" + +class KalamServerError(KalamError): + """Server returned an error response.""" + +class KalamConfigError(KalamError): + """Invalid client configuration.""" + +class Auth: + """Authentication helper.""" + + @staticmethod + def basic(username: str, password: str) -> dict[str, str]: + """Create basic auth credentials.""" + ... + + @staticmethod + def jwt(token: str) -> dict[str, str]: + """Create JWT auth credentials.""" + ... + +class KalamClient: + """KalamDB client for executing SQL queries and subscribing to changes. + + Example:: + + from kalamdb import KalamClient, Auth + + client = KalamClient("http://localhost:8080", Auth.basic("admin", "pass")) + rows = await client.query_rows("SELECT * FROM app.users") + await client.disconnect() + """ + + def __init__( + self, + url: str, + auth: dict[str, str], + options: dict[str, Any] | None = None, + ) -> None: + """Create a new KalamDB client. + + Args: + url: Server URL (e.g. "http://localhost:8080") + auth: Auth credentials from Auth.basic() or Auth.jwt() + options: Optional client settings: + - timeout_seconds (float): per-request timeout (default 30) + - max_retries (int): max HTTP retry attempts (default 3) + """ + ... + + def __repr__(self) -> str: ... + + async def query( + self, sql: str, params: list[Any] | None = None + ) -> dict[str, Any]: + """Execute SQL and return the full query response. + + Args: + sql: SQL query, optionally with $1, $2, ... placeholders. + params: Values to bind to the placeholders. + """ + ... + + async def query_rows( + self, sql: str, params: list[Any] | None = None + ) -> list[dict[str, Any]]: + """Execute SQL and return rows from the first result set. + + Args: + sql: SQL query, optionally with $1, $2, ... placeholders. + params: Values to bind to the placeholders. + """ + ... + + async def query_with_files( + self, + sql: str, + files: dict[str, tuple], + params: list[Any] | None = None, + ) -> dict[str, Any]: + """Execute SQL with file uploads. + + Args: + sql: SQL query with FILE("name") placeholders. + files: Dict mapping placeholder to (filename, bytes) or (filename, bytes, mime). + params: Optional values for $1, $2, ... placeholders. + """ + ... + + async def insert(self, table: str, data: dict[str, Any]) -> None: + """Insert a row into a table.""" + ... + + async def delete( + self, + table: str, + row_id: Any, + pk_column: str = "id", + ) -> None: + """Delete a row by primary key value. + + Args: + table: Fully qualified table name. + row_id: The primary key value. + pk_column: Name of the primary key column (default: "id"). + """ + ... + + async def disconnect(self) -> None: + """Disconnect the client.""" + ... + + async def consume( + self, + topic: str, + group_id: str, + start: str = "latest", + ) -> "Consumer": + """Create a topic consumer (Kafka-style pub/sub). + + Args: + topic: Topic to consume from. + group_id: Consumer group identifier (tracks per-group offsets). + start: Where to start: "earliest" or "latest" (default "latest"). + """ + ... + + async def __aenter__(self) -> "KalamClient": ... + async def __aexit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None: ... + + +class Subscription: + """Live subscription to a SQL query. Iterate with `async for event in sub`.""" + + async def next(self) -> dict[str, Any]: + """Get the next change event. + + Raises StopAsyncIteration when the stream ends. + """ + ... + + async def close(self) -> None: + """Close the subscription.""" + ... + + async def __aenter__(self) -> "Subscription": ... + async def __aexit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None: ... + def __aiter__(self) -> "Subscription": ... + async def __anext__(self) -> dict[str, Any]: ... + def __repr__(self) -> str: ... + + +class Consumer: + """Topic consumer for Kafka-style pub/sub.""" + + async def poll(self) -> list[dict[str, Any]]: + """Poll for the next batch of records. Empty list if none available. + + Records are NOT automatically marked as processed — you must call + `mark_processed(record)` for each one you've handled before calling + `commit()`. + """ + ... + + async def mark_processed(self, record: dict[str, Any]) -> None: + """Mark a record as successfully processed, so `commit()` will ack it.""" + ... + + async def commit(self) -> None: + """Commit offsets of all records that have been marked as processed.""" + ... + + async def close(self) -> None: + """Close the consumer.""" + ... + + async def __aenter__(self) -> "Consumer": ... + async def __aexit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None: ... + def __repr__(self) -> str: ... diff --git a/link/sdks/python/kalamdb/agent.py b/link/sdks/python/kalamdb/agent.py new file mode 100644 index 00000000..2cd302bb --- /dev/null +++ b/link/sdks/python/kalamdb/agent.py @@ -0,0 +1,150 @@ +"""High-level agent runner for Kafka-style topic consumption. + +Handles the polling loop, retries, backoff, and commits so your callback only +has to process each record. +""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Any, Awaitable, Callable, Optional + +from ._native import KalamClient, Consumer + +_log = logging.getLogger("kalamdb.agent") + + +async def run_agent( + *, + client: KalamClient, + topic: str, + group_id: str, + on_record: Callable[[dict[str, Any]], Awaitable[None]], + on_failed: Optional[Callable[[dict[str, Any], Exception], Awaitable[None]]] = None, + start: str = "latest", + max_attempts: int = 3, + initial_backoff_ms: int = 250, + max_backoff_ms: int = 2000, + multiplier: float = 2.0, + poll_idle_sleep_ms: int = 500, + ack_on_failed: bool = True, + stop_signal: Optional[asyncio.Event] = None, +) -> None: + """Run an agent loop that consumes a topic and processes each record. + + Args: + client: Connected KalamClient. + topic: Topic to consume from. + group_id: Consumer group id (tracks per-group offsets). + on_record: Async callback for each record. Raise to trigger retry. + on_failed: Optional callback invoked when a record exhausts retries. + start: Where to start reading: "earliest" or "latest" (default "latest"). + max_attempts: Max attempts per record before giving up (default 3). + initial_backoff_ms: Initial retry delay in ms (default 250). + max_backoff_ms: Maximum retry delay in ms (default 2000). + multiplier: Backoff multiplier per retry (default 2.0). + poll_idle_sleep_ms: Sleep when no records are available (default 500). + ack_on_failed: If True, commit offset even on permanent failure. + If False, the record stays in the topic for re-processing. + stop_signal: Optional asyncio.Event — loop exits when set. + + Example: + async def handle(record): + summary = await llm.summarize(record["payload"]) + await save_summary(record["message_id"], summary) + + await run_agent( + client=client, + topic="blog.posts", + group_id="summarizer-v1", + on_record=handle, + start="earliest", + ) + """ + + async with await client.consume(topic=topic, group_id=group_id, start=start) as consumer: + while True: + if stop_signal is not None and stop_signal.is_set(): + _log.info("run_agent stopping (signal)") + return + + try: + records = await consumer.poll() + except Exception as e: + _log.error("poll failed: %s", e) + await asyncio.sleep(poll_idle_sleep_ms / 1000.0) + continue + + if not records: + await asyncio.sleep(poll_idle_sleep_ms / 1000.0) + continue + + any_marked = False + stop_committing = False + for record in records: + if stop_signal is not None and stop_signal.is_set(): + break + success = await _process_one( + record, + on_record=on_record, + on_failed=on_failed, + max_attempts=max_attempts, + initial_backoff_ms=initial_backoff_ms, + max_backoff_ms=max_backoff_ms, + multiplier=multiplier, + ) + if success or ack_on_failed: + await consumer.mark_processed(record) + any_marked = True + else: + # Record failed and ack_on_failed is False — stop advancing + # the offset so this record gets redelivered next poll. + stop_committing = True + break + + if any_marked and not stop_committing: + try: + await consumer.commit() + except Exception as e: + _log.error("commit failed: %s", e) + + +async def _process_one( + record: dict[str, Any], + *, + on_record: Callable[[dict[str, Any]], Awaitable[None]], + on_failed: Optional[Callable[[dict[str, Any], Exception], Awaitable[None]]], + max_attempts: int, + initial_backoff_ms: int, + max_backoff_ms: int, + multiplier: float, +) -> bool: + """Process a single record with retry. Returns True if eventually ok.""" + backoff_ms = initial_backoff_ms + last_error: Optional[Exception] = None + + for attempt in range(1, max_attempts + 1): + try: + await on_record(record) + return True + except Exception as e: + last_error = e + _log.warning( + "record offset=%s attempt %d/%d failed: %s", + record.get("offset"), + attempt, + max_attempts, + e, + ) + if attempt < max_attempts: + await asyncio.sleep(backoff_ms / 1000.0) + backoff_ms = min(int(backoff_ms * multiplier), max_backoff_ms) + + if on_failed is not None and last_error is not None: + try: + await on_failed(record, last_error) + except Exception as e: + _log.error("on_failed callback raised: %s", e) + + return False diff --git a/link/sdks/python/pyproject.toml b/link/sdks/python/pyproject.toml new file mode 100644 index 00000000..1ed78992 --- /dev/null +++ b/link/sdks/python/pyproject.toml @@ -0,0 +1,29 @@ +[build-system] +requires = ["maturin>=1.0,<2.0"] +build-backend = "maturin" + +[project] +name = "kalamdb" +version = "0.1.0" +description = "Python SDK for KalamDB — SQL-first realtime database" +license = { text = "Apache-2.0" } +requires-python = ">=3.9" +keywords = ["kalamdb", "database", "sql", "realtime", "websocket"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python :: 3", + "Programming Language :: Rust", +] + +[tool.maturin] +features = ["pyo3/extension-module"] +module-name = "kalamdb._native" + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] + +[project.optional-dependencies] +dev = ["pytest>=8.0", "pytest-asyncio>=0.23"] diff --git a/link/sdks/python/src/lib.rs b/link/sdks/python/src/lib.rs new file mode 100644 index 00000000..19ed6cc3 --- /dev/null +++ b/link/sdks/python/src/lib.rs @@ -0,0 +1,1053 @@ +use pyo3::prelude::*; +use pyo3::create_exception; +use pyo3::exceptions::{PyException, PyRuntimeError}; +use std::sync::Arc; +use tokio::sync::Mutex; + +use kalam_client::{ + AuthProvider, + KalamLinkClient, + KalamLinkError, + SubscriptionManager, +}; +use kalam_client::{AutoOffsetReset, TopicConsumer}; + +create_exception!(kalamdb, KalamError, PyException, "Base class for all KalamDB SDK errors."); +create_exception!(kalamdb, KalamConnectionError, KalamError, "Network, WebSocket, or timeout error."); +create_exception!(kalamdb, KalamAuthError, KalamError, "Authentication failure (bad credentials, expired token)."); +create_exception!(kalamdb, KalamServerError, KalamError, "Server returned an error response."); +create_exception!(kalamdb, KalamConfigError, KalamError, "Invalid client configuration."); + +/// Returns true if the error is a TOKEN_EXPIRED server response that we +/// should react to by re-authenticating and retrying once. +fn is_token_expired_error(err: &KalamLinkError) -> bool { + // The server reports expired tokens via ServerError with a specific code + // embedded in the error message. We could parse the body JSON, but the + // error's Display already includes the status/message and TOKEN_EXPIRED + // as a substring is a reliable signal. + matches!(err, KalamLinkError::ServerError { .. }) + && err.to_string().contains("TOKEN_EXPIRED") +} + +/// Ensure the client behind `state` is authenticated. No-op if already so. +/// +/// Holds the mutex guard for the whole login, which serializes concurrent +/// first-login attempts — only one caller actually hits the auth endpoint, +/// the rest wait then see `authenticated = true` and return. +async fn ensure_authenticated_locked(state: &mut ClientState) -> PyResult<()> { + if state.authenticated { + return Ok(()); + } + match &state.original_auth { + AuthProvider::BasicAuth(username, password) => { + let login = state + .client + .login(username, password) + .await + .map_err(to_py_err)?; + let token = login.access_token.clone(); + state.client.set_auth(AuthProvider::jwt_token(token.clone())); + state.jwt = Some(token); + } + AuthProvider::JwtToken(_) | AuthProvider::None => { + // Already set on builder / no auth needed. + } + } + state.authenticated = true; + Ok(()) +} + +/// Run an `execute_query` with auto-refresh on TOKEN_EXPIRED. +/// +/// On the first attempt, ensures the client is authenticated. If the call +/// returns TOKEN_EXPIRED, marks the client as needing re-auth, re-logins, +/// and retries exactly once. All other errors propagate immediately. +async fn execute_with_auth_retry( + state: &Arc>, + sql: &str, + files: Option, Option)>>, + params: Option>, +) -> PyResult { + for attempt in 0..2u32 { + let mut guard = state.lock().await; + ensure_authenticated_locked(&mut guard).await?; + + // Borrow files as &str for the Rust API + let borrowed_files: Option, Option<&str>)>> = + files.as_ref().map(|items| { + items + .iter() + .map(|(p, f, d, m)| (p.as_str(), f.as_str(), d.clone(), m.as_deref())) + .collect() + }); + + let result = guard + .client + .execute_query(sql, borrowed_files, params.clone(), None) + .await; + + match result { + Ok(response) => return Ok(response), + Err(e) if attempt == 0 && is_token_expired_error(&e) => { + // Token expired mid-session. Clear our cached JWT, mark the + // client as needing re-auth, and retry on the next iteration. + guard.authenticated = false; + guard.jwt = None; + continue; + } + Err(e) => return Err(to_py_err(e)), + } + } + unreachable!("retry loop always returns"); +} + +/// Convert a KalamLinkError to the right Python exception class. +fn to_py_err(err: KalamLinkError) -> PyErr { + let message = err.to_string(); + match err { + KalamLinkError::NetworkError(_) + | KalamLinkError::WebSocketError(_) + | KalamLinkError::TimeoutError(_) => KalamConnectionError::new_err(message), + KalamLinkError::AuthenticationError(_) => KalamAuthError::new_err(message), + KalamLinkError::ServerError { .. } | KalamLinkError::SetupRequired(_) => { + KalamServerError::new_err(message) + } + KalamLinkError::ConfigurationError(_) => KalamConfigError::new_err(message), + KalamLinkError::SerializationError(_) | KalamLinkError::Cancelled => { + PyRuntimeError::new_err(message) + } + } +} + +/// Authentication helper — mirrors Auth.basic() / Auth.jwt() from the TS SDK. +#[pyclass] +struct Auth; + +#[pymethods] +impl Auth { + /// Create basic auth credentials. + /// + /// Example: + /// auth = Auth.basic("admin", "password") + #[staticmethod] + fn basic(username: &str, password: &str) -> PyObject { + Python::with_gil(|py| { + let dict = pyo3::types::PyDict::new(py); + dict.set_item("type", "basic").unwrap(); + dict.set_item("username", username).unwrap(); + dict.set_item("password", password).unwrap(); + dict.into_any().unbind() + }) + } + + /// Create JWT auth credentials. + /// + /// Example: + /// auth = Auth.jwt("eyJhbG...") + #[staticmethod] + fn jwt(token: &str) -> PyObject { + Python::with_gil(|py| { + let dict = pyo3::types::PyDict::new(py); + dict.set_item("type", "jwt").unwrap(); + dict.set_item("token", token).unwrap(); + dict.into_any().unbind() + }) + } +} + +/// Internal state — holds the underlying Rust client plus auth state for +/// lazy login and on-expiry refresh. +struct ClientState { + client: KalamLinkClient, + /// The auth credentials the user provided. Kept so we can re-run login() + /// when the JWT expires. + original_auth: AuthProvider, + /// Current JWT (None if not yet authenticated or anonymous). + jwt: Option, + /// True once we've successfully authenticated the underlying client. + authenticated: bool, +} + +/// KalamDB Python client. +/// +/// Construction is synchronous and does NO network I/O — the first query +/// (or an explicit `await client.connect()`) triggers login. If the JWT +/// expires during a long-running session, the SDK will silently re-login +/// using the original credentials on the next request. +/// +/// Example: +/// client = KalamClient("http://localhost:8080", Auth.basic("admin", "pass")) +/// result = await client.query("SELECT * FROM app.users") +/// await client.disconnect() +#[pyclass] +struct KalamClient { + state: Arc>, + url: String, +} + +#[pymethods] +impl KalamClient { + /// Create a new KalamDB client. + /// + /// This does NOT contact the server — the first query (or an explicit + /// `await client.connect()`) triggers the login handshake. + /// + /// Args: + /// url: The KalamDB server URL (e.g. "http://localhost:8080") + /// auth: Auth credentials from Auth.basic() or Auth.jwt() + /// options: Optional dict of client settings: + /// - timeout_seconds (float): per-request timeout (default 30) + /// - max_retries (int): max HTTP retry attempts (default 3) + #[new] + #[pyo3(signature = (url, auth, options=None))] + fn new( + url: &str, + auth: &Bound<'_, pyo3::types::PyDict>, + options: Option>, + ) -> PyResult { + let auth_type: String = auth + .get_item("type")? + .ok_or_else(|| KalamConfigError::new_err("auth dict missing 'type' key"))? + .extract()?; + + let provider = match auth_type.as_str() { + "basic" => { + let username: String = auth + .get_item("username")? + .ok_or_else(|| KalamConfigError::new_err("auth dict missing 'username'"))? + .extract()?; + let password: String = auth + .get_item("password")? + .ok_or_else(|| KalamConfigError::new_err("auth dict missing 'password'"))? + .extract()?; + AuthProvider::basic_auth(username, password) + } + "jwt" => { + let token: String = auth + .get_item("token")? + .ok_or_else(|| KalamConfigError::new_err("auth dict missing 'token'"))? + .extract()?; + AuthProvider::jwt_token(token) + } + other => { + return Err(KalamConfigError::new_err(format!( + "Unknown auth type: '{other}'. Use Auth.basic() or Auth.jwt()" + ))); + } + }; + + let url_owned = url.to_string(); + + // Optional settings + let mut timeout_seconds: Option = None; + let mut max_retries: Option = None; + if let Some(opts) = options { + if let Some(v) = opts.get_item("timeout_seconds")? { + timeout_seconds = Some(v.extract()?); + } + if let Some(v) = opts.get_item("max_retries")? { + max_retries = Some(v.extract()?); + } + } + + // Build the Rust client synchronously — this does NOT touch the network. + let mut builder = KalamLinkClient::builder() + .base_url(&url_owned) + .auth(provider.clone()); + if let Some(secs) = timeout_seconds { + builder = builder.timeout(std::time::Duration::from_secs_f64(secs)); + } + if let Some(retries) = max_retries { + builder = builder.max_retries(retries); + } + let client = builder.build().map_err(to_py_err)?; + + // For JWT auth, the token is already set on the builder — consider it + // authenticated. For Basic, defer login to the first request. For None, + // no auth is needed. + let (authenticated, jwt) = match &provider { + AuthProvider::JwtToken(token) => (true, Some(token.clone())), + AuthProvider::None => (true, None), + AuthProvider::BasicAuth(_, _) => (false, None), + }; + + Ok(Self { + state: Arc::new(Mutex::new(ClientState { + client, + original_auth: provider, + jwt, + authenticated, + })), + url: url_owned, + }) + } + + /// TEST-ONLY: simulate an expired/invalidated JWT. + /// + /// Clears the authenticated flag and cached JWT so the next operation has + /// to re-login. Used by the test suite to verify auth-refresh behaviour. + /// Not part of the stable public API — don't rely on this in application code. + fn _test_invalidate_auth<'py>(&self, py: Python<'py>) -> PyResult> { + let state = self.state.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let mut guard = state.lock().await; + guard.authenticated = false; + guard.jwt = None; + Ok(Python::with_gil(|py| py.None())) + }) + } + + /// Explicitly connect and authenticate now, rather than lazily on first query. + /// + /// Optional — the client auto-connects on first use. Call this if you want + /// to fail fast on bad credentials or an unreachable server at startup. + fn connect<'py>(&self, py: Python<'py>) -> PyResult> { + let state = self.state.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let mut guard = state.lock().await; + ensure_authenticated_locked(&mut guard).await?; + Ok(Python::with_gil(|py| py.None())) + }) + } + + /// Execute a SQL query and return the full response. + /// + /// Args: + /// sql: SQL query string, optionally with $1, $2, ... placeholders + /// params: Optional list of values to bind to the placeholders + /// + /// Example: + /// # Plain query + /// result = await client.query("SELECT * FROM app.users LIMIT 10") + /// + /// # Parameterized query (recommended for user input) + /// result = await client.query( + /// "SELECT * FROM app.users WHERE id = $1 AND active = $2", + /// [42, True] + /// ) + #[pyo3(signature = (sql, params=None))] + fn query<'py>( + &self, + py: Python<'py>, + sql: String, + params: Option>, + ) -> PyResult> { + let state = self.state.clone(); + let params_json = py_params_to_json(params)?; + + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let response = execute_with_auth_retry(&state, &sql, None, params_json).await?; + Python::with_gil(|py| serialize_to_py(py, &response)) + }) + } + + /// Execute SQL and return rows from the first result set. + /// + /// Convenience method that extracts just the rows as a list of dicts. + /// + /// Example: + /// rows = await client.query_rows( + /// "SELECT id, name FROM app.users WHERE active = $1", + /// [True] + /// ) + /// for row in rows: + /// print(row["name"]) + #[pyo3(signature = (sql, params=None))] + fn query_rows<'py>( + &self, + py: Python<'py>, + sql: String, + params: Option>, + ) -> PyResult> { + let state = self.state.clone(); + let params_json = py_params_to_json(params)?; + + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let response = execute_with_auth_retry(&state, &sql, None, params_json).await?; + + Python::with_gil(|py| { + let rows = pyo3::types::PyList::empty(py); + + if let Some(result) = response.results.first() { + if let Some(named_rows) = &result.named_rows { + // Server-provided named rows (preferred when available) + for row in named_rows { + let dict = pyo3::types::PyDict::new(py); + for (key, value) in row { + let py_val = serde_json_value_to_py(py, value)?; + dict.set_item(key, py_val)?; + } + rows.append(dict)?; + } + } else if let Some(positional_rows) = &result.rows { + // Build dicts from schema + positional rows + let column_names: Vec<&str> = result + .schema + .iter() + .map(|f| f.name.as_str()) + .collect(); + + for row in positional_rows { + let dict = pyo3::types::PyDict::new(py); + for (i, value) in row.iter().enumerate() { + if let Some(name) = column_names.get(i) { + let py_val = serde_json_value_to_py(py, value)?; + dict.set_item(*name, py_val)?; + } + } + rows.append(dict)?; + } + } + } + + Ok(rows.unbind()) + }) + }) + } + + /// Execute a SQL query with file uploads. + /// + /// Use FILE("placeholder") in your SQL to reference uploaded files. + /// + /// Args: + /// sql: SQL query containing FILE("name") placeholders + /// files: Dict mapping placeholder name to (filename, bytes) or (filename, bytes, mime_type) + /// params: Optional list of values for $1, $2, ... placeholders + /// + /// Example: + /// with open("avatar.png", "rb") as f: + /// data = f.read() + /// await client.query_with_files( + /// "INSERT INTO app.users (name, avatar) VALUES ($1, FILE('avatar'))", + /// {"avatar": ("avatar.png", data, "image/png")}, + /// ["alice"], + /// ) + #[pyo3(signature = (sql, files, params=None))] + fn query_with_files<'py>( + &self, + py: Python<'py>, + sql: String, + files: Bound<'py, pyo3::types::PyDict>, + params: Option>, + ) -> PyResult> { + let state = self.state.clone(); + let params_json = py_params_to_json(params)?; + + // Convert {placeholder: (filename, bytes[, mime])} to owned Vec + let mut owned_files: Vec<(String, String, Vec, Option)> = Vec::new(); + for (key, value) in files.iter() { + let placeholder: String = key.extract()?; + let tuple = value.downcast::().map_err(|_| { + KalamConfigError::new_err(format!( + "files['{placeholder}'] must be a tuple of (filename, bytes[, mime])" + )) + })?; + + if tuple.len() < 2 || tuple.len() > 3 { + return Err(KalamConfigError::new_err(format!( + "files['{placeholder}'] must be a 2- or 3-tuple" + ))); + } + + let filename: String = tuple.get_item(0)?.extract()?; + let data: Vec = tuple.get_item(1)?.extract()?; + let mime: Option = if tuple.len() == 3 { + Some(tuple.get_item(2)?.extract()?) + } else { + None + }; + + owned_files.push((placeholder, filename, data, mime)); + } + + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let response = + execute_with_auth_retry(&state, &sql, Some(owned_files), params_json).await?; + Python::with_gil(|py| serialize_to_py(py, &response)) + }) + } + + /// Insert a row into a table. + /// + /// Values are sent as parameterized query bindings ($1, $2, ...), so they + /// are properly escaped by the server regardless of their contents. + /// + /// Args: + /// table: Fully qualified table name (e.g. "app.users") + /// data: Dict of column values + /// + /// Example: + /// await client.insert("app.messages", { + /// "role": "user", + /// "content": "hello" + /// }) + fn insert<'py>( + &self, + py: Python<'py>, + table: String, + data: Bound<'py, pyo3::types::PyDict>, + ) -> PyResult> { + let state = self.state.clone(); + + // Build a parameterized INSERT: INSERT INTO t (c1, c2) VALUES ($1, $2) + let mut columns: Vec = Vec::new(); + let mut placeholders: Vec = Vec::new(); + let mut params: Vec = Vec::new(); + + for (key, val) in data.iter() { + let col: String = key.extract()?; + columns.push(col); + params.push(py_to_json_value(&val)?); + placeholders.push(format!("${}", params.len())); + } + + let sql = format!( + "INSERT INTO {} ({}) VALUES ({})", + table, + columns.join(", "), + placeholders.join(", ") + ); + + pyo3_async_runtimes::tokio::future_into_py(py, async move { + execute_with_auth_retry(&state, &sql, None, Some(params)).await?; + Ok(Python::with_gil(|py| py.None())) + }) + } + + /// Delete a row from a table by primary key value. + /// + /// Uses a parameterized query so the row id value is always safely escaped. + /// + /// Args: + /// table: Fully qualified table name (e.g. "app.users") + /// row_id: The primary key value of the row to delete + /// pk_column: Name of the primary key column (default: "id") + /// + /// Example: + /// await client.delete("app.messages", 12345) + /// await client.delete("app.sessions", "sess_abc", pk_column="session_id") + #[pyo3(signature = (table, row_id, pk_column="id"))] + fn delete<'py>( + &self, + py: Python<'py>, + table: String, + row_id: Bound<'py, PyAny>, + pk_column: &str, + ) -> PyResult> { + let state = self.state.clone(); + let id_value = py_to_json_value(&row_id)?; + let pk_column = pk_column.to_string(); + + let sql = format!("DELETE FROM {} WHERE {} = $1", table, pk_column); + let params = vec![id_value]; + + pyo3_async_runtimes::tokio::future_into_py(py, async move { + execute_with_auth_retry(&state, &sql, None, Some(params)).await?; + Ok(Python::with_gil(|py| py.None())) + }) + } + + /// Disconnect the client. + /// + /// Example: + /// await client.disconnect() + fn disconnect<'py>(&self, py: Python<'py>) -> PyResult> { + let state = self.state.clone(); + + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let guard = state.lock().await; + guard.client.disconnect().await; + Ok(Python::with_gil(|py| py.None())) + }) + } + + /// Async context manager entry — enables `async with KalamClient(...) as client:`. + fn __aenter__<'py>(slf: PyRef<'py, Self>, py: Python<'py>) -> PyResult> { + let slf_obj: PyObject = slf.into_pyobject(py)?.unbind().into(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + Ok(slf_obj) + }) + } + + /// Async context manager exit — automatically calls `disconnect()` on scope exit. + #[pyo3(signature = (_exc_type, _exc_value, _traceback))] + fn __aexit__<'py>( + &self, + py: Python<'py>, + _exc_type: Bound<'py, PyAny>, + _exc_value: Bound<'py, PyAny>, + _traceback: Bound<'py, PyAny>, + ) -> PyResult> { + let state = self.state.clone(); + + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let guard = state.lock().await; + guard.client.disconnect().await; + Ok(Python::with_gil(|py| py.None())) + }) + } + + /// Create a topic consumer (Kafka-style pub/sub). + /// + /// Args: + /// topic: Topic name to consume from + /// group_id: Consumer group identifier (tracks per-group offsets) + /// start: Where to start reading: "earliest" or "latest" (default "latest") + /// + /// Example: + /// consumer = await client.consume("blog.posts", "summarizer-v1", start="earliest") + /// records = await consumer.poll() + /// for record in records: + /// print(record["payload"]) + /// await consumer.commit() + #[pyo3(signature = (topic, group_id, start="latest"))] + fn consume<'py>( + &self, + py: Python<'py>, + topic: String, + group_id: String, + start: &str, + ) -> PyResult> { + let url = self.url.clone(); + let state = self.state.clone(); + + let offset_reset = match start { + "earliest" => AutoOffsetReset::Earliest, + "latest" => AutoOffsetReset::Latest, + other => { + return Err(KalamConfigError::new_err(format!( + "start must be 'earliest' or 'latest', got '{other}'" + ))); + } + }; + + pyo3_async_runtimes::tokio::future_into_py(py, async move { + // Make sure we're authenticated so a JWT is available. + let jwt = { + let mut guard = state.lock().await; + ensure_authenticated_locked(&mut guard).await?; + guard.jwt.clone().ok_or_else(|| { + KalamAuthError::new_err("Cannot consume without authentication") + })? + }; + + let consumer = TopicConsumer::builder() + .base_url(&url) + .jwt_token(jwt) + .topic(&topic) + .group_id(&group_id) + .auto_offset_reset(offset_reset) + .enable_auto_commit(false) + .build() + .map_err(to_py_err)?; + + Ok(Python::with_gil(|py| { + Py::new(py, Consumer { + inner: Arc::new(Mutex::new(Some(consumer))), + }).unwrap().into_any() + })) + }) + } + + fn __repr__(&self) -> String { + let status = match self.state.try_lock() { + Ok(guard) if guard.authenticated => "connected", + Ok(_) => "not connected", + Err(_) => "busy", + }; + format!("KalamClient(url='{}', {})", self.url, status) + } + + /// Subscribe to real-time changes from a SQL query. + /// + /// Returns a Subscription object that can be iterated with `async for`. + /// + /// Example: + /// sub = await client.subscribe("SELECT * FROM chat.messages") + /// async for event in sub: + /// print("change:", event) + /// await sub.close() + fn subscribe<'py>(&self, py: Python<'py>, sql: String) -> PyResult> { + let state = self.state.clone(); + + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let mut guard = state.lock().await; + ensure_authenticated_locked(&mut guard).await?; + let manager = guard.client.subscribe(&sql).await.map_err(to_py_err)?; + + Ok(Python::with_gil(|py| { + Py::new(py, Subscription { + inner: Arc::new(Mutex::new(Some(manager))), + }).unwrap().into_any() + })) + }) + } +} + +/// A topic consumer for Kafka-style pub/sub. +/// +/// Use `async for record in consumer` to receive records, or call `await consumer.poll()` +/// for batch polling. Call `await consumer.commit()` to acknowledge processed records. +#[pyclass] +struct Consumer { + inner: Arc>>, +} + +#[pymethods] +impl Consumer { + /// Poll for the next batch of records. + /// + /// Returns a list of record dicts. Empty list means no records available. + /// Records are NOT automatically marked as processed — you must call + /// `mark_processed(record)` for each one you've handled before calling + /// `commit()`. This prevents data loss if your handler fails mid-batch. + fn poll<'py>(&self, py: Python<'py>) -> PyResult> { + let inner = self.inner.clone(); + + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let mut guard = inner.lock().await; + let consumer = guard + .as_mut() + .ok_or_else(|| KalamError::new_err("Consumer is closed"))?; + + let records = consumer.poll().await.map_err(to_py_err)?; + + Python::with_gil(|py| serialize_to_py(py, &records)) + }) + } + + /// Mark a record as successfully processed. + /// + /// After calling this for each record you've handled, call `commit()` to + /// advance the server-side offset. Records that are polled but never + /// marked will be redelivered on the next poll (after commit). + fn mark_processed<'py>( + &self, + py: Python<'py>, + record: Bound<'py, pyo3::types::PyDict>, + ) -> PyResult> { + let inner = self.inner.clone(); + + let offset: u64 = record + .get_item("offset")? + .ok_or_else(|| KalamError::new_err("record dict missing 'offset' field"))? + .extract()?; + + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let mut guard = inner.lock().await; + let consumer = guard + .as_mut() + .ok_or_else(|| KalamError::new_err("Consumer is closed"))?; + + // Reconstruct a minimal ConsumerRecord just to reuse the existing + // offset-tracking API. Only `offset` is read inside `mark_processed`. + let record = kalam_client::ConsumerRecord { + topic_id: String::new(), + topic_name: String::new(), + partition_id: 0, + offset, + message_id: None, + source_table: String::new(), + op: kalam_client::TopicOp::Insert, + timestamp_ms: 0, + payload_mode: kalam_client::PayloadMode::Full, + payload: Vec::new(), + }; + consumer.mark_processed(&record); + Ok(Python::with_gil(|py| py.None())) + }) + } + + /// Commit the offsets of all records that have been marked as processed. + fn commit<'py>(&self, py: Python<'py>) -> PyResult> { + let inner = self.inner.clone(); + + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let mut guard = inner.lock().await; + let consumer = guard + .as_mut() + .ok_or_else(|| KalamError::new_err("Consumer is closed"))?; + + consumer.commit_sync().await.map_err(to_py_err)?; + Ok(Python::with_gil(|py| py.None())) + }) + } + + /// Close the consumer. + fn close<'py>(&self, py: Python<'py>) -> PyResult> { + let inner = self.inner.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let mut guard = inner.lock().await; + *guard = None; + Ok(Python::with_gil(|py| py.None())) + }) + } + + /// Async context manager entry. + fn __aenter__<'py>(slf: PyRef<'py, Self>, py: Python<'py>) -> PyResult> { + let slf_obj: PyObject = slf.into_pyobject(py)?.unbind().into(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + Ok(slf_obj) + }) + } + + /// Async context manager exit — auto-closes. + #[pyo3(signature = (_exc_type, _exc_value, _traceback))] + fn __aexit__<'py>( + &self, + py: Python<'py>, + _exc_type: Bound<'py, PyAny>, + _exc_value: Bound<'py, PyAny>, + _traceback: Bound<'py, PyAny>, + ) -> PyResult> { + let inner = self.inner.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let mut guard = inner.lock().await; + *guard = None; + Ok(Python::with_gil(|py| py.None())) + }) + } + + fn __repr__(&self) -> String { + // Non-blocking peek at the state; "unknown" if the mutex is busy + let state = match self.inner.try_lock() { + Ok(guard) if guard.is_some() => "open", + Ok(_) => "closed", + Err(_) => "busy", + }; + format!("Consumer({state})") + } +} + +/// A live subscription to a SQL query. +/// +/// Use `async for event in subscription` to receive change events, +/// or call `await subscription.next()` manually. +#[pyclass] +struct Subscription { + inner: Arc>>, +} + +#[pymethods] +impl Subscription { + /// Get the next change event. + /// + /// Raises StopAsyncIteration when the subscription stream ends (connection + /// closed or unsubscribed). This matches the `async for` iterator contract, + /// so you can use either `await sub.next()` or `async for event in sub` + /// and get consistent behaviour either way. + fn next<'py>(&self, py: Python<'py>) -> PyResult> { + let inner = self.inner.clone(); + + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let mut guard = inner.lock().await; + let manager = guard + .as_mut() + .ok_or_else(|| pyo3::exceptions::PyStopAsyncIteration::new_err( + "Subscription is closed", + ))?; + + match manager.next().await { + Some(Ok(event)) => { + let msg = event.to_server_message(); + Python::with_gil(|py| serialize_to_py(py, &msg)) + } + Some(Err(e)) => Err(to_py_err(e)), + None => Err(pyo3::exceptions::PyStopAsyncIteration::new_err( + "Subscription stream ended", + )), + } + }) + } + + /// Close the subscription. + fn close<'py>(&self, py: Python<'py>) -> PyResult> { + let inner = self.inner.clone(); + + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let mut guard = inner.lock().await; + if let Some(mut manager) = guard.take() { + let _ = manager.close().await; + } + Ok(Python::with_gil(|py| py.None())) + }) + } + + /// Async context manager entry — enables `async with await client.subscribe(...) as sub:`. + fn __aenter__<'py>(slf: PyRef<'py, Self>, py: Python<'py>) -> PyResult> { + let slf_obj: PyObject = slf.into_pyobject(py)?.unbind().into(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + Ok(slf_obj) + }) + } + + /// Async context manager exit — automatically closes the subscription. + #[pyo3(signature = (_exc_type, _exc_value, _traceback))] + fn __aexit__<'py>( + &self, + py: Python<'py>, + _exc_type: Bound<'py, PyAny>, + _exc_value: Bound<'py, PyAny>, + _traceback: Bound<'py, PyAny>, + ) -> PyResult> { + let inner = self.inner.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let mut guard = inner.lock().await; + if let Some(mut manager) = guard.take() { + let _ = manager.close().await; + } + Ok(Python::with_gil(|py| py.None())) + }) + } + + fn __repr__(&self) -> String { + let state = match self.inner.try_lock() { + Ok(guard) if guard.is_some() => "open", + Ok(_) => "closed", + Err(_) => "busy", + }; + format!("Subscription({state})") + } + + /// Async iterator support: `async for event in subscription`. + fn __aiter__(slf: PyRef<'_, Self>) -> PyRef<'_, Self> { + slf + } + + fn __anext__<'py>(&self, py: Python<'py>) -> PyResult> { + let inner = self.inner.clone(); + + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let mut guard = inner.lock().await; + let manager = guard + .as_mut() + .ok_or_else(|| pyo3::exceptions::PyStopAsyncIteration::new_err(""))?; + + match manager.next().await { + Some(Ok(event)) => { + let msg = event.to_server_message(); + Python::with_gil(|py| serialize_to_py(py, &msg)) + } + Some(Err(e)) => Err(to_py_err(e)), + None => Err(pyo3::exceptions::PyStopAsyncIteration::new_err("")), + } + }) + } +} + +/// Convert any serde-serializable value directly to a Python object. +/// +/// Goes via serde_json::Value as an intermediate, but avoids the extra +/// string-serialize + json.loads round-trip that shows up in profiles for +/// large query responses. +fn serialize_to_py(py: Python<'_>, value: &T) -> PyResult { + let json_value = serde_json::to_value(value) + .map_err(|e| PyRuntimeError::new_err(format!("JSON serialization error: {e}")))?; + serde_json_value_to_py(py, &json_value) +} + +/// Convert a Python list of values into Vec for parameterized queries. +fn py_params_to_json( + params: Option>, +) -> PyResult>> { + let Some(list) = params else { + return Ok(None); + }; + + let mut out = Vec::with_capacity(list.len()); + for item in list.iter() { + out.push(py_to_json_value(&item)?); + } + Ok(Some(out)) +} + +/// Convert a single Python value into a serde_json::Value. +fn py_to_json_value(value: &Bound<'_, PyAny>) -> PyResult { + if value.is_none() { + return Ok(serde_json::Value::Null); + } + if let Ok(b) = value.extract::() { + return Ok(serde_json::Value::Bool(b)); + } + if let Ok(i) = value.extract::() { + return Ok(serde_json::Value::Number(i.into())); + } + if let Ok(f) = value.extract::() { + return serde_json::Number::from_f64(f) + .map(serde_json::Value::Number) + .ok_or_else(|| PyRuntimeError::new_err(format!("Cannot serialize float: {f}"))); + } + if let Ok(s) = value.extract::() { + return Ok(serde_json::Value::String(s)); + } + if let Ok(list) = value.downcast::() { + let mut arr = Vec::with_capacity(list.len()); + for item in list.iter() { + arr.push(py_to_json_value(&item)?); + } + return Ok(serde_json::Value::Array(arr)); + } + if let Ok(dict) = value.downcast::() { + let mut map = serde_json::Map::new(); + for (k, v) in dict.iter() { + let key: String = k.extract()?; + map.insert(key, py_to_json_value(&v)?); + } + return Ok(serde_json::Value::Object(map)); + } + + Err(PyRuntimeError::new_err(format!( + "Unsupported parameter type: {}", + value.get_type().name()? + ))) +} + +/// Convert a serde_json::Value to a Python object. +fn serde_json_value_to_py(py: Python<'_>, value: &serde_json::Value) -> PyResult { + match value { + serde_json::Value::Null => Ok(py.None()), + serde_json::Value::Bool(b) => Ok((*b).into_pyobject(py)?.to_owned().unbind().into()), + serde_json::Value::Number(n) => { + if let Some(i) = n.as_i64() { + Ok(i.into_pyobject(py)?.unbind().into()) + } else if let Some(f) = n.as_f64() { + Ok(f.into_pyobject(py)?.unbind().into()) + } else { + Ok(py.None()) + } + } + serde_json::Value::String(s) => Ok(s.into_pyobject(py)?.unbind().into()), + serde_json::Value::Array(arr) => { + let list = pyo3::types::PyList::empty(py); + for item in arr { + list.append(serde_json_value_to_py(py, item)?)?; + } + Ok(list.unbind().into()) + } + serde_json::Value::Object(map) => { + let dict = pyo3::types::PyDict::new(py); + for (k, v) in map { + dict.set_item(k, serde_json_value_to_py(py, v)?)?; + } + Ok(dict.unbind().into()) + } + } +} + +/// KalamDB Python SDK — internal Rust module (exposed as `kalamdb._native`). +#[pymodule] +fn _native(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + + let py = m.py(); + m.add("KalamError", py.get_type::())?; + m.add("KalamConnectionError", py.get_type::())?; + m.add("KalamAuthError", py.get_type::())?; + m.add("KalamServerError", py.get_type::())?; + m.add("KalamConfigError", py.get_type::())?; + + Ok(()) +} diff --git a/link/sdks/python/tests/conftest.py b/link/sdks/python/tests/conftest.py new file mode 100644 index 00000000..2700d951 --- /dev/null +++ b/link/sdks/python/tests/conftest.py @@ -0,0 +1,79 @@ +"""Shared pytest fixtures for the kalamdb SDK tests. + +Integration tests talk to a real KalamDB server and will FAIL (not skip) if: +- The server is unreachable at KALAMDB_TEST_URL +- KALAMDB_TEST_PASSWORD is not set + +This is intentional — a silent skip would hide real problems in CI. + + KALAMDB_TEST_URL (default: http://localhost:8088) + KALAMDB_TEST_USER (default: admin) + KALAMDB_TEST_PASSWORD (no default — must be set) +""" + +import os +import urllib.error +import uuid +from urllib.request import urlopen + +import pytest +import pytest_asyncio + +from kalamdb import KalamClient, Auth + + +KALAMDB_URL = os.environ.get("KALAMDB_TEST_URL", "http://localhost:8088") +KALAMDB_USER = os.environ.get("KALAMDB_TEST_USER", "admin") +KALAMDB_PASSWORD = os.environ.get("KALAMDB_TEST_PASSWORD") + + +def _server_is_up(url: str) -> bool: + """Return True if *something* is listening at the given URL.""" + try: + urlopen(f"{url}/v1/api/healthcheck", timeout=2) + except urllib.error.HTTPError: + # Any HTTP response (including 403 from localhost-only endpoint) + # proves a server is running. + return True + except (urllib.error.URLError, TimeoutError, OSError): + return False + return True + + +def require_integration_env() -> str: + """Fail the current test with a clear message if prerequisites are missing.""" + if not _server_is_up(KALAMDB_URL): + pytest.fail( + f"KalamDB server not reachable at {KALAMDB_URL}. " + "Start it with `docker compose -f ../../../docker/run/single/docker-compose.yml up -d`." + ) + if not KALAMDB_PASSWORD: + pytest.fail( + "KALAMDB_TEST_PASSWORD env var is required for integration tests. " + "Run with `KALAMDB_TEST_PASSWORD=... pytest`." + ) + return KALAMDB_PASSWORD + + +@pytest_asyncio.fixture +async def client(): + """Yield a connected KalamClient; disconnect automatically when the test finishes.""" + password = require_integration_env() + c = KalamClient(KALAMDB_URL, Auth.basic(KALAMDB_USER, password)) + yield c + await c.disconnect() + + +@pytest_asyncio.fixture +async def temp_namespace(client): + """Provide a unique namespace per test and drop it on teardown. + + Prevents integration tests from leaving tables behind in the shared DB. + """ + namespace = f"test_{uuid.uuid4().hex[:12]}" + await client.query(f"CREATE NAMESPACE IF NOT EXISTS {namespace}") + yield namespace + try: + await client.query(f"DROP NAMESPACE IF EXISTS {namespace} CASCADE") + except Exception: + pass diff --git a/link/sdks/python/tests/test_agent.py b/link/sdks/python/tests/test_agent.py new file mode 100644 index 00000000..b53eef12 --- /dev/null +++ b/link/sdks/python/tests/test_agent.py @@ -0,0 +1,75 @@ +"""Tests for the run_agent helper — retry + backoff logic.""" + +import asyncio +import pytest + +from kalamdb.agent import _process_one + + +@pytest.mark.asyncio +async def test_process_one_succeeds_on_first_try(): + calls = 0 + + async def handler(record): + nonlocal calls + calls += 1 + + ok = await _process_one( + {"offset": 1}, + on_record=handler, + on_failed=None, + max_attempts=3, + initial_backoff_ms=10, + max_backoff_ms=50, + multiplier=2.0, + ) + assert ok is True + assert calls == 1 + + +@pytest.mark.asyncio +async def test_process_one_retries_on_failure(): + attempts = 0 + + async def flaky(record): + nonlocal attempts + attempts += 1 + if attempts < 3: + raise RuntimeError("transient") + + ok = await _process_one( + {"offset": 1}, + on_record=flaky, + on_failed=None, + max_attempts=5, + initial_backoff_ms=1, + max_backoff_ms=10, + multiplier=2.0, + ) + assert ok is True + assert attempts == 3 + + +@pytest.mark.asyncio +async def test_process_one_gives_up_and_calls_on_failed(): + failed_calls = [] + + async def always_fail(record): + raise RuntimeError("permanent") + + async def on_failed(record, error): + failed_calls.append((record, error)) + + ok = await _process_one( + {"offset": 42}, + on_record=always_fail, + on_failed=on_failed, + max_attempts=2, + initial_backoff_ms=1, + max_backoff_ms=10, + multiplier=2.0, + ) + assert ok is False + assert len(failed_calls) == 1 + assert failed_calls[0][0]["offset"] == 42 + assert isinstance(failed_calls[0][1], RuntimeError) diff --git a/link/sdks/python/tests/test_auth.py b/link/sdks/python/tests/test_auth.py new file mode 100644 index 00000000..b719703a --- /dev/null +++ b/link/sdks/python/tests/test_auth.py @@ -0,0 +1,21 @@ +"""Unit tests for the Auth helper (no server required).""" + +import pytest + +from kalamdb import Auth + + +def test_basic_auth_returns_dict(): + creds = Auth.basic("alice", "secret") + assert creds == {"type": "basic", "username": "alice", "password": "secret"} + + +def test_jwt_auth_returns_dict(): + creds = Auth.jwt("eyJhbGciOiJIUzI1NiJ9.payload.sig") + assert creds == {"type": "jwt", "token": "eyJhbGciOiJIUzI1NiJ9.payload.sig"} + + +def test_basic_auth_preserves_special_chars(): + creds = Auth.basic("user@example.com", "p@ss w0rd!") + assert creds["username"] == "user@example.com" + assert creds["password"] == "p@ss w0rd!" diff --git a/link/sdks/python/tests/test_auth_flows.py b/link/sdks/python/tests/test_auth_flows.py new file mode 100644 index 00000000..9da92ea7 --- /dev/null +++ b/link/sdks/python/tests/test_auth_flows.py @@ -0,0 +1,81 @@ +"""Integration tests for authentication flows — JWT, explicit connect, bad creds.""" + +import pytest + +from kalamdb import KalamClient, Auth, KalamAuthError, KalamError + + +@pytest.mark.asyncio +async def test_jwt_auth_works_after_manual_login(): + """User logs in via HTTP (outside the SDK) and uses the resulting JWT directly.""" + import json, urllib.request + from conftest import KALAMDB_URL, KALAMDB_USER, require_integration_env + + password = require_integration_env() + + # Step 1: obtain a JWT by hitting the login endpoint directly. + req = urllib.request.Request( + f"{KALAMDB_URL}/v1/api/auth/login", + data=json.dumps({"username": KALAMDB_USER, "password": password}).encode(), + headers={"Content-Type": "application/json"}, + ) + with urllib.request.urlopen(req, timeout=5) as resp: + token = json.loads(resp.read())["access_token"] + + # Step 2: pass the JWT to the SDK — it should authenticate without re-logging-in. + async with KalamClient(KALAMDB_URL, Auth.jwt(token)) as c: + result = await c.query("SELECT 1 AS one") + assert int(result["results"][0]["rows"][0][0]) == 1 + + +@pytest.mark.asyncio +async def test_wrong_password_raises_auth_error(): + from conftest import KALAMDB_URL, KALAMDB_USER + + client = KalamClient( + KALAMDB_URL, Auth.basic(KALAMDB_USER, "definitely-wrong-password-xyz") + ) + # Login is lazy, so the error should surface on the first query. + with pytest.raises(KalamError): + await client.query("SELECT 1") + await client.disconnect() + + +@pytest.mark.asyncio +async def test_connect_explicit_triggers_login(): + """connect() should perform the login eagerly rather than waiting for first query.""" + from conftest import KALAMDB_URL, KALAMDB_USER, require_integration_env + + password = require_integration_env() + + async with KalamClient(KALAMDB_URL, Auth.basic(KALAMDB_USER, password)) as c: + await c.connect() + # Now a subsequent query should succeed without hitting login again. + result = await c.query("SELECT 2 AS two") + assert int(result["results"][0]["rows"][0][0]) == 2 + + +@pytest.mark.asyncio +async def test_connect_with_bad_password_fails(): + """connect() with invalid credentials should raise immediately, not silently succeed.""" + from conftest import KALAMDB_URL, KALAMDB_USER + + client = KalamClient(KALAMDB_USER, Auth.basic(KALAMDB_USER, "xxxx")) + with pytest.raises(KalamError): + await client.connect() + + +@pytest.mark.asyncio +async def test_constructor_does_no_network_io(): + """__init__ should not make any network calls — it only stores config.""" + # Point at a clearly unreachable URL. If __init__ tried to connect, it + # would hang or throw. We just want to prove construction returns. + client = KalamClient( + "http://127.0.0.1:1", # port 1 should refuse quickly + Auth.basic("admin", "x"), + ) + # If we got here, construction was non-blocking. + assert client is not None + # The error should only surface when we actually try to query. + with pytest.raises(KalamError): + await client.query("SELECT 1") diff --git a/link/sdks/python/tests/test_auth_refresh.py b/link/sdks/python/tests/test_auth_refresh.py new file mode 100644 index 00000000..ffb41d18 --- /dev/null +++ b/link/sdks/python/tests/test_auth_refresh.py @@ -0,0 +1,42 @@ +"""Tests for transparent re-authentication when the JWT is no longer valid.""" + +import pytest + + +@pytest.mark.asyncio +async def test_reauth_transparently_after_invalidated_jwt(client): + """Simulate the JWT going stale mid-session. + + Expected: the SDK notices it's not authenticated, runs login() again using + the stored BasicAuth credentials, and the query that triggered the re-auth + still succeeds without the caller seeing an error. + """ + # First query — establishes the initial session. + first = await client.query("SELECT 1 AS one") + assert int(first["results"][0]["rows"][0][0]) == 1 + + # Pretend the JWT has expired / been revoked. + await client._test_invalidate_auth() + + # Next query should transparently re-login and succeed. + second = await client.query("SELECT 2 AS two") + assert int(second["results"][0]["rows"][0][0]) == 2 + + +@pytest.mark.asyncio +async def test_multiple_queries_after_invalidation_only_relogin_once(client): + """After an invalidation, concurrent queries should race to re-login but + only one should actually hit the auth endpoint — the rest piggyback.""" + import asyncio + + await client.query("SELECT 1") # initial auth + await client._test_invalidate_auth() + + # Fire three queries concurrently — all should succeed. + results = await asyncio.gather( + client.query("SELECT 10 AS v"), + client.query("SELECT 20 AS v"), + client.query("SELECT 30 AS v"), + ) + values = sorted(int(r["results"][0]["rows"][0][0]) for r in results) + assert values == [10, 20, 30] diff --git a/link/sdks/python/tests/test_consumer.py b/link/sdks/python/tests/test_consumer.py new file mode 100644 index 00000000..3263558e --- /dev/null +++ b/link/sdks/python/tests/test_consumer.py @@ -0,0 +1,51 @@ +"""Integration tests for topic consumers.""" + +import pytest + +from kalamdb import Consumer, KalamConfigError, KalamServerError + + +@pytest.mark.asyncio +async def test_consume_returns_consumer_instance(client): + consumer = await client.consume( + topic="does_not_matter", + group_id="test_group", + start="latest", + ) + assert isinstance(consumer, Consumer) + await consumer.close() + + +@pytest.mark.asyncio +async def test_consume_invalid_start_raises_config_error(client): + with pytest.raises(KalamConfigError): + await client.consume("topic", "group", start="middle_of_the_road") + + +@pytest.mark.asyncio +async def test_consume_defaults_to_latest(client): + # Should not raise with no explicit start — default is "latest". + c = await client.consume(topic="any", group_id="any") + assert isinstance(c, Consumer) + await c.close() + + +@pytest.mark.asyncio +async def test_poll_nonexistent_topic_raises(client): + async with await client.consume( + topic="truly_nonexistent_topic_qwerty", + group_id="test_group", + ) as consumer: + with pytest.raises(KalamServerError): + await consumer.poll() + + +@pytest.mark.asyncio +async def test_consumer_context_manager_closes_on_exit(client): + async with await client.consume(topic="any", group_id="g") as c: + assert isinstance(c, Consumer) + # After the with block, the consumer should be closed — further poll fails. + # Note: after __aexit__, the wrapper is fully gone, so attempting more + # operations should raise a KalamError. + with pytest.raises(Exception): + await c.poll() diff --git a/link/sdks/python/tests/test_crud.py b/link/sdks/python/tests/test_crud.py new file mode 100644 index 00000000..09c5681b --- /dev/null +++ b/link/sdks/python/tests/test_crud.py @@ -0,0 +1,133 @@ +"""Integration tests for insert() and delete() — round-trips, data types, pk_column.""" + +import pytest + + +@pytest.mark.asyncio +async def test_insert_round_trips_string(client, temp_namespace): + await client.query(f""" + CREATE TABLE {temp_namespace}.t ( + id BIGINT PRIMARY KEY DEFAULT SNOWFLAKE_ID(), + name TEXT + ) + """) + await client.insert(f"{temp_namespace}.t", {"name": "alice"}) + rows = await client.query_rows(f"SELECT name FROM {temp_namespace}.t") + assert len(rows) == 1 + assert rows[0]["name"] == "alice" + + +@pytest.mark.asyncio +async def test_insert_round_trips_integer(client, temp_namespace): + await client.query(f""" + CREATE TABLE {temp_namespace}.t ( + id BIGINT PRIMARY KEY DEFAULT SNOWFLAKE_ID(), + age INT + ) + """) + await client.insert(f"{temp_namespace}.t", {"age": 30}) + rows = await client.query_rows(f"SELECT age FROM {temp_namespace}.t") + assert int(rows[0]["age"]) == 30 + + +@pytest.mark.asyncio +async def test_insert_round_trips_boolean(client, temp_namespace): + await client.query(f""" + CREATE TABLE {temp_namespace}.t ( + id BIGINT PRIMARY KEY DEFAULT SNOWFLAKE_ID(), + active BOOLEAN + ) + """) + await client.insert(f"{temp_namespace}.t", {"active": True}) + rows = await client.query_rows(f"SELECT active FROM {temp_namespace}.t") + # The server may return this as a bool or a string — accept either. + value = rows[0]["active"] + assert value is True or value in ("true", "True", 1, "1") + + +@pytest.mark.asyncio +async def test_insert_round_trips_null(client, temp_namespace): + await client.query(f""" + CREATE TABLE {temp_namespace}.t ( + id BIGINT PRIMARY KEY DEFAULT SNOWFLAKE_ID(), + note TEXT + ) + """) + await client.insert(f"{temp_namespace}.t", {"note": None}) + rows = await client.query_rows(f"SELECT note FROM {temp_namespace}.t") + assert rows[0]["note"] is None + + +@pytest.mark.asyncio +async def test_insert_escapes_sql_metacharacters_in_values(client, temp_namespace): + # Parameterized insert should safely handle strings containing quotes, + # semicolons, backslashes — things that would break string-concat SQL. + await client.query(f""" + CREATE TABLE {temp_namespace}.t ( + id BIGINT PRIMARY KEY DEFAULT SNOWFLAKE_ID(), + payload TEXT + ) + """) + nasty = "O'Brien; DROP TABLE x; -- \\ 'quoted'" + await client.insert(f"{temp_namespace}.t", {"payload": nasty}) + rows = await client.query_rows(f"SELECT payload FROM {temp_namespace}.t") + assert rows[0]["payload"] == nasty + + +@pytest.mark.asyncio +async def test_insert_multiple_rows_accumulates(client, temp_namespace): + await client.query(f""" + CREATE TABLE {temp_namespace}.t ( + id BIGINT PRIMARY KEY DEFAULT SNOWFLAKE_ID(), + name TEXT + ) + """) + for name in ["a", "b", "c"]: + await client.insert(f"{temp_namespace}.t", {"name": name}) + + rows = await client.query_rows(f"SELECT name FROM {temp_namespace}.t ORDER BY name") + assert [r["name"] for r in rows] == ["a", "b", "c"] + + +@pytest.mark.asyncio +async def test_delete_removes_row_by_default_id(client, temp_namespace): + await client.query(f""" + CREATE TABLE {temp_namespace}.t ( + id BIGINT PRIMARY KEY, + name TEXT + ) + """) + await client.insert(f"{temp_namespace}.t", {"id": 1, "name": "x"}) + await client.insert(f"{temp_namespace}.t", {"id": 2, "name": "y"}) + + await client.delete(f"{temp_namespace}.t", 1) + + rows = await client.query_rows(f"SELECT id FROM {temp_namespace}.t") + ids = sorted(int(r["id"]) for r in rows) + assert ids == [2] + + +@pytest.mark.asyncio +async def test_delete_supports_custom_pk_column(client, temp_namespace): + await client.query(f""" + CREATE TABLE {temp_namespace}.t ( + user_id TEXT PRIMARY KEY, + email TEXT + ) + """) + await client.insert(f"{temp_namespace}.t", {"user_id": "u1", "email": "a@x"}) + await client.insert(f"{temp_namespace}.t", {"user_id": "u2", "email": "b@x"}) + + await client.delete(f"{temp_namespace}.t", "u1", pk_column="user_id") + + rows = await client.query_rows(f"SELECT user_id FROM {temp_namespace}.t") + assert [r["user_id"] for r in rows] == ["u2"] + + +@pytest.mark.asyncio +async def test_delete_nonexistent_row_does_not_raise(client, temp_namespace): + await client.query(f""" + CREATE TABLE {temp_namespace}.t (id BIGINT PRIMARY KEY, v TEXT) + """) + # Deleting a row that doesn't exist is a no-op (0 rows affected), not an error. + await client.delete(f"{temp_namespace}.t", 999) diff --git a/link/sdks/python/tests/test_exceptions.py b/link/sdks/python/tests/test_exceptions.py new file mode 100644 index 00000000..7c6dda32 --- /dev/null +++ b/link/sdks/python/tests/test_exceptions.py @@ -0,0 +1,31 @@ +"""Tests for the SDK exception hierarchy.""" + +import pytest + +from kalamdb import ( + KalamError, + KalamConnectionError, + KalamAuthError, + KalamServerError, + KalamConfigError, +) + + +def test_exception_hierarchy(): + """All custom exceptions should inherit from KalamError.""" + assert issubclass(KalamConnectionError, KalamError) + assert issubclass(KalamAuthError, KalamError) + assert issubclass(KalamServerError, KalamError) + assert issubclass(KalamConfigError, KalamError) + + +def test_kalam_error_is_exception(): + assert issubclass(KalamError, Exception) + + +def test_can_raise_and_catch(): + with pytest.raises(KalamError): + raise KalamConnectionError("oops") + + with pytest.raises(KalamConnectionError): + raise KalamConnectionError("network down") diff --git a/link/sdks/python/tests/test_files.py b/link/sdks/python/tests/test_files.py new file mode 100644 index 00000000..f93c35b7 --- /dev/null +++ b/link/sdks/python/tests/test_files.py @@ -0,0 +1,83 @@ +"""Integration tests for file uploads — query_with_files with FILE columns.""" + +import pytest + +from kalamdb import KalamConfigError + + +@pytest.mark.asyncio +async def test_file_upload_round_trip_3_tuple_with_mime(client, temp_namespace): + await client.query(f""" + CREATE TABLE {temp_namespace}.uploads ( + id BIGINT PRIMARY KEY DEFAULT SNOWFLAKE_ID(), + name TEXT, + attachment FILE + ) + """) + + data = b"PNG\x89\x0D\x0A\x1A\x0Asome fake image bytes" + result = await client.query_with_files( + f"INSERT INTO {temp_namespace}.uploads (name, attachment) VALUES ($1, FILE('att'))", + {"att": ("demo.png", data, "image/png")}, + ["demo"], + ) + assert result["status"] == "success" + + # Confirm a row landed with the right name. + rows = await client.query_rows( + f"SELECT name FROM {temp_namespace}.uploads" + ) + assert rows[0]["name"] == "demo" + + +@pytest.mark.asyncio +async def test_file_upload_2_tuple_without_mime(client, temp_namespace): + await client.query(f""" + CREATE TABLE {temp_namespace}.uploads ( + id BIGINT PRIMARY KEY DEFAULT SNOWFLAKE_ID(), + name TEXT, + attachment FILE + ) + """) + + # Omit mime — the 2-tuple form should work. + result = await client.query_with_files( + f"INSERT INTO {temp_namespace}.uploads (name, attachment) VALUES ($1, FILE('att'))", + {"att": ("plain.txt", b"just some text")}, + ["no-mime"], + ) + assert result["status"] == "success" + + +@pytest.mark.asyncio +async def test_file_upload_invalid_tuple_raises_config_error(client, temp_namespace): + await client.query(f""" + CREATE TABLE {temp_namespace}.uploads ( + id BIGINT PRIMARY KEY DEFAULT SNOWFLAKE_ID(), + attachment FILE + ) + """) + + # Single-element "tuple" — too few items. + with pytest.raises(KalamConfigError): + await client.query_with_files( + f"INSERT INTO {temp_namespace}.uploads (attachment) VALUES (FILE('att'))", + {"att": ("only-filename",)}, # missing bytes + ) + + +@pytest.mark.asyncio +async def test_file_upload_non_tuple_value_raises_config_error(client, temp_namespace): + await client.query(f""" + CREATE TABLE {temp_namespace}.uploads ( + id BIGINT PRIMARY KEY DEFAULT SNOWFLAKE_ID(), + attachment FILE + ) + """) + + # Pass a plain string instead of a tuple. + with pytest.raises(KalamConfigError): + await client.query_with_files( + f"INSERT INTO {temp_namespace}.uploads (attachment) VALUES (FILE('att'))", + {"att": "not a tuple"}, + ) diff --git a/link/sdks/python/tests/test_options.py b/link/sdks/python/tests/test_options.py new file mode 100644 index 00000000..7cea3a3e --- /dev/null +++ b/link/sdks/python/tests/test_options.py @@ -0,0 +1,39 @@ +"""Integration tests for ClientOptions — timeouts, retries.""" + +import pytest + +from kalamdb import KalamClient, Auth, KalamError + + +@pytest.mark.asyncio +async def test_client_accepts_options_dict(): + """Construction with options should not reject valid keys.""" + from conftest import KALAMDB_URL, KALAMDB_USER, require_integration_env + + password = require_integration_env() + + async with KalamClient( + KALAMDB_URL, + Auth.basic(KALAMDB_USER, password), + options={"timeout_seconds": 10.0, "max_retries": 2}, + ) as c: + result = await c.query("SELECT 1 AS one") + assert int(result["results"][0]["rows"][0][0]) == 1 + + +@pytest.mark.asyncio +async def test_short_timeout_against_unreachable_host_fails_fast(): + """A tiny timeout should fail quickly against a black hole, not hang.""" + import time + # TEST-NET-2 — reserved, unroutable; connection attempts hang until timeout. + client = KalamClient( + "http://198.51.100.1:12345", + Auth.basic("admin", "x"), + options={"timeout_seconds": 2.0}, + ) + start = time.monotonic() + with pytest.raises(KalamError): + await client.query("SELECT 1") + elapsed = time.monotonic() - start + # The 2s timeout plus a bit of overhead — definitely under 10s. + assert elapsed < 10, f"expected fast failure, took {elapsed:.1f}s" diff --git a/link/sdks/python/tests/test_pubsub.py b/link/sdks/python/tests/test_pubsub.py new file mode 100644 index 00000000..139f60eb --- /dev/null +++ b/link/sdks/python/tests/test_pubsub.py @@ -0,0 +1,124 @@ +"""Full pub/sub loop tests — producer table feeds a topic, consumer reads it.""" + +import asyncio +import pytest + + +async def _setup_topic_with_source(client, namespace, table_name="src", topic_name="stream"): + """Create a source table and a topic that mirrors its INSERT events.""" + await client.query(f""" + CREATE TABLE {namespace}.{table_name} ( + id BIGINT PRIMARY KEY DEFAULT SNOWFLAKE_ID(), + msg TEXT + ) + """) + await client.query(f"CREATE TOPIC {namespace}.{topic_name}") + await client.query( + f"ALTER TOPIC {namespace}.{topic_name} ADD SOURCE {namespace}.{table_name} ON INSERT" + ) + return f"{namespace}.{table_name}", f"{namespace}.{topic_name}" + + +@pytest.mark.asyncio +async def test_consumer_receives_record_from_topic(client, temp_namespace): + src, topic = await _setup_topic_with_source(client, temp_namespace) + + # Start consuming from earliest so we pick up the row we're about to insert. + async with await client.consume( + topic=topic, + group_id="test-group", + start="earliest", + ) as consumer: + # Insert a row into the source table — should flow to the topic. + await client.insert(src, {"msg": "hello subscribers"}) + + # Poll until we see a record or give up after 5 seconds. + seen = None + deadline = asyncio.get_event_loop().time() + 5.0 + while asyncio.get_event_loop().time() < deadline: + records = await consumer.poll() + if records: + seen = records[0] + break + await asyncio.sleep(0.2) + + assert seen is not None, "consumer never received the produced record" + assert "offset" in seen + # The message_id payload carries the row as JSON — verify our msg made it through. + assert "hello subscribers" in seen.get("message_id", "") + + +@pytest.mark.asyncio +async def test_consumer_commit_advances_offset(client, temp_namespace): + src, topic = await _setup_topic_with_source(client, temp_namespace) + + async with await client.consume( + topic=topic, group_id="offset-group", start="earliest", + ) as consumer: + # Produce two rows. + await client.insert(src, {"msg": "first"}) + await client.insert(src, {"msg": "second"}) + + # Drain both rows and commit. + got = [] + deadline = asyncio.get_event_loop().time() + 5.0 + while len(got) < 2 and asyncio.get_event_loop().time() < deadline: + records = await consumer.poll() + for r in records: + got.append(r) + await consumer.mark_processed(r) + if records: + await consumer.commit() + else: + await asyncio.sleep(0.2) + assert len(got) >= 2 + + # Open a new consumer for the same group — since we committed, it should + # NOT receive the already-processed records. + async with await client.consume( + topic=topic, group_id="offset-group", start="earliest", + ) as consumer: + records = [] + deadline = asyncio.get_event_loop().time() + 2.0 + while asyncio.get_event_loop().time() < deadline: + batch = await consumer.poll() + records.extend(batch) + if not batch: + await asyncio.sleep(0.2) + assert len(records) == 0, ( + f"expected 0 records after commit (offset should be advanced), " + f"got {len(records)}" + ) + + +@pytest.mark.asyncio +async def test_run_agent_processes_records_and_commits(client, temp_namespace): + from kalamdb import run_agent + + src, topic = await _setup_topic_with_source(client, temp_namespace) + # Pre-produce a handful of rows. + for i in range(3): + await client.insert(src, {"msg": f"msg-{i}"}) + + processed = [] + stop = asyncio.Event() + + async def handler(record): + processed.append(record) + if len(processed) >= 3: + stop.set() + + async def run_with_timeout(): + async with asyncio.timeout(10): + await run_agent( + client=client, + topic=topic, + group_id="agent-test", + on_record=handler, + start="earliest", + poll_idle_sleep_ms=200, + stop_signal=stop, + ) + + await run_with_timeout() + assert len(processed) >= 3 diff --git a/link/sdks/python/tests/test_query.py b/link/sdks/python/tests/test_query.py new file mode 100644 index 00000000..ab0c6975 --- /dev/null +++ b/link/sdks/python/tests/test_query.py @@ -0,0 +1,97 @@ +"""Integration tests for KalamClient.query / query_rows — values, types, errors.""" + +import pytest + +from kalamdb import KalamError, KalamServerError + + +@pytest.mark.asyncio +async def test_arithmetic_returns_correct_value(client): + result = await client.query("SELECT 1 + 1 AS answer") + row = result["results"][0]["rows"][0] + assert int(row[0]) == 2 + + +@pytest.mark.asyncio +async def test_query_returns_declared_schema(client): + result = await client.query("SELECT 42 AS a, 'hi' AS b, TRUE AS c") + schema = result["results"][0]["schema"] + names = [f["name"] for f in schema] + assert names == ["a", "b", "c"] + + +@pytest.mark.asyncio +async def test_parameterized_int_bound_correctly(client): + result = await client.query("SELECT $1 AS n", [42]) + row = result["results"][0]["rows"][0] + assert int(row[0]) == 42 + + +@pytest.mark.asyncio +async def test_parameterized_text_bound_correctly(client): + result = await client.query("SELECT $1 AS s", ["hello world"]) + row = result["results"][0]["rows"][0] + assert row[0] == "hello world" + + +@pytest.mark.asyncio +async def test_parameterized_bool_bound_correctly(client): + result = await client.query("SELECT $1 AS b", [True]) + row = result["results"][0]["rows"][0] + assert row[0] in (True, "true", 1, "1") + + +@pytest.mark.asyncio +async def test_parameterized_float_bound_correctly(client): + result = await client.query("SELECT $1 AS f", [3.14]) + row = result["results"][0]["rows"][0] + assert float(row[0]) == pytest.approx(3.14) + + +@pytest.mark.asyncio +async def test_parameterized_null_bound_correctly(client): + result = await client.query("SELECT $1 AS x", [None]) + row = result["results"][0]["rows"][0] + assert row[0] is None + + +@pytest.mark.asyncio +async def test_query_rows_maps_columns_to_dict(client): + rows = await client.query_rows("SELECT 1 AS a, 'x' AS b") + assert len(rows) == 1 + assert rows[0]["a"] == 1 or int(rows[0]["a"]) == 1 + assert rows[0]["b"] == "x" + + +@pytest.mark.asyncio +async def test_query_rows_empty_result(client, temp_namespace): + await client.query(f""" + CREATE TABLE {temp_namespace}.empty_t (id BIGINT PRIMARY KEY, v TEXT) + """) + rows = await client.query_rows(f"SELECT * FROM {temp_namespace}.empty_t") + assert rows == [] + + +@pytest.mark.asyncio +async def test_invalid_sql_raises_server_error(client): + with pytest.raises(KalamServerError): + await client.query("NOT VALID SQL AT ALL") + + +@pytest.mark.asyncio +async def test_server_error_is_a_kalam_error(client): + # Every specific error type should inherit from KalamError so users can + # catch broadly if they want. + with pytest.raises(KalamError): + await client.query("NOT VALID SQL") + + +@pytest.mark.asyncio +async def test_server_error_message_contains_detail(client): + try: + await client.query("SELECT * FROM nonexistent_ns.nonexistent_table") + except KalamServerError as e: + # Error surface should include the server's explanation, not just "oops". + assert str(e) # Non-empty message + else: + pytest.fail("Query against a missing table should have raised") diff --git a/link/sdks/python/tests/test_repr.py b/link/sdks/python/tests/test_repr.py new file mode 100644 index 00000000..e8f365a3 --- /dev/null +++ b/link/sdks/python/tests/test_repr.py @@ -0,0 +1,33 @@ +"""Smoke tests for the __repr__ implementations.""" + +import pytest + + +@pytest.mark.asyncio +async def test_client_repr_shows_url(client): + r = repr(client) + assert "KalamClient" in r + assert "http" in r # Some form of URL appears + + +@pytest.mark.asyncio +async def test_subscription_repr_is_informative(client, temp_namespace): + await client.query(f""" + CREATE TABLE {temp_namespace}.t (id BIGINT PRIMARY KEY, v TEXT) + """) + sub = await client.subscribe(f"SELECT * FROM {temp_namespace}.t") + try: + r = repr(sub) + assert "Subscription" in r + finally: + await sub.close() + + +@pytest.mark.asyncio +async def test_consumer_repr_is_informative(client): + consumer = await client.consume(topic="any", group_id="g") + try: + r = repr(consumer) + assert "Consumer" in r + finally: + await consumer.close() diff --git a/link/sdks/python/tests/test_subscriptions.py b/link/sdks/python/tests/test_subscriptions.py new file mode 100644 index 00000000..51240b7b --- /dev/null +++ b/link/sdks/python/tests/test_subscriptions.py @@ -0,0 +1,149 @@ +"""Integration tests for live subscriptions — events, iteration, cleanup.""" + +import asyncio +import pytest + + +@pytest.mark.asyncio +async def test_subscription_ack_includes_schema(client, temp_namespace): + await client.query(f""" + CREATE TABLE {temp_namespace}.t ( + id BIGINT PRIMARY KEY DEFAULT SNOWFLAKE_ID(), + payload TEXT + ) + """) + async with await client.subscribe(f"SELECT * FROM {temp_namespace}.t") as sub: + async with asyncio.timeout(3): + event = await sub.next() + assert event["type"] == "subscription_ack" + # Schema should include the columns we SELECT *'d + names = [f["name"] for f in event.get("schema", [])] + assert "id" in names + assert "payload" in names + + +@pytest.mark.asyncio +async def test_subscription_initial_data_batch_is_empty_on_fresh_table(client, temp_namespace): + await client.query(f""" + CREATE TABLE {temp_namespace}.t ( + id BIGINT PRIMARY KEY DEFAULT SNOWFLAKE_ID(), + payload TEXT + ) + """) + async with await client.subscribe(f"SELECT * FROM {temp_namespace}.t") as sub: + await sub.next() # ack + async with asyncio.timeout(3): + batch = await sub.next() + assert batch["type"] == "initial_data_batch" + assert batch.get("rows") == [] + + +@pytest.mark.asyncio +async def test_subscription_initial_data_includes_existing_rows(client, temp_namespace): + await client.query(f""" + CREATE TABLE {temp_namespace}.t ( + id BIGINT PRIMARY KEY DEFAULT SNOWFLAKE_ID(), + payload TEXT + ) + """) + await client.insert(f"{temp_namespace}.t", {"payload": "preexisting"}) + + async with await client.subscribe(f"SELECT * FROM {temp_namespace}.t") as sub: + await sub.next() # ack + async with asyncio.timeout(3): + batch = await sub.next() + assert batch["type"] == "initial_data_batch" + assert len(batch.get("rows", [])) == 1 + assert batch["rows"][0]["payload"] == "preexisting" + + +@pytest.mark.asyncio +async def test_subscribe_delivers_live_insert(client, temp_namespace): + await client.query(f""" + CREATE TABLE {temp_namespace}.t ( + id BIGINT PRIMARY KEY DEFAULT SNOWFLAKE_ID(), + payload TEXT + ) + """) + sub = await client.subscribe(f"SELECT * FROM {temp_namespace}.t") + + async def insert_later(): + await asyncio.sleep(0.3) + await client.insert(f"{temp_namespace}.t", {"payload": "live"}) + + task = asyncio.create_task(insert_later()) + try: + saw_insert_payload = None + async with asyncio.timeout(5): + async for event in sub: + if event.get("type") == "change" and event.get("change_type") == "insert": + saw_insert_payload = event["rows"][0]["payload"] + break + assert saw_insert_payload == "live" + finally: + await task + await sub.close() + + +@pytest.mark.asyncio +async def test_subscribe_delivers_delete_event(client, temp_namespace): + await client.query(f""" + CREATE TABLE {temp_namespace}.t ( + id BIGINT PRIMARY KEY, + payload TEXT + ) + """) + await client.insert(f"{temp_namespace}.t", {"id": 42, "payload": "doomed"}) + + sub = await client.subscribe(f"SELECT * FROM {temp_namespace}.t") + + async def delete_later(): + await asyncio.sleep(0.3) + await client.delete(f"{temp_namespace}.t", 42) + + task = asyncio.create_task(delete_later()) + try: + saw_delete = False + async with asyncio.timeout(5): + async for event in sub: + if event.get("type") == "change" and event.get("change_type") == "delete": + saw_delete = True + break + assert saw_delete, "expected a delete change event" + finally: + await task + await sub.close() + + +@pytest.mark.asyncio +async def test_subscribe_raises_stop_async_iteration_on_close(client, temp_namespace): + await client.query(f""" + CREATE TABLE {temp_namespace}.t (id BIGINT PRIMARY KEY, v TEXT) + """) + sub = await client.subscribe(f"SELECT * FROM {temp_namespace}.t") + await sub.close() + with pytest.raises(StopAsyncIteration): + await sub.next() + + +@pytest.mark.asyncio +async def test_multiple_subscriptions_on_same_client(client, temp_namespace): + await client.query(f""" + CREATE TABLE {temp_namespace}.t1 (id BIGINT PRIMARY KEY, v TEXT) + """) + await client.query(f""" + CREATE TABLE {temp_namespace}.t2 (id BIGINT PRIMARY KEY, v TEXT) + """) + + sub1 = await client.subscribe(f"SELECT * FROM {temp_namespace}.t1") + sub2 = await client.subscribe(f"SELECT * FROM {temp_namespace}.t2") + try: + async with asyncio.timeout(3): + ack1 = await sub1.next() + ack2 = await sub2.next() + assert ack1["type"] == "subscription_ack" + assert ack2["type"] == "subscription_ack" + assert ack1["subscription_id"] != ack2["subscription_id"] + finally: + await sub1.close() + await sub2.close() From 4441c9a9daa428bf21b635917492985f21e8f8ed Mon Sep 17 00:00:00 2001 From: inas sarhan Date: Wed, 15 Apr 2026 22:30:41 +0300 Subject: [PATCH 2/2] CI: create venv before maturin develop --- .github/workflows/python-sdk.yml | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/.github/workflows/python-sdk.yml b/.github/workflows/python-sdk.yml index f3b6da52..3104a2f0 100644 --- a/.github/workflows/python-sdk.yml +++ b/.github/workflows/python-sdk.yml @@ -53,16 +53,28 @@ jobs: with: workspaces: link/sdks/python -> target - - name: Install maturin and pytest - run: pip install maturin pytest pytest-asyncio - - - name: Build SDK in develop mode + - name: Create venv and install maturin + pytest + working-directory: link/sdks/python + shell: bash + run: | + python -m venv .venv + if [ -f .venv/bin/activate ]; then source .venv/bin/activate; else source .venv/Scripts/activate; fi + python -m pip install --upgrade pip + pip install maturin pytest pytest-asyncio + + - name: Build SDK in develop mode (inside venv) working-directory: link/sdks/python - run: maturin develop --release + shell: bash + run: | + if [ -f .venv/bin/activate ]; then source .venv/bin/activate; else source .venv/Scripts/activate; fi + maturin develop --release - name: Run unit tests (no server required) working-directory: link/sdks/python - run: pytest tests/test_auth.py tests/test_exceptions.py tests/test_agent.py -v + shell: bash + run: | + if [ -f .venv/bin/activate ]; then source .venv/bin/activate; else source .venv/Scripts/activate; fi + pytest tests/test_auth.py tests/test_exceptions.py tests/test_agent.py -v build-wheels: name: Build wheels (${{ matrix.os }})