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

joaoh82 / rust_sqlite / 25211457852

01 May 2026 10:39AM UTC coverage: 58.257% (-0.6%) from 58.9%
25211457852

push

github

web-flow
Phase 7g.4: Python SDK conn.ask() / ask_run() / AskConfig (#64)

PyO3 wrappers for natural-language → SQL via the engine's `sqlrite::ask`
module. Mirrors the same shape the REPL .ask command and the desktop
Ask… button use, with idiomatic Python ergonomics on top.

## Public surface

```python
import sqlrite

conn = sqlrite.connect("foo.sqlrite")

# Path 1: env var (zero config — same env as REPL/Desktop)
resp = conn.ask("How many users are over 30?")

# Path 2: explicit per-call config (overrides env)
cfg = sqlrite.AskConfig(
    api_key="sk-ant-...",
    model="claude-haiku-4-5",
    max_tokens=512,
    cache_ttl="1h",
)
resp = conn.ask("How many users?", cfg)

# Path 3: per-connection config (set once, reuse)
conn.set_ask_config(cfg)
resp = conn.ask("How many users?")
resp2 = conn.ask("count by age")

# Convenience: generate + execute in one call
rows = conn.ask_run("list active users").fetchall()

# Inspect the response
print(resp.sql)               # str
print(resp.explanation)       # str
print(resp.usage.input_tokens, resp.usage.cache_read_input_tokens)
```

## What's new

  * `sqlrite.AskConfig(provider=..., api_key=..., model=...,
    max_tokens=..., cache_ttl=..., base_url=...)` — constructor +
    `AskConfig.from_env()` static method.
  * `sqlrite.AskResponse` — `.sql`, `.explanation`, `.usage`.
  * `sqlrite.AskUsage` — `.input_tokens`, `.output_tokens`,
    `.cache_creation_input_tokens`, `.cache_read_input_tokens`.
  * `Connection.ask(question, config=None)` — generates SQL,
    does NOT execute. Returns `AskResponse`.
  * `Connection.ask_run(question, config=None)` — generates AND
    executes; returns a `Cursor` you can `.fetchall()` / iterate.
    Empty SQL response (model declined) raises with the model's
    explanation rather than executing the empty string.
  * `Connection.set_ask_config(config)` — per-connection config
    storage. Pass None to clear.

## Config resolution (precedence: high → low)

  1. Per-call `config` arg on `a... (continued)

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

5496 of 9434 relevant lines covered (58.26%)

1.19 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
fn map_err<E: std::fmt::Display>(e: E) -> PyErr {
74
    SQLRiteError::new_err(e.to_string())
75
}
76

77
// ---------------------------------------------------------------------------
78
// Connection
79
//
80
// Wraps `RustConnection` behind a `Mutex` so Python callers can share
81
// a connection between threads (PyO3 requires `#[pyclass]` types to
82
// be `Send + Sync`). The Rust `Connection` isn't `Sync`, so the
83
// Mutex is the straightforward fix — callers still need to serialize
84
// access, but they won't get a panic.
85

86
/// Open a connection to a SQLRite database file. Use `:memory:` to
87
/// get an in-memory database (matching sqlite3 convention).
88
#[pyfunction]
89
#[pyo3(text_signature = "(database, /)")]
90
fn connect(database: &str) -> PyResult<Connection> {
91
    let rust_conn = if database == ":memory:" {
92
        RustConnection::open_in_memory().map_err(map_err)?
93
    } else {
94
        RustConnection::open(PathBuf::from(database)).map_err(map_err)?
95
    };
96
    Ok(Connection {
97
        inner: Some(Mutex::new(rust_conn)),
98
        ask_config: None,
99
    })
100
}
101

102
/// Open a database file read-only (shared OS lock; coexists with
103
/// other read-only openers, excluded by any writer).
104
#[pyfunction]
105
#[pyo3(text_signature = "(database, /)")]
106
fn connect_read_only(database: &str) -> PyResult<Connection> {
107
    let rust_conn = RustConnection::open_read_only(PathBuf::from(database)).map_err(map_err)?;
108
    Ok(Connection {
109
        inner: Some(Mutex::new(rust_conn)),
110
        ask_config: None,
111
    })
112
}
113

114
/// A database connection. Obtain one via [`connect`].
115
#[pyclass]
116
struct Connection {
117
    // `Option<_>` so `close()` can explicitly drop the inner
118
    // connection (and release the OS-level file lock) without
119
    // waiting for GC. Operations on a closed connection raise.
120
    inner: Option<Mutex<RustConnection>>,
121
    // Phase 7g.4 — per-connection ask() config. Set via
122
    // `set_ask_config()` or passed per-call to `ask()` / `ask_run()`.
123
    // When None, `ask()` falls back to `AskConfig::from_env()` so
124
    // env-only consumers get the zero-config experience matching the
125
    // REPL and Desktop surfaces.
126
    ask_config: Option<RustAskConfig>,
127
}
128

129
impl Connection {
130
    fn with_inner<F, T>(&mut self, op: &str, f: F) -> PyResult<T>
131
    where
132
        F: FnOnce(&mut RustConnection) -> PyResult<T>,
133
    {
134
        let guard = self
×
135
            .inner
×
136
            .as_ref()
×
137
            .ok_or_else(|| SQLRiteError::new_err(format!("cannot {op}: connection is closed")))?;
×
138
        let mut locked = guard
×
139
            .lock()
×
140
            .map_err(|_| SQLRiteError::new_err("connection mutex poisoned"))?;
×
141
        f(&mut locked)
×
142
    }
143
}
144

145
#[pymethods]
146
impl Connection {
147
    /// Returns a new cursor. Cursors don't share row state, so
148
    /// multiple cursors against the same connection can iterate
149
    /// independently.
150
    fn cursor(slf: Py<Self>) -> Cursor {
×
151
        Cursor {
152
            conn: slf,
153
            current_rows: None,
154
            description: None,
155
            last_status: None,
156
        }
157
    }
158

159
    /// Convenience shorthand for `cursor().execute(sql)`. Returns
160
    /// the cursor so you can chain `.fetchall()` off it.
161
    #[pyo3(signature = (sql, params=None))]
162
    fn execute(
×
163
        slf: Py<Self>,
164
        py: Python<'_>,
165
        sql: &str,
166
        params: Option<Py<PyAny>>,
167
    ) -> PyResult<Cursor> {
168
        let mut cur = Self::cursor(slf);
×
169
        cur.execute(py, sql, params)?;
×
170
        Ok(cur)
×
171
    }
172

173
    /// Commits the current transaction. Equivalent to `cursor().execute("COMMIT")`,
174
    /// but a no-op if no transaction is open (matching the DB-API's
175
    /// expectation that `commit()` is always safe to call).
176
    fn commit(&mut self) -> PyResult<()> {
×
177
        self.with_inner("commit", |c| {
×
178
            if c.in_transaction() {
×
179
                c.execute("COMMIT").map(|_| ()).map_err(map_err)?;
×
180
            }
181
            Ok(())
×
182
        })
183
    }
184

185
    /// Rolls back the current transaction. No-op if no transaction
186
    /// is open (again: DB-API expectation).
187
    fn rollback(&mut self) -> PyResult<()> {
×
188
        self.with_inner("rollback", |c| {
×
189
            if c.in_transaction() {
×
190
                c.execute("ROLLBACK").map(|_| ()).map_err(map_err)?;
×
191
            }
192
            Ok(())
×
193
        })
194
    }
195

196
    /// Closes the connection and releases the OS file lock. Safe to
197
    /// call multiple times; a closed connection raises `SQLRiteError`
198
    /// on any subsequent operation.
199
    fn close(&mut self) -> PyResult<()> {
×
200
        self.inner = None;
×
201
        Ok(())
×
202
    }
203

204
    /// Context-manager entry — returns self unchanged.
205
    fn __enter__(slf: PyRefMut<'_, Self>) -> PyRefMut<'_, Self> {
×
206
        slf
×
207
    }
208

209
    /// Context-manager exit — commits on clean exit, rolls back on
210
    /// exception (mirrors the stdlib `sqlite3` behavior), then closes.
211
    #[pyo3(signature = (exc_type=None, _exc_value=None, _traceback=None))]
212
    fn __exit__(
×
213
        &mut self,
214
        exc_type: Option<Py<PyAny>>,
215
        _exc_value: Option<Py<PyAny>>,
216
        _traceback: Option<Py<PyAny>>,
217
    ) -> PyResult<bool> {
218
        if self.inner.is_some() {
×
219
            if exc_type.is_some() {
×
220
                self.rollback()?;
×
221
            } else {
222
                self.commit()?;
×
223
            }
224
        }
225
        self.close()?;
×
226
        // Return False to signal "don't suppress any exception the
227
        // with-block may have raised".
228
        Ok(false)
229
    }
230

231
    /// `True` while a `BEGIN … COMMIT/ROLLBACK` block is open.
232
    #[getter]
233
    fn in_transaction(&self) -> PyResult<bool> {
×
234
        let guard = self
×
235
            .inner
×
236
            .as_ref()
×
237
            .ok_or_else(|| SQLRiteError::new_err("connection is closed"))?;
×
238
        let locked = guard
×
239
            .lock()
×
240
            .map_err(|_| SQLRiteError::new_err("connection mutex poisoned"))?;
×
241
        Ok(locked.in_transaction())
×
242
    }
243

244
    /// `True` if this connection was opened read-only.
245
    #[getter]
246
    fn read_only(&self) -> PyResult<bool> {
×
247
        let guard = self
×
248
            .inner
×
249
            .as_ref()
×
250
            .ok_or_else(|| SQLRiteError::new_err("connection is closed"))?;
×
251
        let locked = guard
×
252
            .lock()
×
253
            .map_err(|_| SQLRiteError::new_err("connection mutex poisoned"))?;
×
254
        Ok(locked.is_read_only())
×
255
    }
256

257
    // ---------------------------------------------------------------
258
    // Phase 7g.4 — natural-language → SQL.
259
    //
260
    // Three entry points:
261
    //   * `set_ask_config(...)` stores a config on the connection so
262
    //     subsequent `ask()` calls reuse it without reconfiguring.
263
    //   * `ask(question, config=None)` generates SQL — does NOT execute.
264
    //     Returns an `AskResponse` with `.sql` / `.explanation` / `.usage`.
265
    //   * `ask_run(question, config=None)` is the convenience that
266
    //     calls `ask()` then `execute()` on the generated SQL,
267
    //     returning a `Cursor` you can `.fetchall()` from.
268
    //
269
    // Config resolution (when `config` arg is None):
270
    //   1. The per-connection `ask_config` if set via set_ask_config()
271
    //   2. AskConfig::from_env() — reads SQLRITE_LLM_API_KEY etc.
272
    //   3. Built-in defaults (Sonnet 4.6, max_tokens 1024, 5-min cache TTL)
273
    //
274
    // The schema dump + LLM HTTP call run entirely on the Rust side
275
    // (no GIL re-acquisition for the duration of the network round-
276
    // trip). API key is read from the AskConfig — never logged, never
277
    // serialized into AskResponse, never crosses the FFI boundary
278
    // back to Python (we only return sql/explanation/usage).
279

280
    /// Stash an `AskConfig` on the connection. Subsequent `ask()` and
281
    /// `ask_run()` calls without an explicit config use this. Pass
282
    /// `None` to clear and fall back to env/defaults.
283
    #[pyo3(signature = (config))]
NEW
284
    fn set_ask_config(&mut self, config: Option<&AskConfig>) {
×
NEW
285
        self.ask_config = config.map(|c| c.inner.clone());
×
286
    }
287

288
    /// Generate SQL from a natural-language question. Does **not**
289
    /// execute — call `cur.execute(resp.sql)` (or `ask_run()` for
290
    /// one-shot). Returns an `AskResponse` with `.sql`,
291
    /// `.explanation`, and `.usage`.
292
    ///
293
    /// **GIL handling.** Releases the GIL for the duration of the
294
    /// HTTP call. Without this, a Python-side HTTP mock server (or
295
    /// any other thread) can't run concurrently with `ask()` — they'd
296
    /// sit blocked waiting for the GIL while ureq waited for them
297
    /// to respond. Same threading rule as the rest of PyO3 land:
298
    /// hold the GIL only for Python-data work, release it for I/O.
299
    #[pyo3(signature = (question, config=None))]
NEW
300
    fn ask(
×
301
        &mut self,
302
        py: Python<'_>,
303
        question: &str,
304
        config: Option<&AskConfig>,
305
    ) -> PyResult<AskResponse> {
NEW
306
        let resolved = self.resolve_ask_config(config)?;
×
307
        // Borrow the engine connection for schema dump + LLM call.
308
        // `ask_with_database` takes &Database (read-only), so we
309
        // hold the lock for the duration of one call.
NEW
310
        let inner = self
×
NEW
311
            .inner
×
NEW
312
            .as_ref()
×
NEW
313
            .ok_or_else(|| SQLRiteError::new_err("cannot ask: connection is closed"))?;
×
314
        // We can't take the mutex inside py.allow_threads (the
315
        // borrow on `self` needs the GIL released semantics it
316
        // doesn't have access to), so we lock first, then release
317
        // the GIL across the network call. The lock guard lives
318
        // through the allow_threads block — that's fine, it's a
319
        // pure-Rust mutex with no Python state.
NEW
320
        let locked = inner
×
NEW
321
            .lock()
×
NEW
322
            .map_err(|_| SQLRiteError::new_err("connection mutex poisoned"))?;
×
NEW
323
        let resp = py
×
NEW
324
            .allow_threads(|| ask_with_database(locked.database(), question, &resolved))
×
NEW
325
            .map_err(map_err)?;
×
NEW
326
        Ok(AskResponse::from_rust(resp))
×
327
    }
328

329
    /// Generate SQL **and execute it**. Returns a `Cursor` with the
330
    /// results — call `.fetchall()` / `.fetchone()` / iterate. Errors
331
    /// the same way `ask()` does on generation failure, and the same
332
    /// way `cursor.execute()` does on bad-SQL execution failure (the
333
    /// model produced something the engine can't run).
334
    ///
335
    /// Convenience for one-shot scripts and notebooks. For interactive
336
    /// REPL-style use, prefer `ask()` + manual review (the model can
337
    /// be wrong; auto-execute hides that).
338
    #[pyo3(signature = (question, config=None))]
NEW
339
    fn ask_run(
×
340
        slf: Py<Self>,
341
        py: Python<'_>,
342
        question: &str,
343
        config: Option<&AskConfig>,
344
    ) -> PyResult<Cursor> {
NEW
345
        let resp = {
×
NEW
346
            let mut conn = slf.borrow_mut(py);
×
NEW
347
            conn.ask(py, question, config)?
×
348
        };
NEW
349
        if resp.sql.trim().is_empty() {
×
NEW
350
            return Err(SQLRiteError::new_err(format!(
×
NEW
351
                "model declined to generate SQL: {}",
×
NEW
352
                if resp.explanation.is_empty() {
×
NEW
353
                    "(no explanation)"
×
354
                } else {
NEW
355
                    resp.explanation.as_str()
×
356
                }
357
            )));
358
        }
NEW
359
        Self::execute(slf, py, &resp.sql, None)
×
360
    }
361
}
362

363
impl Connection {
364
    /// Resolve the effective AskConfig for an `ask()` / `ask_run()`
365
    /// call: per-call config wins, then per-connection, then env, then
366
    /// defaults. See the comment block above the methods for the
367
    /// rationale.
NEW
368
    fn resolve_ask_config(&self, per_call: Option<&AskConfig>) -> PyResult<RustAskConfig> {
×
NEW
369
        if let Some(cfg) = per_call {
×
NEW
370
            return Ok(cfg.inner.clone());
×
371
        }
NEW
372
        if let Some(cfg) = &self.ask_config {
×
NEW
373
            return Ok(cfg.clone());
×
374
        }
NEW
375
        RustAskConfig::from_env().map_err(map_err)
×
376
    }
377
}
378

379
// ---------------------------------------------------------------------------
380
// AskConfig (Phase 7g.4)
381
//
382
// Mirrors the Rust AskConfig but with Python-friendly attribute
383
// access. Constructed via `AskConfig(api_key=..., model=...)` or
384
// `AskConfig.from_env()`. Stored on the connection via
385
// `conn.set_ask_config(cfg)` or passed per-call to `conn.ask(q, cfg)`.
386

387
/// LLM call configuration for `Connection.ask()` and `ask_run()`.
388
///
389
/// Construct from kwargs:
390
///
391
///     cfg = sqlrite.AskConfig(
392
///         api_key="sk-ant-...",
393
///         model="claude-sonnet-4-6",
394
///         max_tokens=1024,
395
///         cache_ttl="5m",
396
///     )
397
///
398
/// Or from environment vars (`SQLRITE_LLM_API_KEY` etc.):
399
///
400
///     cfg = sqlrite.AskConfig.from_env()
401
///
402
/// Stored on the connection so subsequent `ask()` calls reuse it:
403
///
404
///     conn.set_ask_config(cfg)
405
///     resp = conn.ask("How many users?")          # uses cfg
406
///
407
/// Or passed per-call (overrides any per-connection config):
408
///
409
///     resp = conn.ask("How many users?", cfg)
410
#[pyclass]
411
#[derive(Clone)]
412
struct AskConfig {
413
    inner: RustAskConfig,
414
}
415

416
#[pymethods]
417
impl AskConfig {
418
    /// Construct from kwargs. Any kwarg left unset uses the same
419
    /// default the Rust side does (provider=anthropic, model=
420
    /// `claude-sonnet-4-6`, max_tokens=1024, cache_ttl="5m").
421
    ///
422
    /// `provider`: `"anthropic"` (only currently supported).
423
    /// `cache_ttl`: `"5m"` (default), `"1h"`, or `"off"`.
424
    /// `base_url`: override the API base URL — production callers
425
    ///   leave this None; tests point it at a localhost mock.
426
    #[new]
427
    #[pyo3(signature = (
428
        provider="anthropic",
429
        api_key=None,
430
        model=None,
431
        max_tokens=None,
432
        cache_ttl=None,
433
        base_url=None,
434
    ))]
NEW
435
    fn new(
×
436
        provider: &str,
437
        api_key: Option<String>,
438
        model: Option<String>,
439
        max_tokens: Option<u32>,
440
        cache_ttl: Option<&str>,
441
        base_url: Option<String>,
442
    ) -> PyResult<Self> {
NEW
443
        let mut inner = RustAskConfig::default();
×
NEW
444
        inner.provider = match provider.to_ascii_lowercase().as_str() {
×
NEW
445
            "anthropic" => ProviderKind::Anthropic,
×
NEW
446
            other => {
×
NEW
447
                return Err(SQLRiteError::new_err(format!(
×
NEW
448
                    "unknown provider: {other} (supported: anthropic)"
×
449
                )));
450
            }
451
        };
NEW
452
        if let Some(k) = api_key {
×
NEW
453
            if !k.is_empty() {
×
NEW
454
                inner.api_key = Some(k);
×
455
            }
456
        }
NEW
457
        if let Some(m) = model {
×
NEW
458
            if !m.is_empty() {
×
NEW
459
                inner.model = m;
×
460
            }
461
        }
NEW
462
        if let Some(t) = max_tokens {
×
NEW
463
            inner.max_tokens = t;
×
464
        }
NEW
465
        if let Some(c) = cache_ttl {
×
NEW
466
            inner.cache_ttl = match c.to_ascii_lowercase().as_str() {
×
NEW
467
                "5m" | "5min" | "5minutes" => CacheTtl::FiveMinutes,
×
NEW
468
                "1h" | "1hr" | "1hour" => CacheTtl::OneHour,
×
NEW
469
                "off" | "none" | "disabled" => CacheTtl::Off,
×
NEW
470
                other => {
×
NEW
471
                    return Err(SQLRiteError::new_err(format!(
×
NEW
472
                        "unknown cache_ttl: {other} (expected 5m, 1h, or off)"
×
473
                    )));
474
                }
475
            };
476
        }
NEW
477
        if let Some(u) = base_url {
×
NEW
478
            if !u.is_empty() {
×
NEW
479
                inner.base_url = Some(u);
×
480
            }
481
        }
NEW
482
        Ok(AskConfig { inner })
×
483
    }
484

485
    /// Build an `AskConfig` from environment variables. Reads:
486
    ///   * `SQLRITE_LLM_PROVIDER` (default: anthropic)
487
    ///   * `SQLRITE_LLM_API_KEY`
488
    ///   * `SQLRITE_LLM_MODEL` (default: claude-sonnet-4-6)
489
    ///   * `SQLRITE_LLM_MAX_TOKENS` (default: 1024)
490
    ///   * `SQLRITE_LLM_CACHE_TTL` (default: 5m)
491
    ///
492
    /// A missing API key is NOT an error here — `from_env()` returns
493
    /// a config with `api_key=None`, and the `ask()` call later raises
494
    /// the friendlier `SQLRiteError("missing API key")`.
495
    #[staticmethod]
NEW
496
    fn from_env() -> PyResult<Self> {
×
NEW
497
        Ok(AskConfig {
×
NEW
498
            inner: RustAskConfig::from_env().map_err(map_err)?,
×
499
        })
500
    }
501

502
    #[getter]
NEW
503
    fn api_key(&self) -> Option<&str> {
×
NEW
504
        self.inner.api_key.as_deref()
×
505
    }
506

507
    #[getter]
NEW
508
    fn model(&self) -> &str {
×
NEW
509
        &self.inner.model
×
510
    }
511

512
    #[getter]
NEW
513
    fn max_tokens(&self) -> u32 {
×
NEW
514
        self.inner.max_tokens
×
515
    }
516

517
    #[getter]
NEW
518
    fn cache_ttl(&self) -> &'static str {
×
NEW
519
        match self.inner.cache_ttl {
×
NEW
520
            CacheTtl::FiveMinutes => "5m",
×
NEW
521
            CacheTtl::OneHour => "1h",
×
NEW
522
            CacheTtl::Off => "off",
×
523
        }
524
    }
525

526
    #[getter]
NEW
527
    fn provider(&self) -> &'static str {
×
NEW
528
        match self.inner.provider {
×
NEW
529
            ProviderKind::Anthropic => "anthropic",
×
530
        }
531
    }
532

NEW
533
    fn __repr__(&self) -> String {
×
NEW
534
        format!(
×
535
            "AskConfig(provider={:?}, model={:?}, max_tokens={}, cache_ttl={:?}, api_key={})",
NEW
536
            self.provider(),
×
NEW
537
            self.model(),
×
NEW
538
            self.max_tokens(),
×
NEW
539
            self.cache_ttl(),
×
NEW
540
            if self.inner.api_key.is_some() {
×
NEW
541
                "<set>"
×
542
            } else {
NEW
543
                "None"
×
544
            },
545
        )
546
    }
547
}
548

549
// ---------------------------------------------------------------------------
550
// AskResponse (Phase 7g.4)
551
//
552
// What conn.ask() returns. Carries the generated SQL, the model's
553
// one-sentence rationale, and token usage. The API key is NOT in
554
// here — by design.
555

556
/// Result of a `conn.ask()` call.
557
///
558
///     resp = conn.ask("How many users?")
559
///     print(resp.sql)              # generated SQL string
560
///     print(resp.explanation)      # one-sentence rationale
561
///     print(resp.usage.input_tokens, resp.usage.cache_read_input_tokens)
562
#[pyclass]
563
struct AskResponse {
564
    #[pyo3(get)]
565
    sql: String,
566
    #[pyo3(get)]
567
    explanation: String,
568
    #[pyo3(get)]
569
    usage: AskUsage,
570
}
571

572
impl AskResponse {
NEW
573
    fn from_rust(resp: RustAskResponse) -> Self {
×
574
        AskResponse {
NEW
575
            sql: resp.sql,
×
NEW
576
            explanation: resp.explanation,
×
NEW
577
            usage: AskUsage::from_rust(resp.usage),
×
578
        }
579
    }
580
}
581

582
#[pymethods]
583
impl AskResponse {
NEW
584
    fn __repr__(&self) -> String {
×
NEW
585
        format!(
×
586
            "AskResponse(sql={:?}, explanation={:?})",
NEW
587
            self.sql, self.explanation
×
588
        )
589
    }
590
}
591

592
/// Token usage breakdown from a `conn.ask()` call. Inspect to verify
593
/// prompt-caching is actually working — if `cache_read_input_tokens`
594
/// is zero across repeated calls with the same schema, something in
595
/// the prefix is invalidating the cache.
596
#[pyclass]
597
#[derive(Clone)]
598
struct AskUsage {
599
    #[pyo3(get)]
600
    input_tokens: u64,
601
    #[pyo3(get)]
602
    output_tokens: u64,
603
    #[pyo3(get)]
604
    cache_creation_input_tokens: u64,
605
    #[pyo3(get)]
606
    cache_read_input_tokens: u64,
607
}
608

609
impl AskUsage {
NEW
610
    fn from_rust(u: Usage) -> Self {
×
611
        AskUsage {
NEW
612
            input_tokens: u.input_tokens,
×
NEW
613
            output_tokens: u.output_tokens,
×
NEW
614
            cache_creation_input_tokens: u.cache_creation_input_tokens,
×
NEW
615
            cache_read_input_tokens: u.cache_read_input_tokens,
×
616
        }
617
    }
618
}
619

620
#[pymethods]
621
impl AskUsage {
NEW
622
    fn __repr__(&self) -> String {
×
NEW
623
        format!(
×
624
            "AskUsage(input_tokens={}, output_tokens={}, \
625
             cache_creation_input_tokens={}, cache_read_input_tokens={})",
NEW
626
            self.input_tokens,
×
NEW
627
            self.output_tokens,
×
NEW
628
            self.cache_creation_input_tokens,
×
NEW
629
            self.cache_read_input_tokens
×
630
        )
631
    }
632
}
633

634
// ---------------------------------------------------------------------------
635
// Cursor
636
//
637
// Holds an optional owned `Rows` iterator from the last SELECT. Non-
638
// SELECT statements don't populate `current_rows`; iteration /
639
// fetchone / fetchall on a non-query cursor just returns empty.
640

641
#[pyclass]
642
struct Cursor {
643
    conn: Py<Connection>,
644
    // Once a SELECT runs, `current_rows` owns the row iterator we
645
    // drain via fetchone / fetchall / __next__.
646
    current_rows: Option<Rows>,
647
    // Last statement's column names, for `.description`. PEP 249
648
    // says `description` is a 7-tuple per column; we fill in only
649
    // the name and leave the rest None.
650
    description: Option<Vec<String>>,
651
    // Status string the engine emitted. Exposed for debugging /
652
    // doctests but not part of PEP 249.
653
    last_status: Option<String>,
654
}
655

656
impl Cursor {
657
    fn take_rows_for_iteration(&mut self) -> Option<&mut Rows> {
×
658
        self.current_rows.as_mut()
×
659
    }
660
}
661

662
#[pymethods]
663
impl Cursor {
664
    /// Executes a single SQL statement.
665
    ///
666
    /// `params`: reserved for a future parameter-binding
667
    /// implementation. Until Phase 5a.2 lands, passing any non-empty
668
    /// value raises `TypeError` — inline your values into the SQL
669
    /// for now (with manual escaping).
670
    #[pyo3(signature = (sql, params=None))]
671
    fn execute(&mut self, py: Python<'_>, sql: &str, params: Option<Py<PyAny>>) -> PyResult<()> {
×
672
        if let Some(p) = params.as_ref() {
×
673
            // Allow `None` and empty tuple/list for DB-API
674
            // compatibility; anything else errors.
675
            let non_empty = Python::with_gil(|py| {
×
676
                if p.is_none(py) {
×
677
                    return false;
×
678
                }
679
                if let Ok(seq) = p.bind(py).downcast::<PyTuple>() {
×
680
                    return !seq.is_empty();
×
681
                }
682
                if let Ok(seq) = p.bind(py).downcast::<PyList>() {
×
683
                    return !seq.is_empty();
×
684
                }
685
                true
×
686
            });
687
            if non_empty {
×
688
                return Err(PyTypeError::new_err(
×
689
                    "parameter binding is not yet supported — inline values into the SQL \
×
690
                     (a future Phase 5a.2 release will add real binding)",
×
691
                ));
692
            }
693
        }
694

695
        // Drive the shared connection. We detach the `Rows` iterator
696
        // from its borrow on Connection by collecting into
697
        // `OwnedRow` up front, then keep a Rows-like iterator here.
698
        let mut conn = self.conn.borrow_mut(py);
×
699
        conn.with_inner("execute", |c| {
×
700
            // Classify: is this a SELECT? If so, prepare + query and
701
            // stash the Rows iterator on `self`. Otherwise just run
702
            // it via `c.execute`.
703
            let trimmed = sql.trim_start();
×
704
            let is_query = trimmed
×
705
                .get(..6)
×
706
                .map(|s| s.eq_ignore_ascii_case("select"))
×
707
                .unwrap_or(false);
×
708

709
            if is_query {
×
710
                let stmt = c.prepare(sql).map_err(map_err)?;
×
711
                let rows = stmt.query().map_err(map_err)?;
×
712
                self.description = Some(rows.columns().to_vec());
×
713
                self.current_rows = Some(rows);
×
714
                self.last_status = Some("SELECT Statement prepared.".to_string());
×
715
            } else {
716
                let status = c.execute(sql).map_err(map_err)?;
×
717
                self.current_rows = None;
×
718
                self.description = None;
×
719
                self.last_status = Some(status);
×
720
            }
721
            Ok(())
×
722
        })
723
    }
724

725
    /// Iterate a list of SQL statements. Each call is separate —
726
    /// this is different from SQLite's `executescript`; we keep the
727
    /// DB-API-style `executemany(sql, param_list)` signature but
728
    /// currently just ignore the param_list.
729
    #[pyo3(signature = (sql, seq_of_params=None))]
730
    fn executemany(
×
731
        &mut self,
732
        py: Python<'_>,
733
        sql: &str,
734
        seq_of_params: Option<Py<PyAny>>,
735
    ) -> PyResult<()> {
736
        if let Some(p) = seq_of_params.as_ref() {
×
737
            let n = Python::with_gil(|py| -> PyResult<usize> {
×
738
                if p.is_none(py) {
×
739
                    return Ok(0);
×
740
                }
741
                if let Ok(seq) = p.bind(py).downcast::<PyList>() {
×
742
                    return Ok(seq.len());
×
743
                }
744
                if let Ok(seq) = p.bind(py).downcast::<PyTuple>() {
×
745
                    return Ok(seq.len());
×
746
                }
747
                Err(PyTypeError::new_err(
×
748
                    "executemany expected a list or tuple of parameter sequences",
×
749
                ))
750
            })?;
751
            if n > 0 {
×
752
                return Err(PyTypeError::new_err(
×
753
                    "parameter binding is not yet supported — Phase 5a.2",
×
754
                ));
755
            }
756
        }
757
        self.execute(py, sql, None)
×
758
    }
759

760
    /// Runs several statements in one call, separated by `;`. Matches
761
    /// sqlite3's `executescript`.
762
    fn executescript(&mut self, py: Python<'_>, sql: &str) -> PyResult<()> {
×
763
        for stmt in sql.split(';') {
×
764
            let trimmed = stmt.trim();
×
765
            if trimmed.is_empty() {
×
766
                continue;
×
767
            }
768
            self.execute(py, trimmed, None)?;
×
769
        }
770
        Ok(())
×
771
    }
772

773
    /// Returns the next row as a tuple, or `None` when the query is
774
    /// exhausted. Raises if no SELECT has been run.
775
    fn fetchone(&mut self, py: Python<'_>) -> PyResult<Option<Py<PyTuple>>> {
×
776
        let Some(rows) = self.take_rows_for_iteration() else {
×
777
            return Ok(None);
×
778
        };
779
        match rows.next().map_err(map_err)? {
×
780
            Some(row) => {
×
781
                let owned = row.to_owned_row();
×
782
                Ok(Some(owned_row_to_tuple(py, &owned)?))
×
783
            }
784
            None => Ok(None),
×
785
        }
786
    }
787

788
    /// Returns up to `size` remaining rows. If `size` is None,
789
    /// returns all remaining rows (== `fetchall`).
790
    #[pyo3(signature = (size=None))]
791
    fn fetchmany(&mut self, py: Python<'_>, size: Option<usize>) -> PyResult<Py<PyList>> {
×
792
        let Some(rows) = self.take_rows_for_iteration() else {
×
793
            return Ok(PyList::empty(py).into());
×
794
        };
795
        let limit = size.unwrap_or(usize::MAX);
×
796
        let mut out: Vec<Py<PyTuple>> = Vec::new();
×
797
        while out.len() < limit {
×
798
            match rows.next().map_err(map_err)? {
×
799
                Some(row) => {
×
800
                    let owned = row.to_owned_row();
×
801
                    out.push(owned_row_to_tuple(py, &owned)?);
×
802
                }
803
                None => break,
×
804
            }
805
        }
806
        Ok(PyList::new(py, out)?.into())
×
807
    }
808

809
    /// Returns every remaining row as a list of tuples.
810
    fn fetchall(&mut self, py: Python<'_>) -> PyResult<Py<PyList>> {
×
811
        self.fetchmany(py, None)
×
812
    }
813

814
    /// DB-API 2.0 column metadata. Returns a list of 7-tuples with
815
    /// the column name in position 0 and None for the other fields
816
    /// (type_code, display_size, internal_size, precision, scale,
817
    /// null_ok), matching what `sqlite3.Cursor.description` returns.
818
    #[getter]
819
    fn description(&self, py: Python<'_>) -> PyResult<Option<Py<PyList>>> {
×
820
        let Some(cols) = self.description.as_ref() else {
×
821
            return Ok(None);
×
822
        };
823
        let mut out: Vec<Py<PyTuple>> = Vec::with_capacity(cols.len());
×
824
        for name in cols {
×
825
            out.push(
×
826
                PyTuple::new(
×
827
                    py,
×
828
                    [
829
                        name.into_pyobject(py)?.into_any().unbind(),
×
830
                        py.None(),
×
831
                        py.None(),
×
832
                        py.None(),
×
833
                        py.None(),
×
834
                        py.None(),
×
835
                        py.None(),
×
836
                    ],
837
                )?
838
                .into(),
×
839
            );
840
        }
841
        Ok(Some(PyList::new(py, out)?.into()))
×
842
    }
843

844
    /// `-1` per PEP 249 (we don't track affected-row counts yet).
845
    #[getter]
846
    fn rowcount(&self) -> i64 {
×
847
        -1
×
848
    }
849

850
    /// `__iter__(self)` returns self — lets `for row in cursor:`
851
    /// work via the PEP 249 iteration protocol.
852
    fn __iter__(slf: PyRefMut<'_, Self>) -> PyRefMut<'_, Self> {
×
853
        slf
×
854
    }
855

856
    /// Yields the next row as a tuple, or signals StopIteration.
857
    fn __next__(&mut self, py: Python<'_>) -> PyResult<Option<Py<PyTuple>>> {
×
858
        self.fetchone(py)
×
859
    }
860

861
    fn close(&mut self) -> PyResult<()> {
×
862
        self.current_rows = None;
×
863
        self.description = None;
×
864
        Ok(())
×
865
    }
866
}
867

868
// ---------------------------------------------------------------------------
869
// Value → Python conversions
870

871
fn value_to_pyobject(py: Python<'_>, v: &Value) -> PyResult<Py<PyAny>> {
872
    match v {
873
        Value::Integer(n) => Ok(n.into_pyobject(py)?.into_any().unbind()),
874
        Value::Real(f) => Ok(f.into_pyobject(py)?.into_any().unbind()),
875
        Value::Text(s) => Ok(s.into_pyobject(py)?.into_any().unbind()),
876
        Value::Bool(b) => {
877
            // `bool::into_pyobject` returns a Borrowed<PyBool> (Python's
878
            // True/False singletons are never owned), so clone into a
879
            // Bound before erasing the type.
880
            Ok(b.into_pyobject(py)?.to_owned().into_any().unbind())
881
        }
882
        // Phase 7a — `VECTOR(N)` columns surface to Python as a `list[float]`.
883
        // Widening f32→f64 here so Python's float (which is f64-backed)
884
        // doesn't lose information; numpy interop / array module are
885
        // future polish.
886
        Value::Vector(elements) => {
887
            let widened: Vec<f64> = elements.iter().map(|x| *x as f64).collect();
888
            Ok(widened.into_pyobject(py)?.into_any().unbind())
889
        }
890
        Value::Null => Ok(py.None()),
891
    }
892
}
893

894
fn owned_row_to_tuple(py: Python<'_>, row: &OwnedRow) -> PyResult<Py<PyTuple>> {
895
    let mut objs: Vec<Py<PyAny>> = Vec::with_capacity(row.values.len());
896
    for v in &row.values {
897
        objs.push(value_to_pyobject(py, v)?);
898
    }
899
    Ok(PyTuple::new(py, objs)?.into())
900
}
901

902
// ---------------------------------------------------------------------------
903
// Module entry point
904

905
/// The `sqlrite` Python module.
906
#[pymodule]
907
#[pyo3(name = "sqlrite")]
908
fn sqlrite_module(m: &Bound<'_, PyModule>) -> PyResult<()> {
909
    m.add("__version__", env!("CARGO_PKG_VERSION"))?;
910
    m.add("SQLRiteError", m.py().get_type::<SQLRiteError>())?;
911
    m.add_function(wrap_pyfunction!(connect, m)?)?;
912
    m.add_function(wrap_pyfunction!(connect_read_only, m)?)?;
913
    m.add_class::<Connection>()?;
914
    m.add_class::<Cursor>()?;
915
    // Phase 7g.4 — natural-language → SQL surface.
916
    m.add_class::<AskConfig>()?;
917
    m.add_class::<AskResponse>()?;
918
    m.add_class::<AskUsage>()?;
919
    Ok(())
920
}
921

922
// Tests live on the Python side under `sdk/python/tests/`. A PyO3
923
// cdylib built with the `extension-module` feature doesn't link
924
// libpython, so running it as a standalone `cargo test` binary
925
// would segfault on the first Python API call — the real coverage
926
// comes from `python -m pytest sdk/python/tests/` after a
927
// `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