pyturso 0.1.5rc5__cp310-cp310-macosx_11_0_arm64.whl → 0.4.0rc9__cp310-cp310-macosx_11_0_arm64.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.

Potentially problematic release.


This version of pyturso might be problematic. Click here for more details.

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