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.
- pyturso-0.4.0rc4.dist-info/METADATA +104 -0
- pyturso-0.4.0rc4.dist-info/RECORD +7 -0
- pyturso-0.4.0rc4.dist-info/WHEEL +5 -0
- turso/__init__.py +41 -0
- turso/_turso.cpython-312-x86_64-linux-gnu.so +0 -0
- turso/lib.py +909 -0
- turso/py.typed +0 -0
|
@@ -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,,
|
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
|
+
]
|
|
Binary file
|
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
|