pyturso 0.4.1__cp313-cp313-manylinux_2_24_x86_64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
turso/lib.py ADDED
@@ -0,0 +1,939 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterable, Iterator, Mapping, Sequence
4
+ from dataclasses import dataclass
5
+ from types import TracebackType
6
+ from typing import Any, Callable, Optional, TypeVar
7
+
8
+ from ._turso import (
9
+ Busy,
10
+ Constraint,
11
+ Corrupt,
12
+ DatabaseFull,
13
+ Interrupt,
14
+ Misuse,
15
+ NotAdb,
16
+ PyTursoConnection,
17
+ PyTursoDatabase,
18
+ PyTursoDatabaseConfig,
19
+ PyTursoEncryptionConfig,
20
+ PyTursoExecutionResult,
21
+ PyTursoLog,
22
+ PyTursoSetupConfig,
23
+ PyTursoStatement,
24
+ PyTursoStatusCode,
25
+ py_turso_database_open,
26
+ py_turso_setup,
27
+ )
28
+ from ._turso import (
29
+ Error as TursoError,
30
+ )
31
+ from ._turso import (
32
+ PyTursoStatusCode as Status,
33
+ )
34
+
35
+ # DB-API 2.0 module attributes
36
+ apilevel = "2.0"
37
+ threadsafety = 1 # 1 means: Threads may share the module, but not connections.
38
+ paramstyle = "qmark" # Only positional parameters are supported.
39
+
40
+
41
+ # Exception hierarchy following DB-API 2.0
42
+ class Warning(Exception):
43
+ pass
44
+
45
+
46
+ class Error(Exception):
47
+ pass
48
+
49
+
50
+ class InterfaceError(Error):
51
+ pass
52
+
53
+
54
+ class DatabaseError(Error):
55
+ pass
56
+
57
+
58
+ class DataError(DatabaseError):
59
+ pass
60
+
61
+
62
+ class OperationalError(DatabaseError):
63
+ pass
64
+
65
+
66
+ class IntegrityError(DatabaseError):
67
+ pass
68
+
69
+
70
+ class InternalError(DatabaseError):
71
+ pass
72
+
73
+
74
+ class ProgrammingError(DatabaseError):
75
+ pass
76
+
77
+
78
+ class NotSupportedError(DatabaseError):
79
+ pass
80
+
81
+
82
+ def _map_turso_exception(exc: Exception) -> Exception:
83
+ """Maps Turso-specific exceptions to DB-API 2.0 exception hierarchy"""
84
+ if isinstance(exc, Busy):
85
+ return OperationalError(str(exc))
86
+ if isinstance(exc, Interrupt):
87
+ return OperationalError(str(exc))
88
+ if isinstance(exc, Misuse):
89
+ return InterfaceError(str(exc))
90
+ if isinstance(exc, Constraint):
91
+ return IntegrityError(str(exc))
92
+ if isinstance(exc, TursoError):
93
+ # Generic Turso error -> DatabaseError
94
+ return DatabaseError(str(exc))
95
+ if isinstance(exc, DatabaseFull):
96
+ return OperationalError(str(exc))
97
+ if isinstance(exc, NotAdb):
98
+ return DatabaseError(str(exc))
99
+ if isinstance(exc, Corrupt):
100
+ return DatabaseError(str(exc))
101
+ return exc
102
+
103
+
104
+ # Internal helpers
105
+
106
+ _DBCursorT = TypeVar("_DBCursorT", bound="Cursor")
107
+
108
+
109
+ def _first_keyword(sql: str) -> str:
110
+ """
111
+ Return the first SQL keyword (uppercased) ignoring leading whitespace
112
+ and single-line and multi-line comments.
113
+
114
+ This is intentionally minimal and only used to detect DML for implicit
115
+ transaction handling. It may not handle all edge cases (e.g. complex WITH).
116
+ """
117
+ i = 0
118
+ n = len(sql)
119
+ while i < n:
120
+ c = sql[i]
121
+ if c.isspace():
122
+ i += 1
123
+ continue
124
+ if c == "-" and i + 1 < n and sql[i + 1] == "-":
125
+ # line comment
126
+ i += 2
127
+ while i < n and sql[i] not in ("\r", "\n"):
128
+ i += 1
129
+ continue
130
+ if c == "/" and i + 1 < n and sql[i + 1] == "*":
131
+ # block comment
132
+ i += 2
133
+ while i + 1 < n and not (sql[i] == "*" and sql[i + 1] == "/"):
134
+ i += 1
135
+ i = min(i + 2, n)
136
+ continue
137
+ break
138
+ # read token
139
+ j = i
140
+ while j < n and (sql[j].isalpha() or sql[j] == "_"):
141
+ j += 1
142
+ return sql[i:j].upper()
143
+
144
+
145
+ def _is_dml(sql: str) -> bool:
146
+ kw = _first_keyword(sql)
147
+ if kw in ("INSERT", "UPDATE", "DELETE", "REPLACE"):
148
+ return True
149
+ # "WITH" can also prefix DML, but we conservatively skip it to avoid false positives.
150
+ return False
151
+
152
+
153
+ def _is_insert_or_replace(sql: str) -> bool:
154
+ kw = _first_keyword(sql)
155
+ return kw in ("INSERT", "REPLACE")
156
+
157
+
158
+ def _run_execute_with_io(stmt: PyTursoStatement, extra_io: Optional[Callable[[], None]]) -> PyTursoExecutionResult:
159
+ """
160
+ Run PyTursoStatement.execute() handling potential async IO loops.
161
+ """
162
+ while True:
163
+ result = stmt.execute()
164
+ status = result.status
165
+ if status == Status.Io:
166
+ stmt.run_io()
167
+ if extra_io:
168
+ extra_io()
169
+ continue
170
+ return result
171
+
172
+
173
+ def _step_once_with_io(stmt: PyTursoStatement, extra_io: Optional[Callable[[], None]]) -> PyTursoStatusCode:
174
+ """
175
+ Run PyTursoStatement.step() once handling potential async IO loops.
176
+ """
177
+ while True:
178
+ status = stmt.step()
179
+ if status == Status.Io:
180
+ stmt.run_io()
181
+ if extra_io:
182
+ extra_io()
183
+ continue
184
+ return status
185
+
186
+
187
+ @dataclass
188
+ class _Prepared:
189
+ stmt: PyTursoStatement
190
+ tail_index: int
191
+ has_columns: bool
192
+ column_names: tuple[str, ...]
193
+
194
+
195
+ # Connection goes FIRST
196
+ class Connection:
197
+ """
198
+ A connection to a Turso (SQLite-compatible) database.
199
+
200
+ Similar to sqlite3.Connection with a subset of features focusing on DB-API 2.0.
201
+ """
202
+
203
+ # Expose exception classes as attributes like sqlite3.Connection does
204
+ @property
205
+ def DataError(self) -> type[DataError]:
206
+ return DataError
207
+
208
+ @property
209
+ def DatabaseError(self) -> type[DatabaseError]:
210
+ return DatabaseError
211
+
212
+ @property
213
+ def Error(self) -> type[Error]:
214
+ return Error
215
+
216
+ @property
217
+ def IntegrityError(self) -> type[IntegrityError]:
218
+ return IntegrityError
219
+
220
+ @property
221
+ def InterfaceError(self) -> type[InterfaceError]:
222
+ return InterfaceError
223
+
224
+ @property
225
+ def InternalError(self) -> type[InternalError]:
226
+ return InternalError
227
+
228
+ @property
229
+ def NotSupportedError(self) -> type[NotSupportedError]:
230
+ return NotSupportedError
231
+
232
+ @property
233
+ def OperationalError(self) -> type[OperationalError]:
234
+ return OperationalError
235
+
236
+ @property
237
+ def ProgrammingError(self) -> type[ProgrammingError]:
238
+ return ProgrammingError
239
+
240
+ @property
241
+ def Warning(self) -> type[Warning]:
242
+ return Warning
243
+
244
+ def __init__(
245
+ self,
246
+ conn: PyTursoConnection,
247
+ *,
248
+ isolation_level: Optional[str] = "DEFERRED",
249
+ extra_io: Optional[Callable[[], None]] = None,
250
+ ) -> None:
251
+ self._conn: PyTursoConnection = conn
252
+ # autocommit behavior:
253
+ # - True: SQLite autocommit mode; commit/rollback are no-ops.
254
+ # - False: PEP 249 compliant: ensure a transaction is always open.
255
+ # We'll use BEGIN DEFERRED after commit/rollback.
256
+ # - "LEGACY": implicit transactions on DML when isolation_level is not None.
257
+ self._autocommit_mode: object | bool = "LEGACY"
258
+ self.isolation_level: Optional[str] = isolation_level
259
+ self.row_factory: Callable[[Cursor, Row], object] | type[Row] | None = None
260
+ self.text_factory: Any = str
261
+ self.extra_io = extra_io
262
+
263
+ # If autocommit is False, ensure a transaction is open
264
+ if self._autocommit_mode is False:
265
+ self._ensure_transaction_open()
266
+
267
+ def _ensure_transaction_open(self) -> None:
268
+ """
269
+ Ensure a transaction is open when autocommit is False.
270
+ """
271
+ try:
272
+ if self._conn.get_auto_commit():
273
+ # No transaction active -> open new one according to isolation_level (default to DEFERRED)
274
+ level = self.isolation_level or "DEFERRED"
275
+ self._exec_ddl_only(f"BEGIN {level}")
276
+ except Exception as exc: # noqa: BLE001
277
+ raise _map_turso_exception(exc)
278
+
279
+ def _exec_ddl_only(self, sql: str) -> None:
280
+ """
281
+ Execute a SQL statement that does not produce rows and ignore any result rows.
282
+ """
283
+ try:
284
+ stmt = self._conn.prepare_single(sql)
285
+ _run_execute_with_io(stmt, self.extra_io)
286
+ # finalize to ensure completion; finalize never mixes with execute
287
+ stmt.finalize()
288
+ except Exception as exc: # noqa: BLE001
289
+ raise _map_turso_exception(exc)
290
+
291
+ def _prepare_first(self, sql: str) -> _Prepared:
292
+ """
293
+ Prepare the first statement in the given SQL string and return metadata.
294
+ """
295
+ try:
296
+ opt = self._conn.prepare_first(sql)
297
+ except Exception as exc: # noqa: BLE001
298
+ raise _map_turso_exception(exc)
299
+ if opt is None:
300
+ raise ProgrammingError("no SQL statements to execute")
301
+
302
+ stmt, tail_idx = opt
303
+ # Determine whether statement returns columns (rows)
304
+ try:
305
+ columns = tuple(stmt.columns())
306
+ except Exception as exc: # noqa: BLE001
307
+ # Clean up statement before re-raising
308
+ try:
309
+ stmt.finalize()
310
+ except Exception:
311
+ pass
312
+ raise _map_turso_exception(exc)
313
+ has_cols = len(columns) > 0
314
+ return _Prepared(stmt=stmt, tail_index=tail_idx, has_columns=has_cols, column_names=columns)
315
+
316
+ def _raise_if_multiple_statements(self, sql: str, tail_index: int) -> None:
317
+ """
318
+ Ensure there is no second statement after the first one; otherwise raise ProgrammingError.
319
+ """
320
+ # Skip any trailing whitespace/comments after tail_index, and check if another statement exists.
321
+ rest = sql[tail_index:]
322
+ try:
323
+ nxt = self._conn.prepare_first(rest)
324
+ if nxt is not None:
325
+ # Clean-up the prepared second statement immediately
326
+ second_stmt, _ = nxt
327
+ try:
328
+ second_stmt.finalize()
329
+ except Exception:
330
+ pass
331
+ raise ProgrammingError("You can only execute one statement at a time")
332
+ except ProgrammingError:
333
+ raise
334
+ except Exception as exc: # noqa: BLE001
335
+ raise _map_turso_exception(exc)
336
+
337
+ @property
338
+ def in_transaction(self) -> bool:
339
+ try:
340
+ return not self._conn.get_auto_commit()
341
+ except Exception as exc: # noqa: BLE001
342
+ raise _map_turso_exception(exc)
343
+
344
+ # Provide autocommit property for sqlite3-like API (optional)
345
+ @property
346
+ def autocommit(self) -> object | bool:
347
+ return self._autocommit_mode
348
+
349
+ @autocommit.setter
350
+ def autocommit(self, val: object | bool) -> None:
351
+ # Accept True, False, or "LEGACY"
352
+ if val not in (True, False, "LEGACY"):
353
+ raise ProgrammingError("autocommit must be True, False, or 'LEGACY'")
354
+ self._autocommit_mode = val
355
+ # If switching to False, ensure a transaction is open
356
+ if val is False:
357
+ self._ensure_transaction_open()
358
+ # If switching to True or LEGACY, nothing else to do immediately.
359
+
360
+ def close(self) -> None:
361
+ # In sqlite3: If autocommit is False, pending transaction is implicitly rolled back.
362
+ try:
363
+ if self._autocommit_mode is False and self.in_transaction:
364
+ try:
365
+ self._exec_ddl_only("ROLLBACK")
366
+ except Exception:
367
+ # As sqlite3 does, ignore rollback failure on close
368
+ pass
369
+ self._conn.close()
370
+ except Exception as exc: # noqa: BLE001
371
+ raise _map_turso_exception(exc)
372
+
373
+ def commit(self) -> None:
374
+ try:
375
+ if self._autocommit_mode is True:
376
+ # No-op in SQLite autocommit mode
377
+ return
378
+ if self.in_transaction:
379
+ self._exec_ddl_only("COMMIT")
380
+ if self._autocommit_mode is False:
381
+ # Re-open a transaction to maintain PEP 249 behavior
382
+ self._ensure_transaction_open()
383
+ except Exception as exc: # noqa: BLE001
384
+ raise _map_turso_exception(exc)
385
+
386
+ def rollback(self) -> None:
387
+ try:
388
+ if self._autocommit_mode is True:
389
+ # No-op in SQLite autocommit mode
390
+ return
391
+ if self.in_transaction:
392
+ self._exec_ddl_only("ROLLBACK")
393
+ if self._autocommit_mode is False:
394
+ # Re-open a transaction to maintain PEP 249 behavior
395
+ self._ensure_transaction_open()
396
+ except Exception as exc: # noqa: BLE001
397
+ raise _map_turso_exception(exc)
398
+
399
+ def _maybe_implicit_begin(self, sql: str) -> None:
400
+ """
401
+ Implement sqlite3 legacy implicit transaction behavior:
402
+
403
+ If autocommit is LEGACY_TRANSACTION_CONTROL, isolation_level is not None, sql is a DML
404
+ (INSERT/UPDATE/DELETE/REPLACE), and there is no open transaction, issue:
405
+ BEGIN <isolation_level>
406
+ """
407
+ if self._autocommit_mode == "LEGACY" and self.isolation_level is not None:
408
+ if not self.in_transaction and _is_dml(sql):
409
+ level = self.isolation_level or "DEFERRED"
410
+ self._exec_ddl_only(f"BEGIN {level}")
411
+
412
+ def cursor(self, factory: Optional[Callable[[Connection], _DBCursorT]] = None) -> _DBCursorT | Cursor:
413
+ if factory is None:
414
+ return Cursor(self)
415
+ return factory(self)
416
+
417
+ def execute(self, sql: str, parameters: Sequence[Any] | Mapping[str, Any] = ()) -> Cursor:
418
+ cur = self.cursor()
419
+ cur.execute(sql, parameters)
420
+ return cur
421
+
422
+ def executemany(self, sql: str, parameters: Iterable[Sequence[Any] | Mapping[str, Any]]) -> Cursor:
423
+ cur = self.cursor()
424
+ cur.executemany(sql, parameters)
425
+ return cur
426
+
427
+ def executescript(self, sql_script: str) -> Cursor:
428
+ cur = self.cursor()
429
+ cur.executescript(sql_script)
430
+ return cur
431
+
432
+ def __call__(self, sql: str) -> PyTursoStatement:
433
+ # Shortcut to prepare a single statement
434
+ try:
435
+ return self._conn.prepare_single(sql)
436
+ except Exception as exc: # noqa: BLE001
437
+ raise _map_turso_exception(exc)
438
+
439
+ def __enter__(self) -> "Connection":
440
+ return self
441
+
442
+ def __exit__(
443
+ self,
444
+ type: type[BaseException] | None,
445
+ value: BaseException | None,
446
+ traceback: TracebackType | None,
447
+ ) -> bool:
448
+ # sqlite3 behavior: In context manager, if no exception -> commit, else rollback (legacy and PEP 249 modes)
449
+ try:
450
+ if type is None:
451
+ self.commit()
452
+ else:
453
+ self.rollback()
454
+ finally:
455
+ # Always propagate exceptions (returning False)
456
+ return False
457
+
458
+
459
+ # Cursor goes SECOND
460
+ class Cursor:
461
+ arraysize: int
462
+
463
+ def __init__(self, connection: Connection, /) -> None:
464
+ self._connection: Connection = connection
465
+ self.arraysize = 1
466
+ self.row_factory: Callable[[Cursor, Row], object] | type[Row] | None = connection.row_factory
467
+
468
+ # State for the last executed statement
469
+ self._active_stmt: Optional[PyTursoStatement] = None
470
+ self._active_has_rows: bool = False
471
+ self._description: Optional[tuple[tuple[str, None, None, None, None, None, None], ...]] = None
472
+ self._lastrowid: Optional[int] = None
473
+ self._rowcount: int = -1
474
+ self._closed: bool = False
475
+
476
+ @property
477
+ def connection(self) -> Connection:
478
+ return self._connection
479
+
480
+ def close(self) -> None:
481
+ if self._closed:
482
+ return
483
+ try:
484
+ # Finalize any active statement to ensure completion.
485
+ if self._active_stmt is not None:
486
+ try:
487
+ self._active_stmt.finalize()
488
+ except Exception:
489
+ pass
490
+ finally:
491
+ self._active_stmt = None
492
+ self._active_has_rows = False
493
+ self._closed = True
494
+
495
+ def _ensure_open(self) -> None:
496
+ if self._closed:
497
+ raise ProgrammingError("Cannot operate on a closed cursor")
498
+
499
+ @property
500
+ def description(self) -> tuple[tuple[str, None, None, None, None, None, None], ...] | None:
501
+ return self._description
502
+
503
+ @property
504
+ def lastrowid(self) -> int | None:
505
+ return self._lastrowid
506
+
507
+ @property
508
+ def rowcount(self) -> int:
509
+ return self._rowcount
510
+
511
+ def _reset_last_result(self) -> None:
512
+ # Ensure any previous statement is finalized to not leak resources
513
+ if self._active_stmt is not None:
514
+ try:
515
+ self._active_stmt.finalize()
516
+ except Exception:
517
+ pass
518
+ self._active_stmt = None
519
+ self._active_has_rows = False
520
+ self._description = None
521
+ self._rowcount = -1
522
+ # Do not reset lastrowid here; sqlite3 preserves lastrowid until next insert.
523
+
524
+ @staticmethod
525
+ def _to_positional_params(parameters: Sequence[Any] | Mapping[str, Any]) -> tuple[Any, ...]:
526
+ if isinstance(parameters, Mapping):
527
+ # Named placeholders are not supported
528
+ raise ProgrammingError("Named parameters are not supported; use positional parameters with '?'")
529
+ if parameters is None:
530
+ return ()
531
+ if isinstance(parameters, tuple):
532
+ return parameters
533
+ # Convert arbitrary sequences to tuple efficiently
534
+ return tuple(parameters)
535
+
536
+ def _maybe_implicit_begin(self, sql: str) -> None:
537
+ self._connection._maybe_implicit_begin(sql)
538
+
539
+ def _prepare_single_statement(self, sql: str) -> _Prepared:
540
+ prepared = self._connection._prepare_first(sql)
541
+ # Ensure there are no further statements
542
+ self._connection._raise_if_multiple_statements(sql, prepared.tail_index)
543
+ return prepared
544
+
545
+ def execute(self, sql: str, parameters: Sequence[Any] | Mapping[str, Any] = ()) -> "Cursor":
546
+ self._ensure_open()
547
+ self._reset_last_result()
548
+
549
+ # Implement legacy implicit transactions if needed
550
+ self._maybe_implicit_begin(sql)
551
+
552
+ # Prepare exactly one statement
553
+ prepared = self._prepare_single_statement(sql)
554
+
555
+ stmt = prepared.stmt
556
+ try:
557
+ # Bind positional parameters
558
+ params = self._to_positional_params(parameters)
559
+ if params:
560
+ stmt.bind(params)
561
+
562
+ if prepared.has_columns:
563
+ # Stepped statement (e.g., SELECT or DML with RETURNING)
564
+ self._active_stmt = stmt
565
+ self._active_has_rows = True
566
+ # Set description immediately (even if there are no rows)
567
+ self._description = tuple((name, None, None, None, None, None, None) for name in prepared.column_names)
568
+ # For statements that return rows, DB-API specifies rowcount is -1
569
+ self._rowcount = -1
570
+ # Do not compute lastrowid here
571
+ else:
572
+ # Executed statement (no rows returned)
573
+ result = _run_execute_with_io(stmt, self._connection.extra_io)
574
+ # rows_changed from execution result
575
+ self._rowcount = int(result.rows_changed)
576
+ # Set description to None
577
+ self._description = None
578
+ # Set lastrowid for INSERT/REPLACE (best-effort)
579
+ self._lastrowid = self._fetch_last_insert_rowid_if_needed(sql, result.rows_changed)
580
+ # Finalize the statement to release resources
581
+ stmt.finalize()
582
+ except Exception as exc: # noqa: BLE001
583
+ # Ensure cleanup on error
584
+ try:
585
+ stmt.finalize()
586
+ except Exception:
587
+ pass
588
+ raise _map_turso_exception(exc)
589
+
590
+ return self
591
+
592
+ def _fetch_last_insert_rowid_if_needed(self, sql: str, rows_changed: int) -> Optional[int]:
593
+ if rows_changed <= 0 or not _is_insert_or_replace(sql):
594
+ return self._lastrowid
595
+ # Query last_insert_rowid(); this is connection-scoped and cheap
596
+ try:
597
+ q = self._connection._conn.prepare_single("SELECT last_insert_rowid()")
598
+ # No parameters; this produces a single-row single-column result
599
+ # Use stepping to fetch the row
600
+ status = _step_once_with_io(q, self._connection.extra_io)
601
+ if status == Status.Row:
602
+ py_row = q.row()
603
+ # row() returns a Python tuple with one element
604
+ # We avoid complex conversions: take first item
605
+ value = tuple(py_row)[0] # type: ignore[call-arg]
606
+ # Finalize to complete
607
+ q.finalize()
608
+ if isinstance(value, int):
609
+ return value
610
+ try:
611
+ return int(value)
612
+ except Exception:
613
+ return self._lastrowid
614
+ # Finalize anyway
615
+ q.finalize()
616
+ except Exception:
617
+ # Ignore errors; lastrowid remains unchanged on failure
618
+ pass
619
+ return self._lastrowid
620
+
621
+ def executemany(self, sql: str, seq_of_parameters: Iterable[Sequence[Any] | Mapping[str, Any]]) -> "Cursor":
622
+ self._ensure_open()
623
+ self._reset_last_result()
624
+
625
+ # executemany only accepts DML; enforce this to match sqlite3 semantics
626
+ if not _is_dml(sql):
627
+ raise ProgrammingError("executemany() requires a single DML (INSERT/UPDATE/DELETE/REPLACE) statement")
628
+
629
+ # Implement legacy implicit transaction: same as execute()
630
+ self._maybe_implicit_begin(sql)
631
+
632
+ prepared = self._prepare_single_statement(sql)
633
+ stmt = prepared.stmt
634
+ try:
635
+ # For executemany, discard any rows produced (even if RETURNING was used)
636
+ # Therefore we ALWAYS use execute() path per-iteration.
637
+ for parameters in seq_of_parameters:
638
+ # Reset previous bindings and program memory before reusing
639
+ stmt.reset()
640
+ params = self._to_positional_params(parameters)
641
+ if params:
642
+ stmt.bind(params)
643
+ result = _run_execute_with_io(stmt, self._connection.extra_io)
644
+ # rowcount is "the number of modified rows" for the LAST executed statement only
645
+ self._rowcount = int(result.rows_changed) + (self._rowcount if self._rowcount != -1 else 0)
646
+ # After loop, finalize statement
647
+ stmt.finalize()
648
+ # Cursor description is None for DML executed via executemany()
649
+ self._description = None
650
+ # sqlite3 leaves lastrowid unchanged for executemany
651
+ except Exception as exc: # noqa: BLE001
652
+ try:
653
+ stmt.finalize()
654
+ except Exception:
655
+ pass
656
+ raise _map_turso_exception(exc)
657
+ return self
658
+
659
+ def executescript(self, sql_script: str) -> "Cursor":
660
+ self._ensure_open()
661
+ self._reset_last_result()
662
+
663
+ # sqlite3 behavior: If autocommit is LEGACY and there is a pending transaction, implicitly COMMIT first
664
+ if self._connection._autocommit_mode == "LEGACY" and self._connection.in_transaction:
665
+ try:
666
+ self._connection._exec_ddl_only("COMMIT")
667
+ except Exception as exc: # noqa: BLE001
668
+ raise _map_turso_exception(exc)
669
+
670
+ # Iterate over statements in the script and execute them, discarding rows
671
+ sql = sql_script
672
+ total_rowcount = -1
673
+ try:
674
+ offset = 0
675
+ while True:
676
+ opt = self._connection._conn.prepare_first(sql[offset:])
677
+ if opt is None:
678
+ break
679
+ stmt, tail = opt
680
+ # Note: per DB-API, any resulting rows are discarded
681
+ result = _run_execute_with_io(stmt, self._connection.extra_io)
682
+ total_rowcount = int(result.rows_changed) if result.rows_changed > 0 else total_rowcount
683
+ # finalize to ensure completion
684
+ stmt.finalize()
685
+ offset += tail
686
+ except Exception as exc: # noqa: BLE001
687
+ raise _map_turso_exception(exc)
688
+
689
+ self._description = None
690
+ self._rowcount = total_rowcount
691
+ return self
692
+
693
+ def _fetchone_tuple(self) -> Optional[tuple[Any, ...]]:
694
+ """
695
+ Fetch one row as a plain Python tuple, or return None if no more rows.
696
+ """
697
+ if not self._active_has_rows or self._active_stmt is None:
698
+ return None
699
+ try:
700
+ status = _step_once_with_io(self._active_stmt, self._connection.extra_io)
701
+ if status == Status.Row:
702
+ row_tuple = tuple(self._active_stmt.row()) # type: ignore[call-arg]
703
+ return row_tuple
704
+ # status == Done: finalize and clean up
705
+ self._active_stmt.finalize()
706
+ self._active_stmt = None
707
+ self._active_has_rows = False
708
+ return None
709
+ except Exception as exc: # noqa: BLE001
710
+ # Finalize and clean up on error
711
+ try:
712
+ if self._active_stmt is not None:
713
+ self._active_stmt.finalize()
714
+ except Exception:
715
+ pass
716
+ self._active_stmt = None
717
+ self._active_has_rows = False
718
+ raise _map_turso_exception(exc)
719
+
720
+ def _apply_row_factory(self, row_values: tuple[Any, ...]) -> Any:
721
+ rf = self.row_factory
722
+ if rf is None:
723
+ return row_values
724
+ if isinstance(rf, type) and issubclass(rf, Row):
725
+ return rf(self, Row(self, row_values)) # type: ignore[call-arg]
726
+ if callable(rf):
727
+ return rf(self, Row(self, row_values)) # type: ignore[misc]
728
+ # Fallback: return tuple
729
+ return row_values
730
+
731
+ def fetchone(self) -> Any:
732
+ self._ensure_open()
733
+ row = self._fetchone_tuple()
734
+ if row is None:
735
+ return None
736
+ return self._apply_row_factory(row)
737
+
738
+ def fetchmany(self, size: Optional[int] = None) -> list[Any]:
739
+ self._ensure_open()
740
+ if size is None:
741
+ size = self.arraysize
742
+ if size < 0:
743
+ raise ValueError("size must be non-negative")
744
+ result: list[Any] = []
745
+ for _ in range(size):
746
+ row = self._fetchone_tuple()
747
+ if row is None:
748
+ break
749
+ result.append(self._apply_row_factory(row))
750
+ return result
751
+
752
+ def fetchall(self) -> list[Any]:
753
+ self._ensure_open()
754
+ result: list[Any] = []
755
+ while True:
756
+ row = self._fetchone_tuple()
757
+ if row is None:
758
+ break
759
+ result.append(self._apply_row_factory(row))
760
+ return result
761
+
762
+ def setinputsizes(self, sizes: Any, /) -> None:
763
+ # No-op for DB-API compliance
764
+ return None
765
+
766
+ def setoutputsize(self, size: Any, column: Any = None, /) -> None:
767
+ # No-op for DB-API compliance
768
+ return None
769
+
770
+ def __iter__(self) -> "Cursor":
771
+ return self
772
+
773
+ def __next__(self) -> Any:
774
+ row = self.fetchone()
775
+ if row is None:
776
+ raise StopIteration
777
+ return row
778
+
779
+
780
+ # Row goes THIRD
781
+ class Row(Sequence[Any]):
782
+ """
783
+ sqlite3.Row-like container supporting index and name-based access.
784
+ """
785
+
786
+ def __new__(cls, cursor: Cursor, data: tuple[Any, ...], /) -> "Row":
787
+ obj = super().__new__(cls)
788
+ # Attach metadata
789
+ obj._cursor = cursor
790
+ obj._data = data
791
+ # Build mapping from column name to index
792
+ desc = cursor.description or ()
793
+ obj._keys = tuple(col[0] for col in desc)
794
+ obj._index = {name: idx for idx, name in enumerate(obj._keys)}
795
+ return obj
796
+
797
+ def keys(self) -> list[str]:
798
+ return list(self._keys)
799
+
800
+ def __getitem__(self, key: int | str | slice, /) -> Any:
801
+ if isinstance(key, slice):
802
+ return self._data[key]
803
+ if isinstance(key, int):
804
+ return self._data[key]
805
+ # key is column name
806
+ idx = self._index.get(key)
807
+ if idx is None:
808
+ raise KeyError(key)
809
+ return self._data[idx]
810
+
811
+ def __hash__(self) -> int:
812
+ return hash((self._keys, self._data))
813
+
814
+ def __iter__(self) -> Iterator[Any]:
815
+ return iter(self._data)
816
+
817
+ def __len__(self) -> int:
818
+ return len(self._data)
819
+
820
+ def __eq__(self, value: object, /) -> bool:
821
+ if not isinstance(value, Row):
822
+ return NotImplemented # type: ignore[return-value]
823
+ return self._keys == value._keys and self._data == value._data
824
+
825
+ def __ne__(self, value: object, /) -> bool:
826
+ if not isinstance(value, Row):
827
+ return NotImplemented # type: ignore[return-value]
828
+ return not self.__eq__(value)
829
+
830
+ # The rest return NotImplemented for non-Row comparisons
831
+ def __lt__(self, value: object, /) -> bool:
832
+ if not isinstance(value, Row):
833
+ return NotImplemented # type: ignore[return-value]
834
+ return (self._keys, self._data) < (value._keys, value._data)
835
+
836
+ def __le__(self, value: object, /) -> bool:
837
+ if not isinstance(value, Row):
838
+ return NotImplemented # type: ignore[return-value]
839
+ return (self._keys, self._data) <= (value._keys, value._data)
840
+
841
+ def __gt__(self, value: object, /) -> bool:
842
+ if not isinstance(value, Row):
843
+ return NotImplemented # type: ignore[return-value]
844
+ return (self._keys, self._data) > (value._keys, value._data)
845
+
846
+ def __ge__(self, value: object, /) -> bool:
847
+ if not isinstance(value, Row):
848
+ return NotImplemented # type: ignore[return-value]
849
+ return (self._keys, self._data) >= (value._keys, value._data)
850
+
851
+
852
+ @dataclass
853
+ class EncryptionOpts:
854
+ cipher: str
855
+ hexkey: str
856
+
857
+
858
+ def connect(
859
+ database: str,
860
+ *,
861
+ experimental_features: Optional[str] = None,
862
+ vfs: Optional[str] = None,
863
+ encryption: Optional[EncryptionOpts] = None,
864
+ isolation_level: Optional[str] = "DEFERRED",
865
+ extra_io: Optional[Callable[[], None]] = None,
866
+ ) -> Connection:
867
+ """
868
+ Open a Turso (SQLite-compatible) database and return a Connection.
869
+
870
+ Parameters:
871
+ - database: path or identifier of the database.
872
+ - experimental_features: comma-separated list of features to enable.
873
+ - isolation_level: one of "DEFERRED" (default), "IMMEDIATE", "EXCLUSIVE", or None.
874
+ """
875
+ try:
876
+ cfg = PyTursoDatabaseConfig(
877
+ path=database,
878
+ experimental_features=experimental_features,
879
+ async_io=False, # Let the Rust layer drive IO internally by default
880
+ vfs=vfs,
881
+ encryption=PyTursoEncryptionConfig(cipher=encryption.cipher, hexkey=encryption.hexkey)
882
+ if encryption
883
+ else None,
884
+ )
885
+ db: PyTursoDatabase = py_turso_database_open(cfg)
886
+ conn: PyTursoConnection = db.connect()
887
+ return Connection(conn, isolation_level=isolation_level, extra_io=extra_io)
888
+ except Exception as exc: # noqa: BLE001
889
+ raise _map_turso_exception(exc)
890
+
891
+
892
+ # Make it easy to enable logging with native `logging` Python module
893
+ def setup_logging(level: Optional[int] = None) -> None:
894
+ """
895
+ Setup Turso logging to integrate with Python's logging module.
896
+
897
+ Usage:
898
+ import turso
899
+ turso.setup_logging(logging.DEBUG)
900
+ """
901
+ import logging
902
+
903
+ level = level or logging.INFO
904
+ logger = logging.getLogger("turso")
905
+ logger.setLevel(level)
906
+
907
+ def _py_logger(log: PyTursoLog) -> None:
908
+ # Map Rust/Turso log level strings to Python logging levels (best-effort)
909
+ lvl_map = {
910
+ "ERROR": logging.ERROR,
911
+ "WARN": logging.WARNING,
912
+ "INFO": logging.INFO,
913
+ "DEBUG": logging.DEBUG,
914
+ "TRACE": logging.DEBUG,
915
+ }
916
+ py_level = lvl_map.get(log.level.upper(), level)
917
+ logger.log(
918
+ py_level,
919
+ "%s [%s:%s] %s",
920
+ log.target,
921
+ log.file,
922
+ log.line,
923
+ log.message,
924
+ )
925
+
926
+ try:
927
+ py_turso_setup(
928
+ PyTursoSetupConfig(
929
+ logger=_py_logger,
930
+ log_level={
931
+ logging.ERROR: "error",
932
+ logging.WARN: "warn",
933
+ logging.INFO: "info",
934
+ logging.DEBUG: "debug",
935
+ }[level],
936
+ )
937
+ )
938
+ except Exception as exc: # noqa: BLE001
939
+ raise _map_turso_exception(exc)