pyturso 0.4.0rc4__cp312-cp312-manylinux_2_17_x86_64.manylinux2014_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.

Potentially problematic release.


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

@@ -0,0 +1,104 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyturso
3
+ Version: 0.4.0rc4
4
+ Classifier: Development Status :: 3 - Alpha
5
+ Classifier: Programming Language :: Python
6
+ Classifier: Programming Language :: Python :: 3
7
+ Classifier: Programming Language :: Python :: 3 :: Only
8
+ Classifier: Programming Language :: Python :: 3.9
9
+ Classifier: Programming Language :: Python :: 3.10
10
+ Classifier: Programming Language :: Python :: 3.11
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Classifier: Programming Language :: Python :: 3.13
13
+ Classifier: Programming Language :: Rust
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: POSIX :: Linux
16
+ Classifier: Operating System :: Microsoft :: Windows
17
+ Classifier: Operating System :: MacOS
18
+ Classifier: Topic :: Database
19
+ Classifier: Topic :: Software Development :: Libraries
20
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
+ Classifier: Topic :: Database :: Database Engines/Servers
22
+ Requires-Dist: typing-extensions>=4.6.0,!=4.7.0
23
+ Requires-Dist: mypy==1.11.0 ; extra == 'dev'
24
+ Requires-Dist: pytest==8.3.1 ; extra == 'dev'
25
+ Requires-Dist: pytest-cov==5.0.0 ; extra == 'dev'
26
+ Requires-Dist: ruff==0.5.4 ; extra == 'dev'
27
+ Requires-Dist: coverage==7.6.1 ; extra == 'dev'
28
+ Requires-Dist: maturin==1.7.8 ; extra == 'dev'
29
+ Provides-Extra: dev
30
+ Summary: Turso is a work-in-progress, in-process OLTP database management system, compatible with SQLite.
31
+ Requires-Python: >=3.9
32
+ Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
33
+ Project-URL: Homepage, https://github.com/tursodatabase/turso
34
+ Project-URL: Source, https://github.com/tursodatabase/turso
35
+
36
+ <p align="center">
37
+ <h1 align="center">Turso Database for Python</h1>
38
+ </p>
39
+
40
+ <p align="center">
41
+ <a title="Python" target="_blank" href="https://pypi.org/project/pyturso/"><img alt="PyPI" src="https://img.shields.io/pypi/v/pyturso"></a>
42
+ <a title="MIT" target="_blank" href="https://github.com/tursodatabase/turso/blob/main/LICENSE.md"><img src="http://img.shields.io/badge/license-MIT-orange.svg?style=flat-square"></a>
43
+ </p>
44
+ <p align="center">
45
+ <a title="Users Discord" target="_blank" href="https://tur.so/discord"><img alt="Chat with other users of Turso on Discord" src="https://img.shields.io/discord/933071162680958986?label=Discord&logo=Discord&style=social"></a>
46
+ </p>
47
+
48
+ ---
49
+
50
+ ## About
51
+
52
+ > **⚠️ Warning:** This software is in BETA. It may still contain bugs and unexpected behavior. Use caution with production data and ensure you have backups.
53
+
54
+ ## Features
55
+
56
+ - **SQLite compatible:** SQLite query language and file format support ([status](https://github.com/tursodatabase/turso/blob/main/COMPAT.md)).
57
+ - **In-process**: No network overhead, runs directly in your Python process
58
+ - **Cross-platform**: Supports Linux, macOS, Windows
59
+
60
+ ## Installation
61
+ ```bash
62
+ uv pip install pyturso
63
+ ```
64
+
65
+ ## Getting Started
66
+ ```python
67
+ import turso
68
+
69
+ # Create/open a database
70
+ # con = turso.connect(":memory:") # For memory mode
71
+ con = turso.connect("sqlite.db")
72
+ cur = con.cursor()
73
+
74
+ # Create a table
75
+ cur.execute("""
76
+ CREATE TABLE IF NOT EXISTS users (
77
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
78
+ username TEXT NOT NULL
79
+ )
80
+ """)
81
+ con.commit()
82
+
83
+ # Insert data
84
+ cur.execute("INSERT INTO users (username) VALUES (?)", ("alice",))
85
+ cur.execute("INSERT INTO users (username) VALUES (?)", ("bob",))
86
+ con.commit()
87
+
88
+ # Query data
89
+ res = cur.execute("SELECT * FROM users")
90
+ users = res.fetchall()
91
+ print(users)
92
+ # Output: [(1, 'alice'), (2, 'bob')]
93
+ ```
94
+
95
+ ## License
96
+
97
+ This project is licensed under the [MIT license](../../LICENSE.md).
98
+
99
+ ## Support
100
+
101
+ - [GitHub Issues](https://github.com/tursodatabase/turso/issues)
102
+ - [Documentation](https://docs.turso.tech)
103
+ - [Discord Community](https://tur.so/discord)
104
+
@@ -0,0 +1,7 @@
1
+ pyturso-0.4.0rc4.dist-info/METADATA,sha256=xdDUxFlPi6KWvzEaGgV7JVg7ZcPVClWr91ksARoQzQk,3683
2
+ pyturso-0.4.0rc4.dist-info/WHEEL,sha256=m2ROzCpH5Kw6bN_3jKfw80jyQS9OqSulcWBhBkC07PU,147
3
+ turso/__init__.py,sha256=hHP6yAHO9k2sVxwkN0BgKyHyVgleQ8qnk5irv_3KAdk,674
4
+ turso/_turso.cpython-312-x86_64-linux-gnu.so,sha256=7OMGae7g0_9N9TQyieDJVVoIjZshjZXQowk913FKny8,39128688
5
+ turso/lib.py,sha256=jOBiWMaK7GwWLG1QVQh0rcK24Ug40Wlq3xrCrUwHUrU,30873
6
+ turso/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ pyturso-0.4.0rc4.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: maturin (1.10.2)
3
+ Root-Is-Purelib: false
4
+ Tag: cp312-cp312-manylinux_2_17_x86_64
5
+ Tag: cp312-cp312-manylinux2014_x86_64
turso/__init__.py ADDED
@@ -0,0 +1,41 @@
1
+ from .lib import (
2
+ Connection,
3
+ Cursor,
4
+ DatabaseError,
5
+ DataError,
6
+ Error,
7
+ IntegrityError,
8
+ InterfaceError,
9
+ InternalError,
10
+ NotSupportedError,
11
+ OperationalError,
12
+ ProgrammingError,
13
+ Row,
14
+ Warning,
15
+ apilevel,
16
+ connect,
17
+ paramstyle,
18
+ setup_logging,
19
+ threadsafety,
20
+ )
21
+
22
+ __all__ = [
23
+ "Connection",
24
+ "Cursor",
25
+ "Row",
26
+ "connect",
27
+ "setup_logging",
28
+ "Warning",
29
+ "DatabaseError",
30
+ "DataError",
31
+ "Error",
32
+ "IntegrityError",
33
+ "InterfaceError",
34
+ "InternalError",
35
+ "NotSupportedError",
36
+ "OperationalError",
37
+ "ProgrammingError",
38
+ "apilevel",
39
+ "paramstyle",
40
+ "threadsafety",
41
+ ]
turso/lib.py ADDED
@@ -0,0 +1,909 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from collections.abc import Iterable, Iterator, Mapping, Sequence
5
+ from dataclasses import dataclass
6
+ from types import TracebackType
7
+ from typing import Any, Callable, Optional, TypeVar
8
+
9
+ from ._turso import (
10
+ Busy,
11
+ Constraint,
12
+ Corrupt,
13
+ DatabaseFull,
14
+ Interrupt,
15
+ Misuse,
16
+ NotAdb,
17
+ PyTursoConnection,
18
+ PyTursoDatabase,
19
+ PyTursoDatabaseConfig,
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) -> 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
+ # Drive IO loop; repeat.
167
+ stmt.run_io()
168
+ continue
169
+ return result
170
+
171
+
172
+ def _step_once_with_io(stmt: PyTursoStatement) -> 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
+ continue
181
+ return status
182
+
183
+
184
+ @dataclass
185
+ class _Prepared:
186
+ stmt: PyTursoStatement
187
+ tail_index: int
188
+ has_columns: bool
189
+ column_names: tuple[str, ...]
190
+
191
+
192
+ # Connection goes FIRST
193
+ class Connection:
194
+ """
195
+ A connection to a Turso (SQLite-compatible) database.
196
+
197
+ Similar to sqlite3.Connection with a subset of features focusing on DB-API 2.0.
198
+ """
199
+
200
+ # Expose exception classes as attributes like sqlite3.Connection does
201
+ @property
202
+ def DataError(self) -> type[DataError]:
203
+ return DataError
204
+
205
+ @property
206
+ def DatabaseError(self) -> type[DatabaseError]:
207
+ return DatabaseError
208
+
209
+ @property
210
+ def Error(self) -> type[Error]:
211
+ return Error
212
+
213
+ @property
214
+ def IntegrityError(self) -> type[IntegrityError]:
215
+ return IntegrityError
216
+
217
+ @property
218
+ def InterfaceError(self) -> type[InterfaceError]:
219
+ return InterfaceError
220
+
221
+ @property
222
+ def InternalError(self) -> type[InternalError]:
223
+ return InternalError
224
+
225
+ @property
226
+ def NotSupportedError(self) -> type[NotSupportedError]:
227
+ return NotSupportedError
228
+
229
+ @property
230
+ def OperationalError(self) -> type[OperationalError]:
231
+ return OperationalError
232
+
233
+ @property
234
+ def ProgrammingError(self) -> type[ProgrammingError]:
235
+ return ProgrammingError
236
+
237
+ @property
238
+ def Warning(self) -> type[Warning]:
239
+ return Warning
240
+
241
+ def __init__(
242
+ self,
243
+ conn: PyTursoConnection,
244
+ *,
245
+ isolation_level: Optional[str] = "DEFERRED",
246
+ ) -> None:
247
+ self._conn: PyTursoConnection = conn
248
+ # autocommit behavior:
249
+ # - True: SQLite autocommit mode; commit/rollback are no-ops.
250
+ # - False: PEP 249 compliant: ensure a transaction is always open.
251
+ # We'll use BEGIN DEFERRED after commit/rollback.
252
+ # - "LEGACY": implicit transactions on DML when isolation_level is not None.
253
+ self._autocommit_mode: object | bool = "LEGACY"
254
+ self.isolation_level: Optional[str] = isolation_level
255
+ self.row_factory: Callable[[Cursor, Row], object] | type[Row] | None = None
256
+ self.text_factory: Any = str
257
+
258
+ # If autocommit is False, ensure a transaction is open
259
+ if self._autocommit_mode is False:
260
+ self._ensure_transaction_open()
261
+
262
+ def _ensure_transaction_open(self) -> None:
263
+ """
264
+ Ensure a transaction is open when autocommit is False.
265
+ """
266
+ try:
267
+ if self._conn.get_auto_commit():
268
+ # No transaction active -> open new one according to isolation_level (default to DEFERRED)
269
+ level = self.isolation_level or "DEFERRED"
270
+ self._exec_ddl_only(f"BEGIN {level}")
271
+ except Exception as exc: # noqa: BLE001
272
+ raise _map_turso_exception(exc)
273
+
274
+ def _exec_ddl_only(self, sql: str) -> None:
275
+ """
276
+ Execute a SQL statement that does not produce rows and ignore any result rows.
277
+ """
278
+ try:
279
+ stmt = self._conn.prepare_single(sql)
280
+ _run_execute_with_io(stmt)
281
+ # finalize to ensure completion; finalize never mixes with execute
282
+ stmt.finalize()
283
+ except Exception as exc: # noqa: BLE001
284
+ raise _map_turso_exception(exc)
285
+
286
+ def _prepare_first(self, sql: str) -> _Prepared:
287
+ """
288
+ Prepare the first statement in the given SQL string and return metadata.
289
+ """
290
+ try:
291
+ opt = self._conn.prepare_first(sql)
292
+ except Exception as exc: # noqa: BLE001
293
+ raise _map_turso_exception(exc)
294
+ if opt is None:
295
+ raise ProgrammingError("no SQL statements to execute")
296
+
297
+ stmt, tail_idx = opt
298
+ # Determine whether statement returns columns (rows)
299
+ try:
300
+ columns = tuple(stmt.columns())
301
+ except Exception as exc: # noqa: BLE001
302
+ # Clean up statement before re-raising
303
+ try:
304
+ stmt.finalize()
305
+ except Exception:
306
+ pass
307
+ raise _map_turso_exception(exc)
308
+ has_cols = len(columns) > 0
309
+ return _Prepared(stmt=stmt, tail_index=tail_idx, has_columns=has_cols, column_names=columns)
310
+
311
+ def _raise_if_multiple_statements(self, sql: str, tail_index: int) -> None:
312
+ """
313
+ Ensure there is no second statement after the first one; otherwise raise ProgrammingError.
314
+ """
315
+ # Skip any trailing whitespace/comments after tail_index, and check if another statement exists.
316
+ rest = sql[tail_index:]
317
+ try:
318
+ nxt = self._conn.prepare_first(rest)
319
+ if nxt is not None:
320
+ # Clean-up the prepared second statement immediately
321
+ second_stmt, _ = nxt
322
+ try:
323
+ second_stmt.finalize()
324
+ except Exception:
325
+ pass
326
+ raise ProgrammingError("You can only execute one statement at a time")
327
+ except ProgrammingError:
328
+ raise
329
+ except Exception as exc: # noqa: BLE001
330
+ raise _map_turso_exception(exc)
331
+
332
+ @property
333
+ def in_transaction(self) -> bool:
334
+ try:
335
+ return not self._conn.get_auto_commit()
336
+ except Exception as exc: # noqa: BLE001
337
+ raise _map_turso_exception(exc)
338
+
339
+ # Provide autocommit property for sqlite3-like API (optional)
340
+ @property
341
+ def autocommit(self) -> object | bool:
342
+ return self._autocommit_mode
343
+
344
+ @autocommit.setter
345
+ def autocommit(self, val: object | bool) -> None:
346
+ # Accept True, False, or "LEGACY"
347
+ if val not in (True, False, "LEGACY"):
348
+ raise ProgrammingError("autocommit must be True, False, or 'LEGACY'")
349
+ self._autocommit_mode = val
350
+ # If switching to False, ensure a transaction is open
351
+ if val is False:
352
+ self._ensure_transaction_open()
353
+ # If switching to True or LEGACY, nothing else to do immediately.
354
+
355
+ def close(self) -> None:
356
+ # In sqlite3: If autocommit is False, pending transaction is implicitly rolled back.
357
+ try:
358
+ if self._autocommit_mode is False and self.in_transaction:
359
+ try:
360
+ self._exec_ddl_only("ROLLBACK")
361
+ except Exception:
362
+ # As sqlite3 does, ignore rollback failure on close
363
+ pass
364
+ self._conn.close()
365
+ except Exception as exc: # noqa: BLE001
366
+ raise _map_turso_exception(exc)
367
+
368
+ def commit(self) -> None:
369
+ try:
370
+ if self._autocommit_mode is True:
371
+ # No-op in SQLite autocommit mode
372
+ return
373
+ if self.in_transaction:
374
+ self._exec_ddl_only("COMMIT")
375
+ if self._autocommit_mode is False:
376
+ # Re-open a transaction to maintain PEP 249 behavior
377
+ self._ensure_transaction_open()
378
+ except Exception as exc: # noqa: BLE001
379
+ raise _map_turso_exception(exc)
380
+
381
+ def rollback(self) -> None:
382
+ try:
383
+ if self._autocommit_mode is True:
384
+ # No-op in SQLite autocommit mode
385
+ return
386
+ if self.in_transaction:
387
+ self._exec_ddl_only("ROLLBACK")
388
+ if self._autocommit_mode is False:
389
+ # Re-open a transaction to maintain PEP 249 behavior
390
+ self._ensure_transaction_open()
391
+ except Exception as exc: # noqa: BLE001
392
+ raise _map_turso_exception(exc)
393
+
394
+ def _maybe_implicit_begin(self, sql: str) -> None:
395
+ """
396
+ Implement sqlite3 legacy implicit transaction behavior:
397
+
398
+ If autocommit is LEGACY_TRANSACTION_CONTROL, isolation_level is not None, sql is a DML
399
+ (INSERT/UPDATE/DELETE/REPLACE), and there is no open transaction, issue:
400
+ BEGIN <isolation_level>
401
+ """
402
+ if self._autocommit_mode == "LEGACY" and self.isolation_level is not None:
403
+ if not self.in_transaction and _is_dml(sql):
404
+ level = self.isolation_level or "DEFERRED"
405
+ self._exec_ddl_only(f"BEGIN {level}")
406
+
407
+ def cursor(self, factory: Optional[Callable[[Connection], _DBCursorT]] = None) -> _DBCursorT | Cursor:
408
+ if factory is None:
409
+ return Cursor(self)
410
+ return factory(self)
411
+
412
+ def execute(self, sql: str, parameters: Sequence[Any] | Mapping[str, Any] = ()) -> Cursor:
413
+ cur = self.cursor()
414
+ cur.execute(sql, parameters)
415
+ return cur
416
+
417
+ def executemany(self, sql: str, parameters: Iterable[Sequence[Any] | Mapping[str, Any]]) -> Cursor:
418
+ cur = self.cursor()
419
+ cur.executemany(sql, parameters)
420
+ return cur
421
+
422
+ def executescript(self, sql_script: str) -> Cursor:
423
+ cur = self.cursor()
424
+ cur.executescript(sql_script)
425
+ return cur
426
+
427
+ def __call__(self, sql: str) -> PyTursoStatement:
428
+ # Shortcut to prepare a single statement
429
+ try:
430
+ return self._conn.prepare_single(sql)
431
+ except Exception as exc: # noqa: BLE001
432
+ raise _map_turso_exception(exc)
433
+
434
+ def __enter__(self) -> "Connection":
435
+ return self
436
+
437
+ def __exit__(
438
+ self,
439
+ type: type[BaseException] | None,
440
+ value: BaseException | None,
441
+ traceback: TracebackType | None,
442
+ ) -> bool:
443
+ # sqlite3 behavior: In context manager, if no exception -> commit, else rollback (legacy and PEP 249 modes)
444
+ try:
445
+ if type is None:
446
+ self.commit()
447
+ else:
448
+ self.rollback()
449
+ finally:
450
+ # Always propagate exceptions (returning False)
451
+ return False
452
+
453
+
454
+ # Cursor goes SECOND
455
+ class Cursor:
456
+ arraysize: int
457
+
458
+ def __init__(self, connection: Connection, /) -> None:
459
+ self._connection: Connection = connection
460
+ self.arraysize = 1
461
+ self.row_factory: Callable[[Cursor, Row], object] | type[Row] | None = connection.row_factory
462
+
463
+ # State for the last executed statement
464
+ self._active_stmt: Optional[PyTursoStatement] = None
465
+ self._active_has_rows: bool = False
466
+ self._description: Optional[tuple[tuple[str, None, None, None, None, None, None], ...]] = None
467
+ self._lastrowid: Optional[int] = None
468
+ self._rowcount: int = -1
469
+ self._closed: bool = False
470
+
471
+ @property
472
+ def connection(self) -> Connection:
473
+ return self._connection
474
+
475
+ def close(self) -> None:
476
+ if self._closed:
477
+ return
478
+ try:
479
+ # Finalize any active statement to ensure completion.
480
+ if self._active_stmt is not None:
481
+ try:
482
+ self._active_stmt.finalize()
483
+ except Exception:
484
+ pass
485
+ finally:
486
+ self._active_stmt = None
487
+ self._active_has_rows = False
488
+ self._closed = True
489
+
490
+ def _ensure_open(self) -> None:
491
+ if self._closed:
492
+ raise ProgrammingError("Cannot operate on a closed cursor")
493
+
494
+ @property
495
+ def description(self) -> tuple[tuple[str, None, None, None, None, None, None], ...] | None:
496
+ return self._description
497
+
498
+ @property
499
+ def lastrowid(self) -> int | None:
500
+ return self._lastrowid
501
+
502
+ @property
503
+ def rowcount(self) -> int:
504
+ return self._rowcount
505
+
506
+ def _reset_last_result(self) -> None:
507
+ # Ensure any previous statement is finalized to not leak resources
508
+ if self._active_stmt is not None:
509
+ try:
510
+ self._active_stmt.finalize()
511
+ except Exception:
512
+ pass
513
+ self._active_stmt = None
514
+ self._active_has_rows = False
515
+ self._description = None
516
+ self._rowcount = -1
517
+ # Do not reset lastrowid here; sqlite3 preserves lastrowid until next insert.
518
+
519
+ @staticmethod
520
+ def _to_positional_params(parameters: Sequence[Any] | Mapping[str, Any]) -> tuple[Any, ...]:
521
+ if isinstance(parameters, Mapping):
522
+ # Named placeholders are not supported
523
+ raise ProgrammingError("Named parameters are not supported; use positional parameters with '?'")
524
+ if parameters is None:
525
+ return ()
526
+ if isinstance(parameters, tuple):
527
+ return parameters
528
+ # Convert arbitrary sequences to tuple efficiently
529
+ return tuple(parameters)
530
+
531
+ def _maybe_implicit_begin(self, sql: str) -> None:
532
+ self._connection._maybe_implicit_begin(sql)
533
+
534
+ def _prepare_single_statement(self, sql: str) -> _Prepared:
535
+ prepared = self._connection._prepare_first(sql)
536
+ # Ensure there are no further statements
537
+ self._connection._raise_if_multiple_statements(sql, prepared.tail_index)
538
+ return prepared
539
+
540
+ def execute(self, sql: str, parameters: Sequence[Any] | Mapping[str, Any] = ()) -> "Cursor":
541
+ self._ensure_open()
542
+ self._reset_last_result()
543
+
544
+ # Implement legacy implicit transactions if needed
545
+ self._maybe_implicit_begin(sql)
546
+
547
+ # Prepare exactly one statement
548
+ prepared = self._prepare_single_statement(sql)
549
+
550
+ stmt = prepared.stmt
551
+ try:
552
+ # Bind positional parameters
553
+ params = self._to_positional_params(parameters)
554
+ if params:
555
+ stmt.bind(params)
556
+
557
+ if prepared.has_columns:
558
+ # Stepped statement (e.g., SELECT or DML with RETURNING)
559
+ self._active_stmt = stmt
560
+ self._active_has_rows = True
561
+ # Set description immediately (even if there are no rows)
562
+ self._description = tuple((name, None, None, None, None, None, None) for name in prepared.column_names)
563
+ # For statements that return rows, DB-API specifies rowcount is -1
564
+ self._rowcount = -1
565
+ # Do not compute lastrowid here
566
+ else:
567
+ # Executed statement (no rows returned)
568
+ result = _run_execute_with_io(stmt)
569
+ # rows_changed from execution result
570
+ self._rowcount = int(result.rows_changed)
571
+ # Set description to None
572
+ self._description = None
573
+ # Set lastrowid for INSERT/REPLACE (best-effort)
574
+ self._lastrowid = self._fetch_last_insert_rowid_if_needed(sql, result.rows_changed)
575
+ # Finalize the statement to release resources
576
+ stmt.finalize()
577
+ except Exception as exc: # noqa: BLE001
578
+ # Ensure cleanup on error
579
+ try:
580
+ stmt.finalize()
581
+ except Exception:
582
+ pass
583
+ raise _map_turso_exception(exc)
584
+
585
+ return self
586
+
587
+ def _fetch_last_insert_rowid_if_needed(self, sql: str, rows_changed: int) -> Optional[int]:
588
+ if rows_changed <= 0 or not _is_insert_or_replace(sql):
589
+ return self._lastrowid
590
+ # Query last_insert_rowid(); this is connection-scoped and cheap
591
+ try:
592
+ q = self._connection._conn.prepare_single("SELECT last_insert_rowid()")
593
+ # No parameters; this produces a single-row single-column result
594
+ # Use stepping to fetch the row
595
+ status = _step_once_with_io(q)
596
+ if status == Status.Row:
597
+ py_row = q.row()
598
+ # row() returns a Python tuple with one element
599
+ # We avoid complex conversions: take first item
600
+ value = tuple(py_row)[0] # type: ignore[call-arg]
601
+ # Finalize to complete
602
+ q.finalize()
603
+ if isinstance(value, int):
604
+ return value
605
+ try:
606
+ return int(value)
607
+ except Exception:
608
+ return self._lastrowid
609
+ # Finalize anyway
610
+ q.finalize()
611
+ except Exception:
612
+ # Ignore errors; lastrowid remains unchanged on failure
613
+ pass
614
+ return self._lastrowid
615
+
616
+ def executemany(self, sql: str, seq_of_parameters: Iterable[Sequence[Any] | Mapping[str, Any]]) -> "Cursor":
617
+ self._ensure_open()
618
+ self._reset_last_result()
619
+
620
+ # executemany only accepts DML; enforce this to match sqlite3 semantics
621
+ if not _is_dml(sql):
622
+ raise ProgrammingError("executemany() requires a single DML (INSERT/UPDATE/DELETE/REPLACE) statement")
623
+
624
+ # Implement legacy implicit transaction: same as execute()
625
+ self._maybe_implicit_begin(sql)
626
+
627
+ prepared = self._prepare_single_statement(sql)
628
+ stmt = prepared.stmt
629
+ try:
630
+ # For executemany, discard any rows produced (even if RETURNING was used)
631
+ # Therefore we ALWAYS use execute() path per-iteration.
632
+ for parameters in seq_of_parameters:
633
+ # Reset previous bindings and program memory before reusing
634
+ stmt.reset()
635
+ params = self._to_positional_params(parameters)
636
+ if params:
637
+ stmt.bind(params)
638
+ result = _run_execute_with_io(stmt)
639
+ # rowcount is "the number of modified rows" for the LAST executed statement only
640
+ self._rowcount = int(result.rows_changed) + (self._rowcount if self._rowcount != -1 else 0)
641
+ # After loop, finalize statement
642
+ stmt.finalize()
643
+ # Cursor description is None for DML executed via executemany()
644
+ self._description = None
645
+ # sqlite3 leaves lastrowid unchanged for executemany
646
+ except Exception as exc: # noqa: BLE001
647
+ try:
648
+ stmt.finalize()
649
+ except Exception:
650
+ pass
651
+ raise _map_turso_exception(exc)
652
+ return self
653
+
654
+ def executescript(self, sql_script: str) -> "Cursor":
655
+ self._ensure_open()
656
+ self._reset_last_result()
657
+
658
+ # sqlite3 behavior: If autocommit is LEGACY and there is a pending transaction, implicitly COMMIT first
659
+ if self._connection._autocommit_mode == "LEGACY" and self._connection.in_transaction:
660
+ try:
661
+ self._connection._exec_ddl_only("COMMIT")
662
+ except Exception as exc: # noqa: BLE001
663
+ raise _map_turso_exception(exc)
664
+
665
+ # Iterate over statements in the script and execute them, discarding rows
666
+ sql = sql_script
667
+ total_rowcount = -1
668
+ try:
669
+ offset = 0
670
+ while True:
671
+ opt = self._connection._conn.prepare_first(sql[offset:])
672
+ if opt is None:
673
+ break
674
+ stmt, tail = opt
675
+ # Note: per DB-API, any resulting rows are discarded
676
+ result = _run_execute_with_io(stmt)
677
+ total_rowcount = int(result.rows_changed) if result.rows_changed > 0 else total_rowcount
678
+ # finalize to ensure completion
679
+ stmt.finalize()
680
+ offset += tail
681
+ except Exception as exc: # noqa: BLE001
682
+ raise _map_turso_exception(exc)
683
+
684
+ self._description = None
685
+ self._rowcount = total_rowcount
686
+ return self
687
+
688
+ def _fetchone_tuple(self) -> Optional[tuple[Any, ...]]:
689
+ """
690
+ Fetch one row as a plain Python tuple, or return None if no more rows.
691
+ """
692
+ if not self._active_has_rows or self._active_stmt is None:
693
+ return None
694
+ try:
695
+ status = _step_once_with_io(self._active_stmt)
696
+ if status == Status.Row:
697
+ row_tuple = tuple(self._active_stmt.row()) # type: ignore[call-arg]
698
+ return row_tuple
699
+ # status == Done: finalize and clean up
700
+ self._active_stmt.finalize()
701
+ self._active_stmt = None
702
+ self._active_has_rows = False
703
+ return None
704
+ except Exception as exc: # noqa: BLE001
705
+ # Finalize and clean up on error
706
+ try:
707
+ if self._active_stmt is not None:
708
+ self._active_stmt.finalize()
709
+ except Exception:
710
+ pass
711
+ self._active_stmt = None
712
+ self._active_has_rows = False
713
+ raise _map_turso_exception(exc)
714
+
715
+ def _apply_row_factory(self, row_values: tuple[Any, ...]) -> Any:
716
+ rf = self.row_factory
717
+ if rf is None:
718
+ return row_values
719
+ if isinstance(rf, type) and issubclass(rf, Row):
720
+ return rf(self, Row(self, row_values)) # type: ignore[call-arg]
721
+ if callable(rf):
722
+ return rf(self, Row(self, row_values)) # type: ignore[misc]
723
+ # Fallback: return tuple
724
+ return row_values
725
+
726
+ def fetchone(self) -> Any:
727
+ self._ensure_open()
728
+ row = self._fetchone_tuple()
729
+ if row is None:
730
+ return None
731
+ return self._apply_row_factory(row)
732
+
733
+ def fetchmany(self, size: Optional[int] = None) -> list[Any]:
734
+ self._ensure_open()
735
+ if size is None:
736
+ size = self.arraysize
737
+ if size < 0:
738
+ raise ValueError("size must be non-negative")
739
+ result: list[Any] = []
740
+ for _ in range(size):
741
+ row = self._fetchone_tuple()
742
+ if row is None:
743
+ break
744
+ result.append(self._apply_row_factory(row))
745
+ return result
746
+
747
+ def fetchall(self) -> list[Any]:
748
+ self._ensure_open()
749
+ result: list[Any] = []
750
+ while True:
751
+ row = self._fetchone_tuple()
752
+ if row is None:
753
+ break
754
+ result.append(self._apply_row_factory(row))
755
+ return result
756
+
757
+ def setinputsizes(self, sizes: Any, /) -> None:
758
+ # No-op for DB-API compliance
759
+ return None
760
+
761
+ def setoutputsize(self, size: Any, column: Any = None, /) -> None:
762
+ # No-op for DB-API compliance
763
+ return None
764
+
765
+ def __iter__(self) -> "Cursor":
766
+ return self
767
+
768
+ def __next__(self) -> Any:
769
+ row = self.fetchone()
770
+ if row is None:
771
+ raise StopIteration
772
+ return row
773
+
774
+
775
+ # Row goes THIRD
776
+ class Row(Sequence[Any]):
777
+ """
778
+ sqlite3.Row-like container supporting index and name-based access.
779
+ """
780
+
781
+ def __new__(cls, cursor: Cursor, data: tuple[Any, ...], /) -> "Row":
782
+ obj = super().__new__(cls)
783
+ # Attach metadata
784
+ obj._cursor = cursor
785
+ obj._data = data
786
+ # Build mapping from column name to index
787
+ desc = cursor.description or ()
788
+ obj._keys = tuple(col[0] for col in desc)
789
+ obj._index = {name: idx for idx, name in enumerate(obj._keys)}
790
+ return obj
791
+
792
+ def keys(self) -> list[str]:
793
+ return list(self._keys)
794
+
795
+ def __getitem__(self, key: int | str | slice, /) -> Any:
796
+ if isinstance(key, slice):
797
+ return self._data[key]
798
+ if isinstance(key, int):
799
+ return self._data[key]
800
+ # key is column name
801
+ idx = self._index.get(key)
802
+ if idx is None:
803
+ raise KeyError(key)
804
+ return self._data[idx]
805
+
806
+ def __hash__(self) -> int:
807
+ return hash((self._keys, self._data))
808
+
809
+ def __iter__(self) -> Iterator[Any]:
810
+ return iter(self._data)
811
+
812
+ def __len__(self) -> int:
813
+ return len(self._data)
814
+
815
+ def __eq__(self, value: object, /) -> bool:
816
+ if not isinstance(value, Row):
817
+ return NotImplemented # type: ignore[return-value]
818
+ return self._keys == value._keys and self._data == value._data
819
+
820
+ def __ne__(self, value: object, /) -> bool:
821
+ if not isinstance(value, Row):
822
+ return NotImplemented # type: ignore[return-value]
823
+ return not self.__eq__(value)
824
+
825
+ # The rest return NotImplemented for non-Row comparisons
826
+ def __lt__(self, value: object, /) -> bool:
827
+ if not isinstance(value, Row):
828
+ return NotImplemented # type: ignore[return-value]
829
+ return (self._keys, self._data) < (value._keys, value._data)
830
+
831
+ def __le__(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 __gt__(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 __ge__(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
+
847
+ def connect(
848
+ database: str,
849
+ *,
850
+ experimental_features: Optional[str] = None,
851
+ isolation_level: Optional[str] = "DEFERRED",
852
+ ) -> Connection:
853
+ """
854
+ Open a Turso (SQLite-compatible) database and return a Connection.
855
+
856
+ Parameters:
857
+ - database: path or identifier of the database.
858
+ - experimental_features: comma-separated list of features to enable.
859
+ - isolation_level: one of "DEFERRED" (default), "IMMEDIATE", "EXCLUSIVE", or None.
860
+ """
861
+ try:
862
+ cfg = PyTursoDatabaseConfig(
863
+ path=database,
864
+ experimental_features=experimental_features,
865
+ async_io=False, # Let the Rust layer drive IO internally by default
866
+ )
867
+ db: PyTursoDatabase = py_turso_database_open(cfg)
868
+ conn: PyTursoConnection = db.connect()
869
+ return Connection(conn, isolation_level=isolation_level)
870
+ except Exception as exc: # noqa: BLE001
871
+ raise _map_turso_exception(exc)
872
+
873
+
874
+ # Make it easy to enable logging with native `logging` Python module
875
+ def setup_logging(level: int = logging.INFO) -> None:
876
+ """
877
+ Setup Turso logging to integrate with Python's logging module.
878
+
879
+ Usage:
880
+ import turso
881
+ turso.setup_logging(logging.DEBUG)
882
+ """
883
+ logger = logging.getLogger("turso")
884
+ logger.setLevel(level)
885
+
886
+ def _py_logger(log: PyTursoLog) -> None:
887
+ # Map Rust/Turso log level strings to Python logging levels (best-effort)
888
+ lvl_map = {
889
+ "ERROR": logging.ERROR,
890
+ "WARN": logging.WARNING,
891
+ "WARNING": logging.WARNING,
892
+ "INFO": logging.INFO,
893
+ "DEBUG": logging.DEBUG,
894
+ "TRACE": logging.DEBUG,
895
+ }
896
+ py_level = lvl_map.get(log.level.upper(), level)
897
+ logger.log(
898
+ py_level,
899
+ "%s [%s:%s] %s",
900
+ log.target,
901
+ log.file,
902
+ log.line,
903
+ log.message,
904
+ )
905
+
906
+ try:
907
+ py_turso_setup(PyTursoSetupConfig(logger=_py_logger, log_level=None))
908
+ except Exception as exc: # noqa: BLE001
909
+ raise _map_turso_exception(exc)
turso/py.typed ADDED
File without changes