• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

joaoh82 / rust_sqlite / 25655045701

11 May 2026 06:54AM UTC coverage: 68.331%. Remained the same
25655045701

push

github

web-flow
feat(sdk): Phase 11.7 SDK propagation of Busy / BusySnapshot (SQLR-22) (#128)

* feat(sdk): Phase 11.7 SDK propagation of Busy / BusySnapshot (SQLR-22)

Surfaces retryable engine errors through the C FFI and every
language SDK so Python / Node / Go callers can actually write
BEGIN CONCURRENT retry loops with idiomatic per-language
patterns. Picked ahead of plan-doc 11.5 (checkpoint integration)
for the same reason 11.5 / 11.6 jumped the queue — durability
already works through the legacy save_database mirror, but
SDK users hitting BEGIN CONCURRENT had no way to distinguish
retryable errors from real failures.

Plan-doc 11.8 ("SDK + REPL propagation") split into two:
- FFI/SDK error propagation ships here as roadmap 11.7
- Multi-handle SDK shape + REPL .spawn → roadmap 11.10

C FFI:
- new SqlriteStatus::Busy = 5 + BusySnapshot = 6 codes
- SqlriteStatus::is_retryable() covers both
- new status_of_sqlrite() mapper routes engine-typed errors to
  the dedicated codes; generic status_of() keeps mapping every
  error to Error for non-engine result types
- sqlrite_execute switched to the engine-aware mapper
- header regenerated via build.rs

Python SDK:
- new sqlrite.BusyError + sqlrite.BusySnapshotError pyo3
  exception classes, both inheriting from sqlrite.SQLRiteError
- new map_engine_err() helper inspects the engine variant and
  raises the matching exception class
- all engine-typed call sites (open / execute / prepare /
  query / rows.next) routed through it
- existing `except sqlrite.SQLRiteError` blocks still catch
  both; retry helpers branch with `except sqlrite.BusyError`

Node.js SDK:
- new exported ErrorKind string enum ('Busy' | 'BusySnapshot'
  | 'Other') and errorKind(message) classifier function
- engine's thiserror Display already prefixes the error
  message with 'Busy: ' / 'BusySnapshot: '; classifier matches
  the prefix (longest-first to avoid mis-classifying snapshot
  errors)
- JS pattern: try { ... } catch (err) {
    if (errorKind(er... (continued)

0 of 7 new or added lines in 1 file covered. (0.0%)

10577 of 15479 relevant lines covered (68.33%)

1.24 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

0.0
/sdk/python/src/lib.rs
1
//! Python bindings for SQLRite (Phase 5c).
2
//!
3
//! Exposes a `sqlrite` module on the Python side shaped after PEP 249
4
//! / the stdlib `sqlite3` module — users who know either should be
5
//! able to pick it up without reading the docs:
6
//!
7
//! ```python
8
//! import sqlrite
9
//!
10
//! conn = sqlrite.connect("foo.sqlrite")
11
//! cur = conn.cursor()
12
//! cur.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)")
13
//! cur.execute("INSERT INTO users (name) VALUES ('alice')")
14
//! cur.execute("SELECT id, name FROM users")
15
//! for row in cur:
16
//!     print(row[0], row[1])
17
//! conn.close()
18
//! ```
19
//!
20
//! ## Implementation notes
21
//!
22
//! - We wrap the Rust `Connection` from the `sqlrite` crate directly
23
//!   (not via the C FFI from `sqlrite-ffi`). PyO3 marshals types
24
//!   cheaper than a C round-trip, so going via the Rust API is
25
//!   strictly better for performance and avoids a double-layer of
26
//!   error mapping.
27
//!
28
//! - Every Rust error surfaces as a Python `sqlrite.SQLRiteError`
29
//!   exception. No silent swallowing — if something went wrong the
30
//!   Python caller sees a traceback.
31
//!
32
//! - Parameter binding (`cur.execute(sql, params)`) isn't in the
33
//!   engine yet — deferred to Phase 5a.2. The wrapper accepts the
34
//!   DB-API signature but raises `TypeError` if a non-empty
35
//!   parameter tuple is passed. Callers should inline values into
36
//!   the SQL for the moment (with manual escaping — full support
37
//!   lands in 5a.2).
38
//!
39
//! - GIL handling: we hold the GIL for the duration of each call.
40
//!   This keeps the bindings simple and is fine for the small-DB
41
//!   use case; Phase 5c.2 will explore `py.allow_threads` to
42
//!   release the GIL during long-running queries once the cursor
43
//!   abstraction lands.
44

45
use std::path::PathBuf;
46
use std::sync::Mutex;
47

48
use pyo3::exceptions::PyTypeError;
49
use pyo3::prelude::*;
50
use pyo3::types::{PyList, PyTuple};
51

52
use sqlrite::ask::{
53
    AskConfig as RustAskConfig, AskResponse as RustAskResponse, CacheTtl, ProviderKind, Usage,
54
    ask_with_database,
55
};
56
use sqlrite::{Connection as RustConnection, OwnedRow, Rows, Value};
57

58
// ---------------------------------------------------------------------------
59
// Exception type
60
//
61
// Every Rust-side error bubbles up as this. Mirrors DB-API 2.0's
62
// `DatabaseError` — we keep a single exception type for simplicity;
63
// finer-grained types (IntegrityError, ProgrammingError, etc.) are
64
// a natural later refinement once the engine distinguishes them.
65

66
pyo3::create_exception!(
67
    sqlrite,
68
    SQLRiteError,
69
    pyo3::exceptions::PyException,
70
    "Base error class for SQLRite failures."
71
);
72

73
// Phase 11.7 — distinct exception classes for the two retryable
74
// engine errors. They inherit from `SQLRiteError` so existing
75
// `except sqlrite.SQLRiteError` blocks still catch them, but
76
// retry helpers can branch on the specific subclass with
77
// `except sqlrite.BusyError` for snapshot-isolation-style retry
78
// loops without re-parsing the message.
79
pyo3::create_exception!(
80
    sqlrite,
81
    BusyError,
82
    SQLRiteError,
83
    "Raised when `BEGIN CONCURRENT` commits hit a row-level \
84
     write-write conflict. The transaction has already been \
85
     rolled back; the caller should retry the whole transaction \
86
     with a fresh `BEGIN CONCURRENT`. Subclass of `SQLRiteError`."
87
);
88

89
pyo3::create_exception!(
90
    sqlrite,
91
    BusySnapshotError,
92
    SQLRiteError,
93
    "Raised when a `BEGIN CONCURRENT` read sees a row that has \
94
     been superseded after the transaction's snapshot was taken. \
95
     Same retry semantics as `BusyError` — wired through the same \
96
     SDK retry helper. Subclass of `SQLRiteError`."
97
);
98

99
/// Generic error mapper — produces a base `SQLRiteError`.
100
/// Falls back to the engine's `Display` impl for the message
101
/// regardless of the variant.
102
fn map_err<E: std::fmt::Display>(e: E) -> PyErr {
103
    SQLRiteError::new_err(e.to_string())
104
}
105

106
/// Phase 11.7 — engine-typed mapper. Inspects the variant and
107
/// raises `BusyError` / `BusySnapshotError` for retryable
108
/// failures, the base `SQLRiteError` otherwise. Every path that
109
/// receives a `Result<_, sqlrite::SQLRiteError>` should use
110
/// this — `map_err` (generic, above) collapses the variant and
111
/// SDK callers lose the retryability information.
112
///
113
/// The full-path `sqlrite::SQLRiteError::…` references below
114
/// disambiguate the engine enum from the pyo3 exception class
115
/// of the same short name (`SQLRiteError`) created above.
116
fn map_engine_err(e: sqlrite::SQLRiteError) -> PyErr {
117
    match &e {
118
        sqlrite::SQLRiteError::Busy(_) => BusyError::new_err(e.to_string()),
119
        sqlrite::SQLRiteError::BusySnapshot(_) => BusySnapshotError::new_err(e.to_string()),
120
        _ => SQLRiteError::new_err(e.to_string()),
121
    }
122
}
123

124
// ---------------------------------------------------------------------------
125
// Connection
126
//
127
// Wraps `RustConnection` behind a `Mutex` so Python callers can share
128
// a connection between threads (PyO3 requires `#[pyclass]` types to
129
// be `Send + Sync`). The Rust `Connection` isn't `Sync`, so the
130
// Mutex is the straightforward fix — callers still need to serialize
131
// access, but they won't get a panic.
132

133
/// Open a connection to a SQLRite database file. Use `:memory:` to
134
/// get an in-memory database (matching sqlite3 convention).
135
#[pyfunction]
136
#[pyo3(text_signature = "(database, /)")]
137
fn connect(database: &str) -> PyResult<Connection> {
138
    let rust_conn = if database == ":memory:" {
139
        RustConnection::open_in_memory().map_err(map_engine_err)?
140
    } else {
141
        RustConnection::open(PathBuf::from(database)).map_err(map_engine_err)?
142
    };
143
    Ok(Connection {
144
        inner: Some(Mutex::new(rust_conn)),
145
        ask_config: None,
146
    })
147
}
148

149
/// Open a database file read-only (shared OS lock; coexists with
150
/// other read-only openers, excluded by any writer).
151
#[pyfunction]
152
#[pyo3(text_signature = "(database, /)")]
153
fn connect_read_only(database: &str) -> PyResult<Connection> {
154
    let rust_conn =
155
        RustConnection::open_read_only(PathBuf::from(database)).map_err(map_engine_err)?;
156
    Ok(Connection {
157
        inner: Some(Mutex::new(rust_conn)),
158
        ask_config: None,
159
    })
160
}
161

162
/// A database connection. Obtain one via [`connect`].
163
#[pyclass]
164
struct Connection {
165
    // `Option<_>` so `close()` can explicitly drop the inner
166
    // connection (and release the OS-level file lock) without
167
    // waiting for GC. Operations on a closed connection raise.
168
    inner: Option<Mutex<RustConnection>>,
169
    // Phase 7g.4 — per-connection ask() config. Set via
170
    // `set_ask_config()` or passed per-call to `ask()` / `ask_run()`.
171
    // When None, `ask()` falls back to `AskConfig::from_env()` so
172
    // env-only consumers get the zero-config experience matching the
173
    // REPL and Desktop surfaces.
174
    ask_config: Option<RustAskConfig>,
175
}
176

177
impl Connection {
178
    fn with_inner<F, T>(&mut self, op: &str, f: F) -> PyResult<T>
179
    where
180
        F: FnOnce(&mut RustConnection) -> PyResult<T>,
181
    {
182
        let guard = self
×
183
            .inner
×
184
            .as_ref()
×
185
            .ok_or_else(|| SQLRiteError::new_err(format!("cannot {op}: connection is closed")))?;
×
186
        let mut locked = guard
×
187
            .lock()
×
188
            .map_err(|_| SQLRiteError::new_err("connection mutex poisoned"))?;
×
189
        f(&mut locked)
×
190
    }
191
}
192

193
#[pymethods]
194
impl Connection {
195
    /// Returns a new cursor. Cursors don't share row state, so
196
    /// multiple cursors against the same connection can iterate
197
    /// independently.
198
    fn cursor(slf: Py<Self>) -> Cursor {
×
199
        Cursor {
200
            conn: slf,
201
            current_rows: None,
202
            description: None,
203
            last_status: None,
204
        }
205
    }
206

207
    /// Convenience shorthand for `cursor().execute(sql)`. Returns
208
    /// the cursor so you can chain `.fetchall()` off it.
209
    #[pyo3(signature = (sql, params=None))]
210
    fn execute(
×
211
        slf: Py<Self>,
212
        py: Python<'_>,
213
        sql: &str,
214
        params: Option<Py<PyAny>>,
215
    ) -> PyResult<Cursor> {
216
        let mut cur = Self::cursor(slf);
×
217
        cur.execute(py, sql, params)?;
×
218
        Ok(cur)
×
219
    }
220

221
    /// Commits the current transaction. Equivalent to `cursor().execute("COMMIT")`,
222
    /// but a no-op if no transaction is open (matching the DB-API's
223
    /// expectation that `commit()` is always safe to call).
224
    fn commit(&mut self) -> PyResult<()> {
×
225
        self.with_inner("commit", |c| {
×
226
            if c.in_transaction() {
×
NEW
227
                c.execute("COMMIT").map(|_| ()).map_err(map_engine_err)?;
×
228
            }
229
            Ok(())
×
230
        })
231
    }
232

233
    /// Rolls back the current transaction. No-op if no transaction
234
    /// is open (again: DB-API expectation).
235
    fn rollback(&mut self) -> PyResult<()> {
×
236
        self.with_inner("rollback", |c| {
×
237
            if c.in_transaction() {
×
NEW
238
                c.execute("ROLLBACK").map(|_| ()).map_err(map_engine_err)?;
×
239
            }
240
            Ok(())
×
241
        })
242
    }
243

244
    /// Closes the connection and releases the OS file lock. Safe to
245
    /// call multiple times; a closed connection raises `SQLRiteError`
246
    /// on any subsequent operation.
247
    fn close(&mut self) -> PyResult<()> {
×
248
        self.inner = None;
×
249
        Ok(())
×
250
    }
251

252
    /// Context-manager entry — returns self unchanged.
253
    fn __enter__(slf: PyRefMut<'_, Self>) -> PyRefMut<'_, Self> {
×
254
        slf
×
255
    }
256

257
    /// Context-manager exit — commits on clean exit, rolls back on
258
    /// exception (mirrors the stdlib `sqlite3` behavior), then closes.
259
    #[pyo3(signature = (exc_type=None, _exc_value=None, _traceback=None))]
260
    fn __exit__(
×
261
        &mut self,
262
        exc_type: Option<Py<PyAny>>,
263
        _exc_value: Option<Py<PyAny>>,
264
        _traceback: Option<Py<PyAny>>,
265
    ) -> PyResult<bool> {
266
        if self.inner.is_some() {
×
267
            if exc_type.is_some() {
×
268
                self.rollback()?;
×
269
            } else {
270
                self.commit()?;
×
271
            }
272
        }
273
        self.close()?;
×
274
        // Return False to signal "don't suppress any exception the
275
        // with-block may have raised".
276
        Ok(false)
277
    }
278

279
    /// `True` while a `BEGIN … COMMIT/ROLLBACK` block is open.
280
    #[getter]
281
    fn in_transaction(&self) -> PyResult<bool> {
×
282
        let guard = self
×
283
            .inner
×
284
            .as_ref()
×
285
            .ok_or_else(|| SQLRiteError::new_err("connection is closed"))?;
×
286
        let locked = guard
×
287
            .lock()
×
288
            .map_err(|_| SQLRiteError::new_err("connection mutex poisoned"))?;
×
289
        Ok(locked.in_transaction())
×
290
    }
291

292
    /// `True` if this connection was opened read-only.
293
    #[getter]
294
    fn read_only(&self) -> PyResult<bool> {
×
295
        let guard = self
×
296
            .inner
×
297
            .as_ref()
×
298
            .ok_or_else(|| SQLRiteError::new_err("connection is closed"))?;
×
299
        let locked = guard
×
300
            .lock()
×
301
            .map_err(|_| SQLRiteError::new_err("connection mutex poisoned"))?;
×
302
        Ok(locked.is_read_only())
×
303
    }
304

305
    // ---------------------------------------------------------------
306
    // Phase 7g.4 — natural-language → SQL.
307
    //
308
    // Three entry points:
309
    //   * `set_ask_config(...)` stores a config on the connection so
310
    //     subsequent `ask()` calls reuse it without reconfiguring.
311
    //   * `ask(question, config=None)` generates SQL — does NOT execute.
312
    //     Returns an `AskResponse` with `.sql` / `.explanation` / `.usage`.
313
    //   * `ask_run(question, config=None)` is the convenience that
314
    //     calls `ask()` then `execute()` on the generated SQL,
315
    //     returning a `Cursor` you can `.fetchall()` from.
316
    //
317
    // Config resolution (when `config` arg is None):
318
    //   1. The per-connection `ask_config` if set via set_ask_config()
319
    //   2. AskConfig::from_env() — reads SQLRITE_LLM_API_KEY etc.
320
    //   3. Built-in defaults (Sonnet 4.6, max_tokens 1024, 5-min cache TTL)
321
    //
322
    // The schema dump + LLM HTTP call run entirely on the Rust side
323
    // (no GIL re-acquisition for the duration of the network round-
324
    // trip). API key is read from the AskConfig — never logged, never
325
    // serialized into AskResponse, never crosses the FFI boundary
326
    // back to Python (we only return sql/explanation/usage).
327

328
    /// Stash an `AskConfig` on the connection. Subsequent `ask()` and
329
    /// `ask_run()` calls without an explicit config use this. Pass
330
    /// `None` to clear and fall back to env/defaults.
331
    #[pyo3(signature = (config))]
332
    fn set_ask_config(&mut self, config: Option<&AskConfig>) {
×
333
        self.ask_config = config.map(|c| c.inner.clone());
×
334
    }
335

336
    /// Generate SQL from a natural-language question. Does **not**
337
    /// execute — call `cur.execute(resp.sql)` (or `ask_run()` for
338
    /// one-shot). Returns an `AskResponse` with `.sql`,
339
    /// `.explanation`, and `.usage`.
340
    ///
341
    /// **GIL handling.** Releases the GIL for the duration of the
342
    /// HTTP call. Without this, a Python-side HTTP mock server (or
343
    /// any other thread) can't run concurrently with `ask()` — they'd
344
    /// sit blocked waiting for the GIL while ureq waited for them
345
    /// to respond. Same threading rule as the rest of PyO3 land:
346
    /// hold the GIL only for Python-data work, release it for I/O.
347
    #[pyo3(signature = (question, config=None))]
348
    fn ask(
×
349
        &mut self,
350
        py: Python<'_>,
351
        question: &str,
352
        config: Option<&AskConfig>,
353
    ) -> PyResult<AskResponse> {
354
        let resolved = self.resolve_ask_config(config)?;
×
355
        // Borrow the engine connection for schema dump + LLM call.
356
        // `ask_with_database` takes &Database (read-only), so we
357
        // hold the lock for the duration of one call.
358
        let inner = self
×
359
            .inner
×
360
            .as_ref()
×
361
            .ok_or_else(|| SQLRiteError::new_err("cannot ask: connection is closed"))?;
×
362
        // We can't take the mutex inside py.allow_threads (the
363
        // borrow on `self` needs the GIL released semantics it
364
        // doesn't have access to), so we lock first, then release
365
        // the GIL across the network call. The lock guard lives
366
        // through the allow_threads block — that's fine, it's a
367
        // pure-Rust mutex with no Python state.
368
        let locked = inner
×
369
            .lock()
×
370
            .map_err(|_| SQLRiteError::new_err("connection mutex poisoned"))?;
×
371
        let resp = py
×
372
            .allow_threads(|| {
×
373
                let db = locked.database();
×
374
                ask_with_database(&db, question, &resolved)
×
375
            })
376
            .map_err(map_err)?;
×
377
        Ok(AskResponse::from_rust(resp))
×
378
    }
379

380
    /// Generate SQL **and execute it**. Returns a `Cursor` with the
381
    /// results — call `.fetchall()` / `.fetchone()` / iterate. Errors
382
    /// the same way `ask()` does on generation failure, and the same
383
    /// way `cursor.execute()` does on bad-SQL execution failure (the
384
    /// model produced something the engine can't run).
385
    ///
386
    /// Convenience for one-shot scripts and notebooks. For interactive
387
    /// REPL-style use, prefer `ask()` + manual review (the model can
388
    /// be wrong; auto-execute hides that).
389
    #[pyo3(signature = (question, config=None))]
390
    fn ask_run(
×
391
        slf: Py<Self>,
392
        py: Python<'_>,
393
        question: &str,
394
        config: Option<&AskConfig>,
395
    ) -> PyResult<Cursor> {
396
        let resp = {
×
397
            let mut conn = slf.borrow_mut(py);
×
398
            conn.ask(py, question, config)?
×
399
        };
400
        if resp.sql.trim().is_empty() {
×
401
            return Err(SQLRiteError::new_err(format!(
×
402
                "model declined to generate SQL: {}",
×
403
                if resp.explanation.is_empty() {
×
404
                    "(no explanation)"
×
405
                } else {
406
                    resp.explanation.as_str()
×
407
                }
408
            )));
409
        }
410
        Self::execute(slf, py, &resp.sql, None)
×
411
    }
412
}
413

414
impl Connection {
415
    /// Resolve the effective AskConfig for an `ask()` / `ask_run()`
416
    /// call: per-call config wins, then per-connection, then env, then
417
    /// defaults. See the comment block above the methods for the
418
    /// rationale.
419
    fn resolve_ask_config(&self, per_call: Option<&AskConfig>) -> PyResult<RustAskConfig> {
×
420
        if let Some(cfg) = per_call {
×
421
            return Ok(cfg.inner.clone());
×
422
        }
423
        if let Some(cfg) = &self.ask_config {
×
424
            return Ok(cfg.clone());
×
425
        }
426
        RustAskConfig::from_env().map_err(map_err)
×
427
    }
428
}
429

430
// ---------------------------------------------------------------------------
431
// AskConfig (Phase 7g.4)
432
//
433
// Mirrors the Rust AskConfig but with Python-friendly attribute
434
// access. Constructed via `AskConfig(api_key=..., model=...)` or
435
// `AskConfig.from_env()`. Stored on the connection via
436
// `conn.set_ask_config(cfg)` or passed per-call to `conn.ask(q, cfg)`.
437

438
/// LLM call configuration for `Connection.ask()` and `ask_run()`.
439
///
440
/// Construct from kwargs:
441
///
442
///     cfg = sqlrite.AskConfig(
443
///         api_key="sk-ant-...",
444
///         model="claude-sonnet-4-6",
445
///         max_tokens=1024,
446
///         cache_ttl="5m",
447
///     )
448
///
449
/// Or from environment vars (`SQLRITE_LLM_API_KEY` etc.):
450
///
451
///     cfg = sqlrite.AskConfig.from_env()
452
///
453
/// Stored on the connection so subsequent `ask()` calls reuse it:
454
///
455
///     conn.set_ask_config(cfg)
456
///     resp = conn.ask("How many users?")          # uses cfg
457
///
458
/// Or passed per-call (overrides any per-connection config):
459
///
460
///     resp = conn.ask("How many users?", cfg)
461
#[pyclass]
462
#[derive(Clone)]
463
struct AskConfig {
464
    inner: RustAskConfig,
465
}
466

467
#[pymethods]
468
impl AskConfig {
469
    /// Construct from kwargs. Any kwarg left unset uses the same
470
    /// default the Rust side does (provider=anthropic, model=
471
    /// `claude-sonnet-4-6`, max_tokens=1024, cache_ttl="5m").
472
    ///
473
    /// `provider`: `"anthropic"` (only currently supported).
474
    /// `cache_ttl`: `"5m"` (default), `"1h"`, or `"off"`.
475
    /// `base_url`: override the API base URL — production callers
476
    ///   leave this None; tests point it at a localhost mock.
477
    #[new]
478
    #[pyo3(signature = (
479
        provider="anthropic",
480
        api_key=None,
481
        model=None,
482
        max_tokens=None,
483
        cache_ttl=None,
484
        base_url=None,
485
    ))]
486
    fn new(
×
487
        provider: &str,
488
        api_key: Option<String>,
489
        model: Option<String>,
490
        max_tokens: Option<u32>,
491
        cache_ttl: Option<&str>,
492
        base_url: Option<String>,
493
    ) -> PyResult<Self> {
494
        let mut inner = RustAskConfig::default();
×
495
        inner.provider = match provider.to_ascii_lowercase().as_str() {
×
496
            "anthropic" => ProviderKind::Anthropic,
×
497
            other => {
×
498
                return Err(SQLRiteError::new_err(format!(
×
499
                    "unknown provider: {other} (supported: anthropic)"
×
500
                )));
501
            }
502
        };
503
        if let Some(k) = api_key {
×
504
            if !k.is_empty() {
×
505
                inner.api_key = Some(k);
×
506
            }
507
        }
508
        if let Some(m) = model {
×
509
            if !m.is_empty() {
×
510
                inner.model = m;
×
511
            }
512
        }
513
        if let Some(t) = max_tokens {
×
514
            inner.max_tokens = t;
×
515
        }
516
        if let Some(c) = cache_ttl {
×
517
            inner.cache_ttl = match c.to_ascii_lowercase().as_str() {
×
518
                "5m" | "5min" | "5minutes" => CacheTtl::FiveMinutes,
×
519
                "1h" | "1hr" | "1hour" => CacheTtl::OneHour,
×
520
                "off" | "none" | "disabled" => CacheTtl::Off,
×
521
                other => {
×
522
                    return Err(SQLRiteError::new_err(format!(
×
523
                        "unknown cache_ttl: {other} (expected 5m, 1h, or off)"
×
524
                    )));
525
                }
526
            };
527
        }
528
        if let Some(u) = base_url {
×
529
            if !u.is_empty() {
×
530
                inner.base_url = Some(u);
×
531
            }
532
        }
533
        Ok(AskConfig { inner })
×
534
    }
535

536
    /// Build an `AskConfig` from environment variables. Reads:
537
    ///   * `SQLRITE_LLM_PROVIDER` (default: anthropic)
538
    ///   * `SQLRITE_LLM_API_KEY`
539
    ///   * `SQLRITE_LLM_MODEL` (default: claude-sonnet-4-6)
540
    ///   * `SQLRITE_LLM_MAX_TOKENS` (default: 1024)
541
    ///   * `SQLRITE_LLM_CACHE_TTL` (default: 5m)
542
    ///
543
    /// A missing API key is NOT an error here — `from_env()` returns
544
    /// a config with `api_key=None`, and the `ask()` call later raises
545
    /// the friendlier `SQLRiteError("missing API key")`.
546
    #[staticmethod]
547
    fn from_env() -> PyResult<Self> {
×
548
        Ok(AskConfig {
×
549
            inner: RustAskConfig::from_env().map_err(map_err)?,
×
550
        })
551
    }
552

553
    #[getter]
554
    fn api_key(&self) -> Option<&str> {
×
555
        self.inner.api_key.as_deref()
×
556
    }
557

558
    #[getter]
559
    fn model(&self) -> &str {
×
560
        &self.inner.model
×
561
    }
562

563
    #[getter]
564
    fn max_tokens(&self) -> u32 {
×
565
        self.inner.max_tokens
×
566
    }
567

568
    #[getter]
569
    fn cache_ttl(&self) -> &'static str {
×
570
        match self.inner.cache_ttl {
×
571
            CacheTtl::FiveMinutes => "5m",
×
572
            CacheTtl::OneHour => "1h",
×
573
            CacheTtl::Off => "off",
×
574
        }
575
    }
576

577
    #[getter]
578
    fn provider(&self) -> &'static str {
×
579
        match self.inner.provider {
×
580
            ProviderKind::Anthropic => "anthropic",
×
581
        }
582
    }
583

584
    fn __repr__(&self) -> String {
×
585
        format!(
×
586
            "AskConfig(provider={:?}, model={:?}, max_tokens={}, cache_ttl={:?}, api_key={})",
587
            self.provider(),
×
588
            self.model(),
×
589
            self.max_tokens(),
×
590
            self.cache_ttl(),
×
591
            if self.inner.api_key.is_some() {
×
592
                "<set>"
×
593
            } else {
594
                "None"
×
595
            },
596
        )
597
    }
598
}
599

600
// ---------------------------------------------------------------------------
601
// AskResponse (Phase 7g.4)
602
//
603
// What conn.ask() returns. Carries the generated SQL, the model's
604
// one-sentence rationale, and token usage. The API key is NOT in
605
// here — by design.
606

607
/// Result of a `conn.ask()` call.
608
///
609
///     resp = conn.ask("How many users?")
610
///     print(resp.sql)              # generated SQL string
611
///     print(resp.explanation)      # one-sentence rationale
612
///     print(resp.usage.input_tokens, resp.usage.cache_read_input_tokens)
613
#[pyclass]
614
struct AskResponse {
615
    #[pyo3(get)]
616
    sql: String,
617
    #[pyo3(get)]
618
    explanation: String,
619
    #[pyo3(get)]
620
    usage: AskUsage,
621
}
622

623
impl AskResponse {
624
    fn from_rust(resp: RustAskResponse) -> Self {
×
625
        AskResponse {
626
            sql: resp.sql,
×
627
            explanation: resp.explanation,
×
628
            usage: AskUsage::from_rust(resp.usage),
×
629
        }
630
    }
631
}
632

633
#[pymethods]
634
impl AskResponse {
635
    fn __repr__(&self) -> String {
×
636
        format!(
×
637
            "AskResponse(sql={:?}, explanation={:?})",
638
            self.sql, self.explanation
×
639
        )
640
    }
641
}
642

643
/// Token usage breakdown from a `conn.ask()` call. Inspect to verify
644
/// prompt-caching is actually working — if `cache_read_input_tokens`
645
/// is zero across repeated calls with the same schema, something in
646
/// the prefix is invalidating the cache.
647
#[pyclass]
648
#[derive(Clone)]
649
struct AskUsage {
650
    #[pyo3(get)]
651
    input_tokens: u64,
652
    #[pyo3(get)]
653
    output_tokens: u64,
654
    #[pyo3(get)]
655
    cache_creation_input_tokens: u64,
656
    #[pyo3(get)]
657
    cache_read_input_tokens: u64,
658
}
659

660
impl AskUsage {
661
    fn from_rust(u: Usage) -> Self {
×
662
        AskUsage {
663
            input_tokens: u.input_tokens,
×
664
            output_tokens: u.output_tokens,
×
665
            cache_creation_input_tokens: u.cache_creation_input_tokens,
×
666
            cache_read_input_tokens: u.cache_read_input_tokens,
×
667
        }
668
    }
669
}
670

671
#[pymethods]
672
impl AskUsage {
673
    fn __repr__(&self) -> String {
×
674
        format!(
×
675
            "AskUsage(input_tokens={}, output_tokens={}, \
676
             cache_creation_input_tokens={}, cache_read_input_tokens={})",
677
            self.input_tokens,
×
678
            self.output_tokens,
×
679
            self.cache_creation_input_tokens,
×
680
            self.cache_read_input_tokens
×
681
        )
682
    }
683
}
684

685
// ---------------------------------------------------------------------------
686
// Cursor
687
//
688
// Holds an optional owned `Rows` iterator from the last SELECT. Non-
689
// SELECT statements don't populate `current_rows`; iteration /
690
// fetchone / fetchall on a non-query cursor just returns empty.
691

692
#[pyclass]
693
struct Cursor {
694
    conn: Py<Connection>,
695
    // Once a SELECT runs, `current_rows` owns the row iterator we
696
    // drain via fetchone / fetchall / __next__.
697
    current_rows: Option<Rows>,
698
    // Last statement's column names, for `.description`. PEP 249
699
    // says `description` is a 7-tuple per column; we fill in only
700
    // the name and leave the rest None.
701
    description: Option<Vec<String>>,
702
    // Status string the engine emitted. Exposed for debugging /
703
    // doctests but not part of PEP 249.
704
    last_status: Option<String>,
705
}
706

707
impl Cursor {
708
    fn take_rows_for_iteration(&mut self) -> Option<&mut Rows> {
×
709
        self.current_rows.as_mut()
×
710
    }
711
}
712

713
#[pymethods]
714
impl Cursor {
715
    /// Executes a single SQL statement.
716
    ///
717
    /// `params`: reserved for a future parameter-binding
718
    /// implementation. Until Phase 5a.2 lands, passing any non-empty
719
    /// value raises `TypeError` — inline your values into the SQL
720
    /// for now (with manual escaping).
721
    #[pyo3(signature = (sql, params=None))]
722
    fn execute(&mut self, py: Python<'_>, sql: &str, params: Option<Py<PyAny>>) -> PyResult<()> {
×
723
        if let Some(p) = params.as_ref() {
×
724
            // Allow `None` and empty tuple/list for DB-API
725
            // compatibility; anything else errors.
726
            let non_empty = Python::with_gil(|py| {
×
727
                if p.is_none(py) {
×
728
                    return false;
×
729
                }
730
                if let Ok(seq) = p.bind(py).downcast::<PyTuple>() {
×
731
                    return !seq.is_empty();
×
732
                }
733
                if let Ok(seq) = p.bind(py).downcast::<PyList>() {
×
734
                    return !seq.is_empty();
×
735
                }
736
                true
×
737
            });
738
            if non_empty {
×
739
                return Err(PyTypeError::new_err(
×
740
                    "parameter binding is not yet supported — inline values into the SQL \
×
741
                     (a future Phase 5a.2 release will add real binding)",
×
742
                ));
743
            }
744
        }
745

746
        // Drive the shared connection. We detach the `Rows` iterator
747
        // from its borrow on Connection by collecting into
748
        // `OwnedRow` up front, then keep a Rows-like iterator here.
749
        let mut conn = self.conn.borrow_mut(py);
×
750
        conn.with_inner("execute", |c| {
×
751
            // Classify: is this a SELECT? If so, prepare + query and
752
            // stash the Rows iterator on `self`. Otherwise just run
753
            // it via `c.execute`.
754
            let trimmed = sql.trim_start();
×
755
            let is_query = trimmed
×
756
                .get(..6)
×
757
                .map(|s| s.eq_ignore_ascii_case("select"))
×
758
                .unwrap_or(false);
×
759

760
            if is_query {
×
NEW
761
                let stmt = c.prepare(sql).map_err(map_engine_err)?;
×
NEW
762
                let rows = stmt.query().map_err(map_engine_err)?;
×
763
                self.description = Some(rows.columns().to_vec());
×
764
                self.current_rows = Some(rows);
×
765
                self.last_status = Some("SELECT Statement prepared.".to_string());
×
766
            } else {
NEW
767
                let status = c.execute(sql).map_err(map_engine_err)?;
×
768
                self.current_rows = None;
×
769
                self.description = None;
×
770
                self.last_status = Some(status);
×
771
            }
772
            Ok(())
×
773
        })
774
    }
775

776
    /// Iterate a list of SQL statements. Each call is separate —
777
    /// this is different from SQLite's `executescript`; we keep the
778
    /// DB-API-style `executemany(sql, param_list)` signature but
779
    /// currently just ignore the param_list.
780
    #[pyo3(signature = (sql, seq_of_params=None))]
781
    fn executemany(
×
782
        &mut self,
783
        py: Python<'_>,
784
        sql: &str,
785
        seq_of_params: Option<Py<PyAny>>,
786
    ) -> PyResult<()> {
787
        if let Some(p) = seq_of_params.as_ref() {
×
788
            let n = Python::with_gil(|py| -> PyResult<usize> {
×
789
                if p.is_none(py) {
×
790
                    return Ok(0);
×
791
                }
792
                if let Ok(seq) = p.bind(py).downcast::<PyList>() {
×
793
                    return Ok(seq.len());
×
794
                }
795
                if let Ok(seq) = p.bind(py).downcast::<PyTuple>() {
×
796
                    return Ok(seq.len());
×
797
                }
798
                Err(PyTypeError::new_err(
×
799
                    "executemany expected a list or tuple of parameter sequences",
×
800
                ))
801
            })?;
802
            if n > 0 {
×
803
                return Err(PyTypeError::new_err(
×
804
                    "parameter binding is not yet supported — Phase 5a.2",
×
805
                ));
806
            }
807
        }
808
        self.execute(py, sql, None)
×
809
    }
810

811
    /// Runs several statements in one call, separated by `;`. Matches
812
    /// sqlite3's `executescript`.
813
    fn executescript(&mut self, py: Python<'_>, sql: &str) -> PyResult<()> {
×
814
        for stmt in sql.split(';') {
×
815
            let trimmed = stmt.trim();
×
816
            if trimmed.is_empty() {
×
817
                continue;
×
818
            }
819
            self.execute(py, trimmed, None)?;
×
820
        }
821
        Ok(())
×
822
    }
823

824
    /// Returns the next row as a tuple, or `None` when the query is
825
    /// exhausted. Raises if no SELECT has been run.
826
    fn fetchone(&mut self, py: Python<'_>) -> PyResult<Option<Py<PyTuple>>> {
×
827
        let Some(rows) = self.take_rows_for_iteration() else {
×
828
            return Ok(None);
×
829
        };
NEW
830
        match rows.next().map_err(map_engine_err)? {
×
831
            Some(row) => {
×
832
                let owned = row.to_owned_row();
×
833
                Ok(Some(owned_row_to_tuple(py, &owned)?))
×
834
            }
835
            None => Ok(None),
×
836
        }
837
    }
838

839
    /// Returns up to `size` remaining rows. If `size` is None,
840
    /// returns all remaining rows (== `fetchall`).
841
    #[pyo3(signature = (size=None))]
842
    fn fetchmany(&mut self, py: Python<'_>, size: Option<usize>) -> PyResult<Py<PyList>> {
×
843
        let Some(rows) = self.take_rows_for_iteration() else {
×
844
            return Ok(PyList::empty(py).into());
×
845
        };
846
        let limit = size.unwrap_or(usize::MAX);
×
847
        let mut out: Vec<Py<PyTuple>> = Vec::new();
×
848
        while out.len() < limit {
×
NEW
849
            match rows.next().map_err(map_engine_err)? {
×
850
                Some(row) => {
×
851
                    let owned = row.to_owned_row();
×
852
                    out.push(owned_row_to_tuple(py, &owned)?);
×
853
                }
854
                None => break,
×
855
            }
856
        }
857
        Ok(PyList::new(py, out)?.into())
×
858
    }
859

860
    /// Returns every remaining row as a list of tuples.
861
    fn fetchall(&mut self, py: Python<'_>) -> PyResult<Py<PyList>> {
×
862
        self.fetchmany(py, None)
×
863
    }
864

865
    /// DB-API 2.0 column metadata. Returns a list of 7-tuples with
866
    /// the column name in position 0 and None for the other fields
867
    /// (type_code, display_size, internal_size, precision, scale,
868
    /// null_ok), matching what `sqlite3.Cursor.description` returns.
869
    #[getter]
870
    fn description(&self, py: Python<'_>) -> PyResult<Option<Py<PyList>>> {
×
871
        let Some(cols) = self.description.as_ref() else {
×
872
            return Ok(None);
×
873
        };
874
        let mut out: Vec<Py<PyTuple>> = Vec::with_capacity(cols.len());
×
875
        for name in cols {
×
876
            out.push(
×
877
                PyTuple::new(
×
878
                    py,
×
879
                    [
880
                        name.into_pyobject(py)?.into_any().unbind(),
×
881
                        py.None(),
×
882
                        py.None(),
×
883
                        py.None(),
×
884
                        py.None(),
×
885
                        py.None(),
×
886
                        py.None(),
×
887
                    ],
888
                )?
889
                .into(),
×
890
            );
891
        }
892
        Ok(Some(PyList::new(py, out)?.into()))
×
893
    }
894

895
    /// `-1` per PEP 249 (we don't track affected-row counts yet).
896
    #[getter]
897
    fn rowcount(&self) -> i64 {
×
898
        -1
×
899
    }
900

901
    /// `__iter__(self)` returns self — lets `for row in cursor:`
902
    /// work via the PEP 249 iteration protocol.
903
    fn __iter__(slf: PyRefMut<'_, Self>) -> PyRefMut<'_, Self> {
×
904
        slf
×
905
    }
906

907
    /// Yields the next row as a tuple, or signals StopIteration.
908
    fn __next__(&mut self, py: Python<'_>) -> PyResult<Option<Py<PyTuple>>> {
×
909
        self.fetchone(py)
×
910
    }
911

912
    fn close(&mut self) -> PyResult<()> {
×
913
        self.current_rows = None;
×
914
        self.description = None;
×
915
        Ok(())
×
916
    }
917
}
918

919
// ---------------------------------------------------------------------------
920
// Value → Python conversions
921

922
fn value_to_pyobject(py: Python<'_>, v: &Value) -> PyResult<Py<PyAny>> {
923
    match v {
924
        Value::Integer(n) => Ok(n.into_pyobject(py)?.into_any().unbind()),
925
        Value::Real(f) => Ok(f.into_pyobject(py)?.into_any().unbind()),
926
        Value::Text(s) => Ok(s.into_pyobject(py)?.into_any().unbind()),
927
        Value::Bool(b) => {
928
            // `bool::into_pyobject` returns a Borrowed<PyBool> (Python's
929
            // True/False singletons are never owned), so clone into a
930
            // Bound before erasing the type.
931
            Ok(b.into_pyobject(py)?.to_owned().into_any().unbind())
932
        }
933
        // Phase 7a — `VECTOR(N)` columns surface to Python as a `list[float]`.
934
        // Widening f32→f64 here so Python's float (which is f64-backed)
935
        // doesn't lose information; numpy interop / array module are
936
        // future polish.
937
        Value::Vector(elements) => {
938
            let widened: Vec<f64> = elements.iter().map(|x| *x as f64).collect();
939
            Ok(widened.into_pyobject(py)?.into_any().unbind())
940
        }
941
        Value::Null => Ok(py.None()),
942
    }
943
}
944

945
fn owned_row_to_tuple(py: Python<'_>, row: &OwnedRow) -> PyResult<Py<PyTuple>> {
946
    let mut objs: Vec<Py<PyAny>> = Vec::with_capacity(row.values.len());
947
    for v in &row.values {
948
        objs.push(value_to_pyobject(py, v)?);
949
    }
950
    Ok(PyTuple::new(py, objs)?.into())
951
}
952

953
// ---------------------------------------------------------------------------
954
// Module entry point
955

956
/// The `sqlrite` Python module.
957
#[pymodule]
958
#[pyo3(name = "sqlrite")]
959
fn sqlrite_module(m: &Bound<'_, PyModule>) -> PyResult<()> {
960
    m.add("__version__", env!("CARGO_PKG_VERSION"))?;
961
    m.add("SQLRiteError", m.py().get_type::<SQLRiteError>())?;
962
    // Phase 11.7 — retryable engine errors. Inherit from
963
    // `SQLRiteError` so existing `except sqlrite.SQLRiteError`
964
    // blocks still catch them. Retry loops can branch on the
965
    // narrower class with `except sqlrite.BusyError`.
966
    m.add("BusyError", m.py().get_type::<BusyError>())?;
967
    m.add("BusySnapshotError", m.py().get_type::<BusySnapshotError>())?;
968
    m.add_function(wrap_pyfunction!(connect, m)?)?;
969
    m.add_function(wrap_pyfunction!(connect_read_only, m)?)?;
970
    m.add_class::<Connection>()?;
971
    m.add_class::<Cursor>()?;
972
    // Phase 7g.4 — natural-language → SQL surface.
973
    m.add_class::<AskConfig>()?;
974
    m.add_class::<AskResponse>()?;
975
    m.add_class::<AskUsage>()?;
976
    Ok(())
977
}
978

979
// Tests live on the Python side under `sdk/python/tests/`. A PyO3
980
// cdylib built with the `extension-module` feature doesn't link
981
// libpython, so running it as a standalone `cargo test` binary
982
// would segfault on the first Python API call — the real coverage
983
// comes from `python -m pytest sdk/python/tests/` after a
984
// `maturin develop`.
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc