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

joaoh82 / rust_sqlite / 25626364902

10 May 2026 10:28AM UTC coverage: 66.053% (+0.2%) from 65.897%
25626364902

Pull #122

github

web-flow
Merge d4ba5faaa into 17eac04f0
Pull Request #122: feat(engine): Phase 11.1 multi-connection foundation (SQLR-22)

91 of 110 new or added lines in 6 files covered. (82.73%)

1 existing line in 1 file now uncovered.

9468 of 14334 relevant lines covered (66.05%)

1.21 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))]
284
    fn set_ask_config(&mut self, config: Option<&AskConfig>) {
×
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))]
300
    fn ask(
×
301
        &mut self,
302
        py: Python<'_>,
303
        question: &str,
304
        config: Option<&AskConfig>,
305
    ) -> PyResult<AskResponse> {
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.
310
        let inner = self
×
311
            .inner
×
312
            .as_ref()
×
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.
320
        let locked = inner
×
321
            .lock()
×
322
            .map_err(|_| SQLRiteError::new_err("connection mutex poisoned"))?;
×
323
        let resp = py
×
NEW
324
            .allow_threads(|| {
×
NEW
325
                let db = locked.database();
×
NEW
326
                ask_with_database(&db, question, &resolved)
×
327
            })
328
            .map_err(map_err)?;
×
329
        Ok(AskResponse::from_rust(resp))
×
330
    }
331

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

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

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

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

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

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

505
    #[getter]
506
    fn api_key(&self) -> Option<&str> {
×
507
        self.inner.api_key.as_deref()
×
508
    }
509

510
    #[getter]
511
    fn model(&self) -> &str {
×
512
        &self.inner.model
×
513
    }
514

515
    #[getter]
516
    fn max_tokens(&self) -> u32 {
×
517
        self.inner.max_tokens
×
518
    }
519

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

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

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

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

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

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

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

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

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

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

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

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

659
impl Cursor {
660
    fn take_rows_for_iteration(&mut self) -> Option<&mut Rows> {
×
661
        self.current_rows.as_mut()
×
662
    }
663
}
664

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

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

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

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

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

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

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

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

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

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

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

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

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

871
// ---------------------------------------------------------------------------
872
// Value → Python conversions
873

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

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

905
// ---------------------------------------------------------------------------
906
// Module entry point
907

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

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

© 2026 Coveralls, Inc