diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 59722b1..efad52c 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -86,6 +86,31 @@ jobs: name: wheels-macos-arm64 path: dist + windows-x86_64: + runs-on: windows-latest + strategy: + matrix: + target: [x86_64] + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 + with: + python-version: '3.10' + - name: Build wheels + uses: PyO3/maturin-action@v1 + env: + CXX: clang++ + CC: clang + with: + target: ${{ matrix.target }} + args: --release --out dist --find-interpreter + sccache: 'true' + - name: Upload wheels + uses: actions/upload-artifact@v4 + with: + name: wheels-windows-x86_64 + path: dist + sdist: runs-on: ubuntu-latest steps: @@ -105,7 +130,7 @@ jobs: name: Release runs-on: ubuntu-latest if: "startsWith(github.ref, 'refs/tags/')" - needs: [linux, macos-arm64, macos-x86_64, sdist] + needs: [linux, macos-arm64, macos-x86_64, windows-x86_64, sdist] steps: - uses: actions/download-artifact@v4 with: diff --git a/Cargo.lock b/Cargo.lock index 07ebcec..39850e1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -95,7 +95,7 @@ checksum = "16e62a023e7c117e27523144c5d2459f4397fcc3cab0085af8e2224f643a0193" dependencies = [ "proc-macro2", "quote", - "syn 2.0.72", + "syn", ] [[package]] @@ -106,7 +106,7 @@ checksum = "6e0c28dcc82d7c8ead5cb13beb15405b57b8546e93215673ff8ca0349a028107" dependencies = [ "proc-macro2", "quote", - "syn 2.0.72", + "syn", ] [[package]] @@ -215,7 +215,7 @@ dependencies = [ "regex", "rustc-hash", "shlex", - "syn 2.0.72", + "syn", "which", ] @@ -326,6 +326,15 @@ dependencies = [ "libloading", ] +[[package]] +name = "cmake" +version = "0.1.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7caa3f9de89ddbe2c607f4101924c5abec803763ae9534e4f4d7d8f84aa81f0" +dependencies = [ + "cc", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -389,7 +398,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.52.0", ] [[package]] @@ -472,7 +481,7 @@ checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" dependencies = [ "proc-macro2", "quote", - "syn 2.0.72", + "syn", ] [[package]] @@ -582,6 +591,12 @@ dependencies = [ "hashbrown 0.14.5", ] +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hermit-abi" version = "0.3.9" @@ -594,7 +609,7 @@ version = "0.5.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3d1354bf6b7235cb4a0576c2619fd4ed18183f689b12b006a0ee7329eeff9a5" dependencies = [ - "windows-sys", + "windows-sys 0.52.0", ] [[package]] @@ -654,7 +669,7 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", - "socket2", + "socket2 0.5.7", "tokio", "tower-service", "tracing", @@ -737,9 +752,9 @@ dependencies = [ [[package]] name = "indoc" -version = "1.0.9" +version = "2.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa799dd5ed20a7e349f3b4639aa80d74549c81716d9ec4f994c9b5815598306" +checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd" [[package]] name = "inout" @@ -751,6 +766,17 @@ dependencies = [ "generic-array", ] +[[package]] +name = "io-uring" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d93587f37623a1a17d94ef2bc9ada592f5465fe7732084ab7beefabe5c77c0c4" +dependencies = [ + "bitflags 2.6.0", + "cfg-if", + "libc", +] + [[package]] name = "itertools" version = "0.12.1" @@ -790,9 +816,9 @@ checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" [[package]] name = "libc" -version = "0.2.155" +version = "0.2.174" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c" +checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" [[package]] name = "libloading" @@ -806,9 +832,9 @@ dependencies = [ [[package]] name = "libsql" -version = "0.9.10" +version = "0.9.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c9f529b1afe0465e1f864fa34cdcad08fb463a1bf86b40267493db83508ee9d" +checksum = "c799cab358fae692da41c51e9982e228150c8990e5b624e869f9e11d1ef6b877" dependencies = [ "anyhow", "async-stream", @@ -846,20 +872,21 @@ dependencies = [ [[package]] name = "libsql-ffi" -version = "0.9.10" +version = "0.9.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "739717a55160a200ef8a9122a17d559148ddd3f0c526b52b3908f9a80aca5284" +checksum = "61a606c215cf0763f1ffd46fabda099b9f1783d5c5dc9e4a7ab31f309f5b65d0" dependencies = [ "bindgen", "cc", + "cmake", "glob", ] [[package]] name = "libsql-hrana" -version = "0.9.10" +version = "0.9.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44633324952c32e3ab7da5e5cf36af9d7afe53e74f811e622ef23207b357ff18" +checksum = "e3c82fac8ed8e9dea93d7747b2f57fb3f50e0d371dcd431b5fb93d89c9c07e13" dependencies = [ "base64 0.21.7", "bytes", @@ -869,9 +896,9 @@ dependencies = [ [[package]] name = "libsql-rusqlite" -version = "0.9.10" +version = "0.9.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d042d19c09bb858a4ca93c6ae346a8267215301e265097c2858a1c91f6384bd" +checksum = "b0ceb32f341c13ecbf24d3c150c76882d15b85a0d4b707584117ed6ac4844594" dependencies = [ "bitflags 2.6.0", "fallible-iterator 0.2.0", @@ -901,9 +928,9 @@ dependencies = [ [[package]] name = "libsql-sys" -version = "0.9.10" +version = "0.9.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3760d2141b2ac78a24c303c53ee0720301bbbe5158db3fc016c26f3eeeb44712" +checksum = "1ce785faebf645e54c49eb4f2bd09e9c3a87321daf6e257a110ef88e0174e7a4" dependencies = [ "bytes", "libsql-ffi", @@ -915,9 +942,9 @@ dependencies = [ [[package]] name = "libsql_replication" -version = "0.9.10" +version = "0.9.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "039dccb52999803f36bc850be4e2ebc6987d1fe622994df1678b87c849c9dca1" +checksum = "784a2ab03543056c9bd63b79e9bda5a2cf7b87e400a2bc90cc1281aea1a5acef" dependencies = [ "aes", "async-stream", @@ -1012,7 +1039,7 @@ dependencies = [ "hermit-abi", "libc", "wasi", - "windows-sys", + "windows-sys 0.52.0", ] [[package]] @@ -1162,7 +1189,7 @@ checksum = "2f38a4412a78282e09a2cf38d195ea5420d15ba0602cb375210efbc877243965" dependencies = [ "proc-macro2", "quote", - "syn 2.0.72", + "syn", ] [[package]] @@ -1177,6 +1204,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "portable-atomic" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" + [[package]] name = "ppv-lite86" version = "0.2.17" @@ -1190,7 +1223,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f12335488a2f3b0a83b14edad48dca9879ce89b2edd10e80237e4e852dd645e" dependencies = [ "proc-macro2", - "syn 2.0.72", + "syn", ] [[package]] @@ -1222,12 +1255,12 @@ dependencies = [ "itertools", "proc-macro2", "quote", - "syn 2.0.72", + "syn", ] [[package]] name = "pylibsql" -version = "0.1.2" +version = "0.1.11" dependencies = [ "libsql", "pyo3", @@ -1239,15 +1272,15 @@ dependencies = [ [[package]] name = "pyo3" -version = "0.19.2" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e681a6cfdc4adcc93b4d3cf993749a4552018ee0a9b65fc0ccfad74352c72a38" +checksum = "8970a78afe0628a3e3430376fc5fd76b6b45c4d43360ffd6cdd40bdde72b682a" dependencies = [ - "cfg-if", "indoc", "libc", "memoffset", - "parking_lot", + "once_cell", + "portable-atomic", "pyo3-build-config", "pyo3-ffi", "pyo3-macros", @@ -1256,9 +1289,9 @@ dependencies = [ [[package]] name = "pyo3-build-config" -version = "0.19.2" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "076c73d0bc438f7a4ef6fdd0c3bb4732149136abd952b110ac93e4edb13a6ba5" +checksum = "458eb0c55e7ece017adeba38f2248ff3ac615e53660d7c71a238d7d2a01c7598" dependencies = [ "once_cell", "target-lexicon", @@ -1266,9 +1299,9 @@ dependencies = [ [[package]] name = "pyo3-ffi" -version = "0.19.2" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e53cee42e77ebe256066ba8aa77eff722b3bb91f3419177cf4cd0f304d3284d9" +checksum = "7114fe5457c61b276ab77c5055f206295b812608083644a5c5b2640c3102565c" dependencies = [ "libc", "pyo3-build-config", @@ -1276,25 +1309,27 @@ dependencies = [ [[package]] name = "pyo3-macros" -version = "0.19.2" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfeb4c99597e136528c6dd7d5e3de5434d1ceaf487436a3f03b2d56b6fc9efd1" +checksum = "a8725c0a622b374d6cb051d11a0983786448f7785336139c3c94f5aa6bef7e50" dependencies = [ "proc-macro2", "pyo3-macros-backend", "quote", - "syn 1.0.109", + "syn", ] [[package]] name = "pyo3-macros-backend" -version = "0.19.2" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "947dc12175c254889edc0c02e399476c2f652b4b9ebd123aa655c224de259536" +checksum = "4109984c22491085343c05b0dbc54ddc405c3cf7b4374fc533f5c3313a572ccc" dependencies = [ + "heck", "proc-macro2", + "pyo3-build-config", "quote", - "syn 1.0.109", + "syn", ] [[package]] @@ -1386,7 +1421,7 @@ dependencies = [ "libc", "spin", "untrusted", - "windows-sys", + "windows-sys 0.52.0", ] [[package]] @@ -1411,7 +1446,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.52.0", ] [[package]] @@ -1486,7 +1521,7 @@ version = "0.1.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fbc91545643bcf3a0bbb6569265615222618bdf33ce4ffbbd13c4bbd4c093534" dependencies = [ - "windows-sys", + "windows-sys 0.52.0", ] [[package]] @@ -1535,7 +1570,7 @@ checksum = "e0cd7e117be63d3c3678776753929474f3b04a43a080c744d6b0ae2a8c28e222" dependencies = [ "proc-macro2", "quote", - "syn 2.0.72", + "syn", ] [[package]] @@ -1601,7 +1636,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce305eb0b4296696835b71df73eb912e0f1ffd2556a501fcede6e0c50349191c" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807" +dependencies = [ + "libc", + "windows-sys 0.59.0", ] [[package]] @@ -1616,17 +1661,6 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" -[[package]] -name = "syn" -version = "1.0.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - [[package]] name = "syn" version = "2.0.72" @@ -1646,9 +1680,9 @@ checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" [[package]] name = "target-lexicon" -version = "0.12.15" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4873307b7c257eddcb50c9bedf158eb669578359fb28428bef438fec8e6ba7c2" +checksum = "e502f78cdbb8ba4718f566c418c52bc729126ffd16baee5baa718cf25dd5a69a" [[package]] name = "thiserror" @@ -1667,7 +1701,7 @@ checksum = "a4558b58466b9ad7ca0f102865eccc95938dca1a74a856f2b57b6629050da261" dependencies = [ "proc-macro2", "quote", - "syn 2.0.72", + "syn", ] [[package]] @@ -1682,20 +1716,22 @@ dependencies = [ [[package]] name = "tokio" -version = "1.39.1" +version = "1.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d040ac2b29ab03b09d4129c2f5bbd012a3ac2f79d38ff506a4bf8dd34b0eac8a" +checksum = "43864ed400b6043a4757a25c7a64a8efde741aed79a056a2fb348a406701bb35" dependencies = [ "backtrace", "bytes", + "io-uring", "libc", "mio", "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2", + "slab", + "socket2 0.6.0", "tokio-macros", - "windows-sys", + "windows-sys 0.59.0", ] [[package]] @@ -1710,13 +1746,13 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.4.0" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752" +checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.72", + "syn", ] [[package]] @@ -1873,7 +1909,7 @@ checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.72", + "syn", ] [[package]] @@ -1899,9 +1935,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.18" +version = "0.3.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad0f048c97dbd9faa9b7df56362b8ebcaa52adb06b498c050d2f4e32f90a7a8b" +checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" dependencies = [ "nu-ansi-term", "sharded-slab", @@ -1940,9 +1976,9 @@ checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" [[package]] name = "unindent" -version = "0.1.11" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1766d682d402817b5ac4490b3c3002d91dfa0d22812f341609f97b08757359c" +checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" [[package]] name = "untrusted" @@ -2009,7 +2045,7 @@ dependencies = [ "log", "proc-macro2", "quote", - "syn 2.0.72", + "syn", "wasm-bindgen-shared", ] @@ -2031,7 +2067,7 @@ checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" dependencies = [ "proc-macro2", "quote", - "syn 2.0.72", + "syn", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -2112,6 +2148,15 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -2194,7 +2239,7 @@ checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.72", + "syn", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 45c2ac9..2f932b8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,18 +1,18 @@ [package] name = "pylibsql" -version = "0.1.2" -edition = "2021" +version = "0.1.11" +edition = "2024" [lib] crate-type = ["cdylib"] [dependencies] -pyo3 = "0.19.0" -libsql = { version = "0.9.10", features = ["encryption"] } -tokio = { version = "1.29.1", features = [ "rt-multi-thread" ] } -tracing-subscriber = "0.3" +pyo3 = "0.25.1" +libsql = { version = "0.9.22", features = ["encryption"] } +tokio = { version = "1.47.0", features = [ "rt-multi-thread" ] } +tracing-subscriber = "0.3.19" [build-dependencies] version_check = "0.9.5" # used where logic has to be version/distribution specific, e.g. pypy -pyo3-build-config = { version = "0.19.0" } +pyo3-build-config = { version = "0.25.1" } diff --git a/docs/api.md b/docs/api.md index f63d4f0..08d0b15 100644 --- a/docs/api.md +++ b/docs/api.md @@ -32,6 +32,16 @@ Rolls back the current transaction and starts a new one. Closes the database connection. +### `with` statement + +Connection objects can be used as context managers to ensure that transactions are properly committed or rolled back. When entering the context, the connection object is returned. When exiting: +- Without exception: automatically commits the transaction +- With exception: automatically rolls back the transaction + +This behavior is compatible with Python's `sqlite3` module. Context managers work correctly in both transactional and autocommit modes. + +When mixing manual transaction control with context managers, the context manager's commit/rollback will apply to any active transaction at the time of exit. Manual calls to `commit()` or `rollback()` within the context are allowed and will start a new transaction as usual. + ### execute(sql, parameters=()) Create a new cursor object and executes the SQL statement. diff --git a/pyproject.toml b/pyproject.toml index 57acd3d..01d534d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "libsql" -version = "0.1.2" +version = "0.1.11" requires-python = ">=3.7" classifiers = [ "Programming Language :: Rust", diff --git a/src/lib.rs b/src/lib.rs index ed7d444..5f792e4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,14 +2,25 @@ use ::libsql as libsql_core; use pyo3::create_exception; use pyo3::exceptions::PyValueError; use pyo3::prelude::*; -use pyo3::types::{PyList, PyTuple}; -use std::cell::{OnceCell, RefCell}; +use pyo3::types::{PyList, PyModule, PyTuple}; +use std::cell::RefCell; use std::sync::{Arc, OnceLock}; use std::time::Duration; use tokio::runtime::{Handle, Runtime}; const LEGACY_TRANSACTION_CONTROL: i32 = -1; +#[derive(Clone)] +enum ListOrTuple<'py> { + List(Bound<'py, PyList>), + Tuple(Bound<'py, PyTuple>), +} + +struct ListOrTupleIterator<'py> { + index: usize, + inner: ListOrTuple<'py>, +} + fn rt() -> Handle { static RT: OnceLock = OnceLock::new(); @@ -38,16 +49,17 @@ fn is_remote_path(path: &str) -> bool { #[pyfunction] #[cfg(not(Py_3_12))] -#[pyo3(signature = (database, timeout=5.0, isolation_level="DEFERRED".to_string(), check_same_thread=true, uri=false, sync_url=None, sync_interval=None, auth_token="", encryption_key=None))] +#[pyo3(signature = (database, timeout=5.0, isolation_level="DEFERRED".to_string(), _check_same_thread=true, _uri=false, sync_url=None, sync_interval=None, offline=false, auth_token="", encryption_key=None))] fn connect( py: Python<'_>, database: String, timeout: f64, isolation_level: Option, - check_same_thread: bool, - uri: bool, + _check_same_thread: bool, + _uri: bool, sync_url: Option, sync_interval: Option, + offline: bool, auth_token: &str, encryption_key: Option, ) -> PyResult { @@ -56,10 +68,11 @@ fn connect( database, timeout, isolation_level, - check_same_thread, - uri, + _check_same_thread, + _uri, sync_url, sync_interval, + offline, auth_token, encryption_key, )?; @@ -68,16 +81,17 @@ fn connect( #[pyfunction] #[cfg(Py_3_12)] -#[pyo3(signature = (database, timeout=5.0, isolation_level="DEFERRED".to_string(), check_same_thread=true, uri=false, sync_url=None, sync_interval=None, auth_token="", encryption_key=None, autocommit = LEGACY_TRANSACTION_CONTROL))] +#[pyo3(signature = (database, timeout=5.0, isolation_level="DEFERRED".to_string(), _check_same_thread=true, _uri=false, sync_url=None, sync_interval=None, offline=false, auth_token="", encryption_key=None, autocommit = LEGACY_TRANSACTION_CONTROL))] fn connect( py: Python<'_>, database: String, timeout: f64, isolation_level: Option, - check_same_thread: bool, - uri: bool, + _check_same_thread: bool, + _uri: bool, sync_url: Option, sync_interval: Option, + offline: bool, auth_token: &str, encryption_key: Option, autocommit: i32, @@ -87,10 +101,11 @@ fn connect( database, timeout, isolation_level.clone(), - check_same_thread, - uri, + _check_same_thread, + _uri, sync_url, sync_interval, + offline, auth_token, encryption_key, )?; @@ -111,10 +126,11 @@ fn _connect_core( database: String, timeout: f64, isolation_level: Option, - check_same_thread: bool, - uri: bool, + _check_same_thread: bool, + _uri: bool, sync_url: Option, sync_interval: Option, + offline: bool, auth_token: &str, encryption_key: Option, ) -> PyResult { @@ -136,17 +152,20 @@ fn _connect_core( match sync_url { Some(sync_url) => { let sync_interval = sync_interval.map(|i| std::time::Duration::from_secs_f64(i)); - let mut builder = libsql_core::Builder::new_remote_replica( + let mut builder = libsql_core::Builder::new_synced_database( database, sync_url, auth_token.to_string(), ); - if let Some(encryption_config) = encryption_config { - builder = builder.encryption_config(encryption_config); + if let Some(_) = encryption_config { + return Err(PyValueError::new_err( + "encryption is not supported for synced databases", + )); } if let Some(sync_interval) = sync_interval { builder = builder.sync_interval(sync_interval); } + builder = builder.remote_writes(!offline); let fut = builder.build(); tokio::pin!(fut); let result = rt.block_on(check_signals(py, fut)); @@ -217,10 +236,11 @@ pub struct Connection { // SAFETY: The libsql crate guarantees that `Connection` is thread-safe. unsafe impl Send for Connection {} +unsafe impl Sync for Connection {} #[pymethods] impl Connection { - fn close(self_: PyRef<'_, Self>, py: Python<'_>) -> PyResult<()> { + fn close(self_: PyRef<'_, Self>, _py: Python<'_>) -> PyResult<()> { self_.conn.replace(None); Ok(()) } @@ -283,24 +303,26 @@ impl Connection { Ok(()) } + #[pyo3(signature = (sql, parameters=None))] fn execute( self_: PyRef<'_, Self>, sql: String, - parameters: Option<&PyTuple>, + parameters: Option>, ) -> PyResult { let cursor = Connection::cursor(&self_)?; rt().block_on(async { execute(&cursor, sql, parameters).await })?; Ok(cursor) } + #[pyo3(signature = (sql, parameters=None))] fn executemany( self_: PyRef<'_, Self>, sql: String, - parameters: Option<&PyList>, + parameters: Option<&Bound<'_, PyList>>, ) -> PyResult { let cursor = Connection::cursor(&self_)?; for parameters in parameters.unwrap().iter() { - let parameters = parameters.extract::<&PyTuple>()?; + let parameters = parameters.extract::()?; rt().block_on(async { execute(&cursor, sql.clone(), Some(parameters)).await })?; } Ok(cursor) @@ -330,11 +352,12 @@ impl Connection { fn in_transaction(self_: PyRef<'_, Self>) -> PyResult { #[cfg(Py_3_12)] { - return Ok( - !self_.conn.borrow().as_ref().unwrap().is_autocommit() || self_.autocommit == 0 - ); + Ok(!self_.conn.borrow().as_ref().unwrap().is_autocommit() || self_.autocommit == 0) + } + #[cfg(not(Py_3_12))] + { + Ok(!self_.conn.borrow().as_ref().unwrap().is_autocommit()) } - Ok(!self_.conn.borrow().as_ref().unwrap().is_autocommit()) } #[getter] @@ -354,6 +377,27 @@ impl Connection { self_.autocommit = autocommit; Ok(()) } + + fn __enter__(slf: PyRef<'_, Self>) -> PyResult> { + Ok(slf) + } + + #[pyo3(signature = (exc_type=None, _exc_val=None, _exc_tb=None))] + fn __exit__( + self_: PyRef<'_, Self>, + exc_type: Option<&Bound<'_, PyAny>>, + _exc_val: Option<&Bound<'_, PyAny>>, + _exc_tb: Option<&Bound<'_, PyAny>>, + ) -> PyResult { + if exc_type.is_none() { + // Commit on clean exit + Connection::commit(self_)?; + } else { + // Rollback on error + Connection::rollback(self_)?; + } + Ok(false) // Always propagate exceptions + } } #[pyclass] @@ -371,6 +415,7 @@ pub struct Cursor { // SAFETY: The libsql crate guarantees that `Connection` is thread-safe. unsafe impl Send for Cursor {} +unsafe impl Sync for Cursor {} impl Drop for Cursor { fn drop(&mut self) { @@ -393,22 +438,24 @@ impl Cursor { Ok(()) } + #[pyo3(signature = (sql, parameters=None))] fn execute<'a>( self_: PyRef<'a, Self>, sql: String, - parameters: Option<&PyTuple>, + parameters: Option>, ) -> PyResult> { rt().block_on(async { execute(&self_, sql, parameters).await })?; Ok(self_) } + #[pyo3(signature = (sql, parameters=None))] fn executemany<'a>( self_: PyRef<'a, Self>, sql: String, - parameters: Option<&PyList>, + parameters: Option<&Bound<'_, PyList>>, ) -> PyResult> { for parameters in parameters.unwrap().iter() { - let parameters = parameters.extract::<&PyTuple>()?; + let parameters = parameters.extract::()?; rt().block_on(async { execute(&self_, sql.clone(), Some(parameters)).await })?; } Ok(self_) @@ -432,7 +479,7 @@ impl Cursor { } #[getter] - fn description(self_: PyRef<'_, Self>) -> PyResult> { + fn description(self_: PyRef<'_, Self>) -> PyResult>> { let stmt = self_.stmt.borrow(); let mut elements: Vec> = vec![]; match stmt.as_ref() { @@ -448,17 +495,18 @@ impl Cursor { self_.py().None(), self_.py().None(), ) - .to_object(self_.py()); - elements.push(element); + .into_pyobject(self_.py()) + .unwrap(); + elements.push(element.into()); } - let elements = PyTuple::new(self_.py(), elements); + let elements = PyTuple::new(self_.py(), elements)?; Ok(Some(elements)) } None => Ok(None), } } - fn fetchone(self_: PyRef<'_, Self>) -> PyResult> { + fn fetchone(self_: PyRef<'_, Self>) -> PyResult>> { let mut rows = self_.rows.borrow_mut(); match rows.as_mut() { Some(rows) => { @@ -475,7 +523,8 @@ impl Cursor { } } - fn fetchmany(self_: PyRef<'_, Self>, size: Option) -> PyResult> { + #[pyo3(signature = (size=None))] + fn fetchmany(self_: PyRef<'_, Self>, size: Option) -> PyResult>> { let mut rows = self_.rows.borrow_mut(); match rows.as_mut() { Some(rows) => { @@ -501,13 +550,13 @@ impl Cursor { } } } - Ok(Some(PyList::new(self_.py(), elements))) + Ok(Some(PyList::new(self_.py(), elements)?)) } None => Ok(None), } } - fn fetchall(self_: PyRef<'_, Self>) -> PyResult> { + fn fetchall(self_: PyRef<'_, Self>) -> PyResult>> { let mut rows = self_.rows.borrow_mut(); match rows.as_mut() { Some(rows) => { @@ -524,7 +573,7 @@ impl Cursor { None => break, } } - Ok(Some(PyList::new(self_.py(), elements))) + Ok(Some(PyList::new(self_.py(), elements)?)) } None => Ok(None), } @@ -552,7 +601,11 @@ async fn begin_transaction(conn: &libsql_core::Connection) -> PyResult<()> { Ok(()) } -async fn execute(cursor: &Cursor, sql: String, parameters: Option<&PyTuple>) -> PyResult<()> { +async fn execute<'py>( + cursor: &Cursor, + sql: String, + parameters: Option>, +) -> PyResult<()> { if cursor.conn.borrow().as_ref().is_none() { return Err(PyValueError::new_err("Connection already closed")); } @@ -576,7 +629,10 @@ async fn execute(cursor: &Cursor, sql: String, parameters: Option<&PyTuple>) -> } else if let Ok(value) = param.extract::<&[u8]>() { libsql_core::Value::Blob(value.to_vec()) } else { - return Err(PyValueError::new_err("Unsupported parameter type")); + return Err(PyValueError::new_err(format!( + "Unsupported parameter type {}", + param.to_string() + ))); }; params.push(param); } @@ -629,32 +685,74 @@ fn stmt_is_dml(sql: &str) -> bool { sql.starts_with("INSERT") || sql.starts_with("UPDATE") || sql.starts_with("DELETE") } -fn convert_row(py: Python, row: libsql_core::Row, column_count: i32) -> PyResult<&PyTuple> { +fn convert_row( + py: Python, + row: libsql_core::Row, + column_count: i32, +) -> PyResult> { let mut elements: Vec> = vec![]; for col_idx in 0..column_count { let libsql_value = row.get_value(col_idx).map_err(to_py_err)?; let value = match libsql_value { libsql_core::Value::Integer(v) => { let value = v as i64; - value.into_py(py) + value.into_pyobject(py).unwrap().into() } - libsql_core::Value::Real(v) => v.into_py(py), - libsql_core::Value::Text(v) => v.into_py(py), + libsql_core::Value::Real(v) => v.into_pyobject(py).unwrap().into(), + libsql_core::Value::Text(v) => v.into_pyobject(py).unwrap().into(), libsql_core::Value::Blob(v) => { let value = v.as_slice(); - value.into_py(py) + value.into_pyobject(py).unwrap().into() } libsql_core::Value::Null => py.None(), }; elements.push(value); } - Ok(PyTuple::new(py, elements)) + Ok(PyTuple::new(py, elements)?) } create_exception!(libsql, Error, pyo3::exceptions::PyException); +impl<'py> FromPyObject<'py> for ListOrTuple<'py> { + fn extract_bound(ob: &Bound<'py, PyAny>) -> PyResult { + if let Ok(list) = ob.downcast::() { + Ok(ListOrTuple::List(list.clone())) + } else if let Ok(tuple) = ob.downcast::() { + Ok(ListOrTuple::Tuple(tuple.clone())) + } else { + Err(PyValueError::new_err( + "Expected a list or tuple for parameters", + )) + } + } +} + +impl<'py> ListOrTuple<'py> { + pub fn iter(&self) -> ListOrTupleIterator<'py> { + ListOrTupleIterator { + index: 0, + inner: self.clone(), + } + } +} + +impl<'py> Iterator for ListOrTupleIterator<'py> { + type Item = Bound<'py, PyAny>; + + fn next(&mut self) -> Option { + let rv = match &self.inner { + ListOrTuple::List(list) => list.get_item(self.index), + ListOrTuple::Tuple(tuple) => tuple.get_item(self.index), + }; + + rv.ok().map(|item| { + self.index += 1; + item + }) + } +} #[pymodule] -fn libsql(py: Python, m: &PyModule) -> PyResult<()> { +fn libsql(py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { let _ = tracing_subscriber::fmt::try_init(); m.add("LEGACY_TRANSACTION_CONTROL", LEGACY_TRANSACTION_CONTROL)?; m.add("paramstyle", "qmark")?; diff --git a/tests/test_suite.py b/tests/test_suite.py index 428f314..fb83a63 100644 --- a/tests/test_suite.py +++ b/tests/test_suite.py @@ -6,16 +6,19 @@ import pytest import tempfile + @pytest.mark.parametrize("provider", ["libsql", "sqlite"]) def test_connection_timeout(provider): conn = connect(provider, ":memory:", timeout=1.0) conn.close() + @pytest.mark.parametrize("provider", ["libsql", "sqlite"]) def test_connection_close(provider): conn = connect(provider, ":memory:") conn.close() + @pytest.mark.parametrize("provider", ["libsql", "sqlite"]) def test_execute(provider): conn = connect(provider, ":memory:") @@ -23,6 +26,9 @@ def test_execute(provider): conn.execute("INSERT INTO users VALUES (1, 'alice@example.com')") res = conn.execute("SELECT * FROM users") assert (1, "alice@example.com") == res.fetchone() + # allow lists for parameters as well + res = conn.execute("SELECT * FROM users WHERE id = ?", [1]) + assert (1, "alice@example.com") == res.fetchone() @pytest.mark.parametrize("provider", ["libsql", "sqlite"]) @@ -34,6 +40,7 @@ def test_cursor_execute(provider): res = cur.execute("SELECT * FROM users") assert (1, "alice@example.com") == res.fetchone() + @pytest.mark.parametrize("provider", ["libsql", "sqlite"]) def test_cursor_close(provider): conn = connect(provider, ":memory:") @@ -47,6 +54,7 @@ def test_cursor_close(provider): with pytest.raises(Exception): cur.execute("SELECT * FROM users") + @pytest.mark.parametrize("provider", ["libsql", "sqlite"]) def test_executemany(provider): conn = connect(provider, ":memory:") @@ -198,7 +206,9 @@ def test_connection_autocommit(provider): res = cur.execute("SELECT * FROM users") assert (1, "alice@example.com") == res.fetchone() - conn = connect(provider, ":memory:", timeout=5, isolation_level="DEFERRED", autocommit=-1) + conn = connect( + provider, ":memory:", timeout=5, isolation_level="DEFERRED", autocommit=-1 + ) assert conn.isolation_level == "DEFERRED" assert conn.autocommit == -1 cur = conn.cursor() @@ -210,7 +220,9 @@ def test_connection_autocommit(provider): assert (1, "alice@example.com") == res.fetchone() # Test autocommit Enabled (True) - conn = connect(provider, ":memory:", timeout=5, isolation_level=None, autocommit=True) + conn = connect( + provider, ":memory:", timeout=5, isolation_level=None, autocommit=True + ) assert conn.isolation_level == None assert conn.autocommit == True cur = conn.cursor() @@ -221,7 +233,9 @@ def test_connection_autocommit(provider): res = cur.execute("SELECT * FROM users") assert (1, "bob@example.com") == res.fetchone() - conn = connect(provider, ":memory:", timeout=5, isolation_level="DEFERRED", autocommit=True) + conn = connect( + provider, ":memory:", timeout=5, isolation_level="DEFERRED", autocommit=True + ) assert conn.isolation_level == "DEFERRED" assert conn.autocommit == True cur = conn.cursor() @@ -233,7 +247,9 @@ def test_connection_autocommit(provider): assert (1, "bob@example.com") == res.fetchone() # Test autocommit Disabled (False) - conn = connect(provider, ":memory:", timeout=5, isolation_level="DEFERRED", autocommit=False) + conn = connect( + provider, ":memory:", timeout=5, isolation_level="DEFERRED", autocommit=False + ) assert conn.isolation_level == "DEFERRED" assert conn.autocommit == False cur = conn.cursor() @@ -260,6 +276,7 @@ def test_params(provider): res = cur.execute("SELECT * FROM users") assert (1, "alice@example.com") == res.fetchone() + @pytest.mark.parametrize("provider", ["libsql", "sqlite"]) def test_none_param(provider): conn = connect(provider, ":memory:") @@ -272,6 +289,7 @@ def test_none_param(provider): assert results[0] == (1, None) assert results[1] == (2, "alice@example.com") + @pytest.mark.parametrize("provider", ["libsql", "sqlite"]) def test_fetchmany(provider): conn = connect(provider, ":memory:") @@ -321,6 +339,194 @@ def test_int64(provider): assert [(1, 1099511627776)] == res.fetchall() +@pytest.mark.parametrize("provider", ["libsql", "sqlite"]) +def test_context_manager_commit(provider): + """Test that context manager commits on clean exit""" + conn = connect(provider, ":memory:") + with conn as c: + c.execute("CREATE TABLE t(x)") + c.execute("INSERT INTO t VALUES (1)") + # Changes should be committed + cur = conn.cursor() + cur.execute("SELECT COUNT(*) FROM t") + assert cur.fetchone()[0] == 1 + + +@pytest.mark.parametrize("provider", ["libsql", "sqlite"]) +def test_context_manager_rollback(provider): + """Test that context manager rolls back on exception""" + conn = connect(provider, ":memory:") + try: + with conn as c: + c.execute("CREATE TABLE t(x)") + c.execute("INSERT INTO t VALUES (1)") + raise ValueError("Test exception") + except ValueError: + pass + # Changes should be rolled back + cur = conn.cursor() + try: + cur.execute("SELECT COUNT(*) FROM t") + # If we get here, the table exists (rollback didn't work) + assert False, "Table should not exist after rollback" + except Exception: + # Table doesn't exist, which is what we expect after rollback + pass + + +@pytest.mark.parametrize("provider", ["libsql", "sqlite"]) +def test_context_manager_autocommit(provider): + """Test that context manager works correctly with autocommit mode""" + conn = connect(provider, ":memory:", isolation_level=None) # autocommit mode + with conn as c: + c.execute("CREATE TABLE t(x)") + c.execute("INSERT INTO t VALUES (1)") + # In autocommit mode, changes are committed immediately + cur = conn.cursor() + cur.execute("SELECT COUNT(*) FROM t") + assert cur.fetchone()[0] == 1 + + +@pytest.mark.parametrize("provider", ["libsql", "sqlite"]) +def test_context_manager_nested(provider): + """Test nested context managers""" + conn = connect(provider, ":memory:") + with conn as c1: + c1.execute("CREATE TABLE t(x)") + c1.execute("INSERT INTO t VALUES (1)") + with conn as c2: + c2.execute("INSERT INTO t VALUES (2)") + # Inner context commits + cur = conn.cursor() + cur.execute("SELECT COUNT(*) FROM t") + assert cur.fetchone()[0] == 2 + # Outer context also commits + cur = conn.cursor() + cur.execute("SELECT COUNT(*) FROM t") + assert cur.fetchone()[0] == 2 + + +@pytest.mark.parametrize("provider", ["libsql", "sqlite"]) +def test_context_manager_connection_reuse(provider): + """Test that connection remains usable after context manager exit""" + conn = connect(provider, ":memory:") + + # First use with context manager + with conn as c: + c.execute("CREATE TABLE t(x)") + c.execute("INSERT INTO t VALUES (1)") + + # Connection should still be valid + cur = conn.cursor() + cur.execute("INSERT INTO t VALUES (2)") + conn.commit() + + # Verify both inserts worked + cur.execute("SELECT COUNT(*) FROM t") + assert cur.fetchone()[0] == 2 + + # Use context manager again + with conn as c: + c.execute("INSERT INTO t VALUES (3)") + + # Final verification + cur.execute("SELECT COUNT(*) FROM t") + assert cur.fetchone()[0] == 3 + + conn.close() + + +@pytest.mark.parametrize("provider", ["libsql", "sqlite"]) +def test_context_manager_nested_exception(provider): + """Test exception handling in nested context managers""" + conn = connect(provider, ":memory:") + + # Create table outside context + conn.execute("CREATE TABLE t(x)") + conn.commit() + + # Test that nested context managers share the same transaction + # An exception in an inner context will roll back the entire transaction + try: + with conn as c1: + c1.execute("INSERT INTO t VALUES (1)") + try: + with conn as c2: + c2.execute("INSERT INTO t VALUES (2)") + raise ValueError("Inner exception") + except ValueError: + pass + # The inner rollback affects the entire transaction + # So value 1 is also rolled back + c1.execute("INSERT INTO t VALUES (3)") + except: + pass + + # Only value 3 should be committed (1 and 2 were rolled back together) + cur = conn.cursor() + cur.execute("SELECT x FROM t ORDER BY x") + results = cur.fetchall() + assert results == [(3,)] + + # Test outer exception after nested context commits + conn.execute("DROP TABLE t") + conn.execute("CREATE TABLE t(x)") + conn.commit() + + try: + with conn as c1: + c1.execute("INSERT INTO t VALUES (10)") + with conn as c2: + c2.execute("INSERT INTO t VALUES (20)") + # Inner context will commit both values + # This will cause outer rollback but values are already committed + raise RuntimeError("Outer exception") + except RuntimeError: + pass + + # Values 10 and 20 should be committed by inner context + cur.execute("SELECT COUNT(*) FROM t") + assert cur.fetchone()[0] == 2 + + +@pytest.mark.parametrize("provider", ["libsql", "sqlite"]) +def test_context_manager_manual_transaction_control(provider): + """Test mixing manual transaction control with context managers""" + conn = connect(provider, ":memory:") + + with conn as c: + c.execute("CREATE TABLE t(x)") + c.execute("INSERT INTO t VALUES (1)") + + # Manual commit within context + c.commit() + + # Start new transaction + c.execute("INSERT INTO t VALUES (2)") + # This will be committed by context manager + + # Both values should be present + cur = conn.cursor() + cur.execute("SELECT COUNT(*) FROM t") + assert cur.fetchone()[0] == 2 + + # Test manual rollback within context + with conn as c: + c.execute("INSERT INTO t VALUES (3)") + + # Manual rollback + c.rollback() + + # New transaction + c.execute("INSERT INTO t VALUES (4)") + # This will be committed by context manager + + # Should have values 1, 2, and 4 (not 3) + cur.execute("SELECT x FROM t ORDER BY x") + results = cur.fetchall() + assert results == [(1,), (2,), (4,)] + + def connect(provider, database, timeout=5, isolation_level="DEFERRED", autocommit=-1): if provider == "libsql-remote": from urllib import request @@ -331,9 +537,7 @@ def connect(provider, database, timeout=5, isolation_level="DEFERRED", autocommi raise Exception("libsql-remote server is not running") if res.getcode() != 200: raise Exception("libsql-remote server is not running") - return libsql.connect( - database, sync_url="http://localhost:8080", auth_token="" - ) + return libsql.connect(database, sync_url="http://localhost:8080", auth_token="") if provider == "libsql": if sys.version_info < (3, 12): return libsql.connect( @@ -343,15 +547,23 @@ def connect(provider, database, timeout=5, isolation_level="DEFERRED", autocommi if autocommit == -1: autocommit = libsql.LEGACY_TRANSACTION_CONTROL return libsql.connect( - database, timeout=timeout, isolation_level=isolation_level, autocommit=autocommit + database, + timeout=timeout, + isolation_level=isolation_level, + autocommit=autocommit, ) if provider == "sqlite": if sys.version_info < (3, 12): - return sqlite3.connect(database, timeout=timeout, isolation_level=isolation_level) + return sqlite3.connect( + database, timeout=timeout, isolation_level=isolation_level + ) else: if autocommit == -1: autocommit = sqlite3.LEGACY_TRANSACTION_CONTROL return sqlite3.connect( - database, timeout=timeout, isolation_level=isolation_level, autocommit=autocommit + database, + timeout=timeout, + isolation_level=isolation_level, + autocommit=autocommit, ) raise Exception(f"Provider `{provider}` is not supported")