pyweb-stack 0.1.0__py3-none-any.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.
- pyweb/__init__.py +71 -0
- pyweb/app.py +37 -0
- pyweb/app_loader.py +139 -0
- pyweb/asgi.py +71 -0
- pyweb/auth.py +701 -0
- pyweb/bench.py +101 -0
- pyweb/browser.py +232 -0
- pyweb/build.py +295 -0
- pyweb/cache.py +201 -0
- pyweb/cli/__init__.py +465 -0
- pyweb/cli/__main__.py +3 -0
- pyweb/compiler/__init__.py +5 -0
- pyweb/compiler/ast.py +45 -0
- pyweb/compiler/codegen/__init__.py +9 -0
- pyweb/compiler/codegen/ir.py +50 -0
- pyweb/compiler/errors.py +29 -0
- pyweb/compiler/lower.py +733 -0
- pyweb/compiler/parser.py +532 -0
- pyweb/compiler/pipeline.py +158 -0
- pyweb/compiler/pyjs.py +1087 -0
- pyweb/compiler/rpc.py +35 -0
- pyweb/context.py +193 -0
- pyweb/css.py +115 -0
- pyweb/db/__init__.py +652 -0
- pyweb/db/migrate.py +143 -0
- pyweb/decorators.py +49 -0
- pyweb/deploy.py +102 -0
- pyweb/forms.py +108 -0
- pyweb/hosting.py +139 -0
- pyweb/jobs.py +248 -0
- pyweb/live.py +88 -0
- pyweb/lsp.py +214 -0
- pyweb/models.py +269 -0
- pyweb/npm.py +270 -0
- pyweb/observability.py +313 -0
- pyweb/platform.py +69 -0
- pyweb/plugins.py +112 -0
- pyweb/py.typed +0 -0
- pyweb/realtime.py +189 -0
- pyweb/rpc.py +137 -0
- pyweb/runtime/browser/runtime.js +1151 -0
- pyweb/runtime/server/__init__.py +445 -0
- pyweb/security.py +162 -0
- pyweb/serve.py +335 -0
- pyweb/ssr.py +260 -0
- pyweb/sync.py +118 -0
- pyweb/testing.py +154 -0
- pyweb/uploads.py +94 -0
- pyweb_stack-0.1.0.dist-info/METADATA +198 -0
- pyweb_stack-0.1.0.dist-info/RECORD +54 -0
- pyweb_stack-0.1.0.dist-info/WHEEL +5 -0
- pyweb_stack-0.1.0.dist-info/entry_points.txt +2 -0
- pyweb_stack-0.1.0.dist-info/licenses/LICENSE +21 -0
- pyweb_stack-0.1.0.dist-info/top_level.txt +1 -0
pyweb/db/__init__.py
ADDED
|
@@ -0,0 +1,652 @@
|
|
|
1
|
+
"""DB toolkit: parameterized query builder, SQLite driver with pooling and
|
|
2
|
+
transactions, migration autogen/apply, Postgres guard, pagination."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import queue
|
|
8
|
+
import re
|
|
9
|
+
import sqlite3
|
|
10
|
+
import threading
|
|
11
|
+
from contextlib import contextmanager
|
|
12
|
+
|
|
13
|
+
_OPS = {
|
|
14
|
+
"eq": "=", "gt": ">", "lt": "<", "gte": ">=", "lte": "<=",
|
|
15
|
+
"ne": "!=", "like": "LIKE",
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
_IDENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def qmark_to_format(sql):
|
|
22
|
+
"""``?`` → ``%s`` outside quotes; literal ``%`` → ``%%``.
|
|
23
|
+
|
|
24
|
+
SQL that already uses ``%s`` (no ``?`` placeholders) is returned
|
|
25
|
+
unchanged, so driver-native SQL keeps working.
|
|
26
|
+
"""
|
|
27
|
+
out, quote, found = [], None, False
|
|
28
|
+
for ch in sql:
|
|
29
|
+
if quote:
|
|
30
|
+
out.append("%%" if ch == "%" else ch)
|
|
31
|
+
if ch == quote:
|
|
32
|
+
quote = None
|
|
33
|
+
continue
|
|
34
|
+
if ch in ("'", '"'):
|
|
35
|
+
quote = ch
|
|
36
|
+
out.append(ch)
|
|
37
|
+
elif ch == "?":
|
|
38
|
+
found = True
|
|
39
|
+
out.append("%s")
|
|
40
|
+
elif ch == "%":
|
|
41
|
+
out.append("%%")
|
|
42
|
+
else:
|
|
43
|
+
out.append(ch)
|
|
44
|
+
return "".join(out) if found else sql
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _ident(name, kind="identifier"):
|
|
48
|
+
if not isinstance(name, str) or not _IDENT.match(name):
|
|
49
|
+
raise ValueError(f"invalid {kind}: {name!r}")
|
|
50
|
+
return name
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Query:
|
|
54
|
+
"""Build PARAMETERIZED SQL only. Values always become placeholders."""
|
|
55
|
+
|
|
56
|
+
def __init__(self, table, paramstyle="qmark"):
|
|
57
|
+
self.table = table
|
|
58
|
+
self._table = _ident(table, "table")
|
|
59
|
+
if paramstyle not in ("qmark", "format"):
|
|
60
|
+
raise ValueError(f"unknown paramstyle: {paramstyle!r}")
|
|
61
|
+
self._paramstyle = paramstyle
|
|
62
|
+
self._select = ("*",)
|
|
63
|
+
self._wheres: list[tuple] = []
|
|
64
|
+
self._order: list = []
|
|
65
|
+
self._raw_order: tuple = ()
|
|
66
|
+
self._limit = None
|
|
67
|
+
self._offset = None
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def _ph(self):
|
|
71
|
+
return "?" if self._paramstyle == "qmark" else "%s"
|
|
72
|
+
|
|
73
|
+
def select(self, *cols):
|
|
74
|
+
if cols:
|
|
75
|
+
self._select = tuple(_ident(c, "column") for c in cols)
|
|
76
|
+
return self
|
|
77
|
+
|
|
78
|
+
def where(self, **kwargs):
|
|
79
|
+
for key, value in kwargs.items():
|
|
80
|
+
if "__" in key:
|
|
81
|
+
field, op = key.rsplit("__", 1)
|
|
82
|
+
if op in _OPS:
|
|
83
|
+
self._wheres.append((field, _OPS[op], value))
|
|
84
|
+
continue
|
|
85
|
+
self._wheres.append((_ident(key, "column"), value))
|
|
86
|
+
else:
|
|
87
|
+
_ident(key, "column")
|
|
88
|
+
self._wheres.append((key, "=", value))
|
|
89
|
+
return self
|
|
90
|
+
|
|
91
|
+
def _record_where(self, col, val):
|
|
92
|
+
if isinstance(val, tuple) and len(val) == 2 and val[0] in ("=", ">", "<", ">=", "<=", "!=", "LIKE"):
|
|
93
|
+
self._wheres.append((col, val))
|
|
94
|
+
else:
|
|
95
|
+
self._wheres.append((col, val))
|
|
96
|
+
|
|
97
|
+
def order_by(self, field=None, *rest, **kw):
|
|
98
|
+
if field is not None and not rest and (not kw or set(kw) == {"desc"}):
|
|
99
|
+
desc = kw.get("desc", False)
|
|
100
|
+
if field.startswith("-"):
|
|
101
|
+
field, desc = field[1:], True
|
|
102
|
+
self._order.append((_ident(field, "column"), desc))
|
|
103
|
+
return self
|
|
104
|
+
cols = (field,) + tuple(rest) if field is not None else ()
|
|
105
|
+
for col in cols:
|
|
106
|
+
if col.startswith("-"):
|
|
107
|
+
self._order.append((_ident(col[1:], "column"), True))
|
|
108
|
+
else:
|
|
109
|
+
self._order.append((_ident(col, "column"), False))
|
|
110
|
+
self._raw_order = cols
|
|
111
|
+
return self
|
|
112
|
+
|
|
113
|
+
def limit(self, n):
|
|
114
|
+
if not isinstance(n, int) or isinstance(n, bool) or n < 0:
|
|
115
|
+
raise ValueError(f"invalid LIMIT: {n!r}")
|
|
116
|
+
self._limit = n
|
|
117
|
+
return self
|
|
118
|
+
|
|
119
|
+
def offset(self, n):
|
|
120
|
+
self._offset = n
|
|
121
|
+
return self
|
|
122
|
+
|
|
123
|
+
def paginate(self, page=1, per_page=20):
|
|
124
|
+
return self.limit(per_page).offset((page - 1) * per_page)
|
|
125
|
+
|
|
126
|
+
def _where_clause(self, params):
|
|
127
|
+
parts = []
|
|
128
|
+
for entry in self._wheres:
|
|
129
|
+
if len(entry) == 3:
|
|
130
|
+
col, op, val = entry
|
|
131
|
+
else:
|
|
132
|
+
col, val = entry
|
|
133
|
+
op = "="
|
|
134
|
+
if isinstance(val, (list, tuple)) and op == "=":
|
|
135
|
+
if not val:
|
|
136
|
+
parts.append("0 = 1")
|
|
137
|
+
else:
|
|
138
|
+
parts.append(f"{col} IN ({', '.join([self._ph] * len(val))})")
|
|
139
|
+
params.extend(val)
|
|
140
|
+
elif val is None:
|
|
141
|
+
parts.append(f'"{col}" IS NULL')
|
|
142
|
+
else:
|
|
143
|
+
parts.append(f'"{col}" {op} {self._ph}')
|
|
144
|
+
params.append(val)
|
|
145
|
+
return " AND ".join(parts)
|
|
146
|
+
|
|
147
|
+
def _select_sql(self):
|
|
148
|
+
params: list = []
|
|
149
|
+
cols = ", ".join(self._select)
|
|
150
|
+
q = f'SELECT {cols} FROM "{self._table}"'
|
|
151
|
+
if self._wheres:
|
|
152
|
+
q += " WHERE " + self._where_clause(params)
|
|
153
|
+
if self._order:
|
|
154
|
+
q += " ORDER BY " + ", ".join(f'"{f}" {"DESC" if d else "ASC"}' for f, d in self._order)
|
|
155
|
+
if self._limit is not None:
|
|
156
|
+
q += f" LIMIT {int(self._limit)}"
|
|
157
|
+
if self._offset is not None:
|
|
158
|
+
q += f" OFFSET {int(self._offset)}"
|
|
159
|
+
return q, params
|
|
160
|
+
|
|
161
|
+
def sql(self):
|
|
162
|
+
return self._select_sql()
|
|
163
|
+
|
|
164
|
+
def build_select(self):
|
|
165
|
+
return self._select_sql()
|
|
166
|
+
|
|
167
|
+
def build_insert(self, data):
|
|
168
|
+
if not data:
|
|
169
|
+
raise ValueError("cannot build INSERT with no columns")
|
|
170
|
+
cols = [_ident(k, "column") for k in data]
|
|
171
|
+
sql = (f"INSERT INTO {self._table} ({', '.join(cols)}) "
|
|
172
|
+
f"VALUES ({', '.join([self._ph] * len(cols))})")
|
|
173
|
+
return sql, list(data.values())
|
|
174
|
+
|
|
175
|
+
def build_update(self, data):
|
|
176
|
+
if not data:
|
|
177
|
+
raise ValueError("cannot build UPDATE with no columns")
|
|
178
|
+
if not self._wheres:
|
|
179
|
+
raise ValueError("refusing unqualified UPDATE without WHERE")
|
|
180
|
+
cols = [_ident(k, "column") for k in data]
|
|
181
|
+
params = list(data.values())
|
|
182
|
+
sql = f"UPDATE {self._table} SET " + ", ".join(f"{c} = {self._ph}" for c in cols)
|
|
183
|
+
sql += " WHERE " + self._where_clause(params)
|
|
184
|
+
return sql, params
|
|
185
|
+
|
|
186
|
+
def build_delete(self):
|
|
187
|
+
if not self._wheres:
|
|
188
|
+
raise ValueError("refusing unqualified DELETE without WHERE")
|
|
189
|
+
params: list = []
|
|
190
|
+
return f"DELETE FROM {self._table} WHERE " + self._where_clause(params), params
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class Migration:
|
|
194
|
+
def __init__(self, name, statements):
|
|
195
|
+
self.name = name
|
|
196
|
+
self.statements = list(statements)
|
|
197
|
+
|
|
198
|
+
def apply(self, conn):
|
|
199
|
+
for stmt in self.statements:
|
|
200
|
+
conn.execute(stmt)
|
|
201
|
+
conn.commit()
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class Schema:
|
|
205
|
+
def __init__(self):
|
|
206
|
+
self.migrations: list[Migration] = []
|
|
207
|
+
self.applied: list[str] = []
|
|
208
|
+
|
|
209
|
+
def add(self, migration):
|
|
210
|
+
self.migrations.append(migration)
|
|
211
|
+
|
|
212
|
+
def migrate(self, conn):
|
|
213
|
+
for m in self.migrations:
|
|
214
|
+
if m.name not in self.applied:
|
|
215
|
+
m.apply(conn)
|
|
216
|
+
self.applied.append(m.name)
|
|
217
|
+
return list(self.applied)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class Transaction:
|
|
221
|
+
def __init__(self, conn):
|
|
222
|
+
self.conn = conn
|
|
223
|
+
|
|
224
|
+
def __enter__(self):
|
|
225
|
+
self.conn.execute("BEGIN")
|
|
226
|
+
return self.conn
|
|
227
|
+
|
|
228
|
+
def __exit__(self, exc_type, exc, tb):
|
|
229
|
+
if exc_type is None:
|
|
230
|
+
self.conn.commit()
|
|
231
|
+
else:
|
|
232
|
+
self.conn.rollback()
|
|
233
|
+
return False
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class Result:
|
|
237
|
+
def __init__(self, rows, columns, lastrowid, rowcount):
|
|
238
|
+
self._rows = list(rows)
|
|
239
|
+
self.columns = list(columns)
|
|
240
|
+
self.lastrowid = lastrowid
|
|
241
|
+
self.rowcount = rowcount
|
|
242
|
+
|
|
243
|
+
def fetchall(self):
|
|
244
|
+
return list(self._rows)
|
|
245
|
+
|
|
246
|
+
def fetchone(self):
|
|
247
|
+
return self._rows[0] if self._rows else None
|
|
248
|
+
|
|
249
|
+
def dicts(self):
|
|
250
|
+
return [dict(zip(self.columns, r)) for r in self._rows]
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
class TransientDBError(Exception):
|
|
254
|
+
"""Retriable error (deadlock, serialization failure, conn reset)."""
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
class _PooledDB:
|
|
258
|
+
"""Shared DB-API pool: thread-local transactions, prepared statements,
|
|
259
|
+
streaming cursors, prepared-statement cache, retriable-error mapping.
|
|
260
|
+
|
|
261
|
+
Why one base: SQLite/Postgres/MySQL all speak DB-API; the differences
|
|
262
|
+
are connect(), paramstyle, and error classes. Alternatives considered:
|
|
263
|
+
SQLAlchemy (heavy dep, hides the SQL we want visible for placement
|
|
264
|
+
analysis). This keeps ``Query``-built SQL inspectable by the compiler.
|
|
265
|
+
"""
|
|
266
|
+
|
|
267
|
+
paramstyle = "qmark"
|
|
268
|
+
|
|
269
|
+
def _connect(self):
|
|
270
|
+
raise NotImplementedError
|
|
271
|
+
|
|
272
|
+
def _is_transient(self, exc: Exception) -> bool:
|
|
273
|
+
return False
|
|
274
|
+
|
|
275
|
+
def __init__(self, pool_size=5, timeout=10.0, statement_cache=128,
|
|
276
|
+
connect_kwargs=None):
|
|
277
|
+
# Connections open lazily (up to pool_size), so importing an app
|
|
278
|
+
# never fails just because the database is briefly unreachable.
|
|
279
|
+
self._pool: queue.Queue = queue.Queue()
|
|
280
|
+
self._pool_size = max(1, pool_size)
|
|
281
|
+
self._pool_timeout = timeout
|
|
282
|
+
self._created = 0
|
|
283
|
+
self._create_lock = threading.Lock()
|
|
284
|
+
self._local = threading.local()
|
|
285
|
+
self._stmt_cache_size = statement_cache
|
|
286
|
+
self._stmt_cache: dict[str, str] = {}
|
|
287
|
+
|
|
288
|
+
def _current(self):
|
|
289
|
+
return getattr(self._local, "conn", None)
|
|
290
|
+
|
|
291
|
+
def _acquire(self):
|
|
292
|
+
try:
|
|
293
|
+
return self._pool.get_nowait()
|
|
294
|
+
except queue.Empty:
|
|
295
|
+
pass
|
|
296
|
+
with self._create_lock:
|
|
297
|
+
if self._created < self._pool_size:
|
|
298
|
+
conn = self._connect()
|
|
299
|
+
self._created += 1
|
|
300
|
+
return conn
|
|
301
|
+
try:
|
|
302
|
+
return self._pool.get(timeout=self._pool_timeout)
|
|
303
|
+
except queue.Empty:
|
|
304
|
+
raise TransientDBError(
|
|
305
|
+
f"no database connection available within {self._pool_timeout}s "
|
|
306
|
+
f"(pool_size={self._pool_size})") from None
|
|
307
|
+
|
|
308
|
+
def _adapt(self, sql, params):
|
|
309
|
+
"""Rewrite portable ``?`` placeholders for ``format``-style drivers."""
|
|
310
|
+
if self.paramstyle != "format" or not params:
|
|
311
|
+
return sql
|
|
312
|
+
return qmark_to_format(sql)
|
|
313
|
+
|
|
314
|
+
def prepare(self, sql: str) -> str:
|
|
315
|
+
"""Cache/validate a statement; returns the (possibly rewritten) SQL.
|
|
316
|
+
|
|
317
|
+
DB-API has no cross-driver prepare handle, so this caches the
|
|
318
|
+
validated statement text and returns it for ``execute(prepared)``.
|
|
319
|
+
Drivers that support server-side prepares (psycopg) get them via
|
|
320
|
+
``execute(..., prepare=True)``.
|
|
321
|
+
"""
|
|
322
|
+
cached = self._stmt_cache.get(sql)
|
|
323
|
+
if cached is not None:
|
|
324
|
+
return cached
|
|
325
|
+
if len(self._stmt_cache) >= self._stmt_cache_size:
|
|
326
|
+
self._stmt_cache.pop(next(iter(self._stmt_cache)))
|
|
327
|
+
self._stmt_cache[sql] = sql
|
|
328
|
+
return sql
|
|
329
|
+
|
|
330
|
+
def execute(self, sql, params=(), *, prepare=False, attempts=1):
|
|
331
|
+
params = tuple(params)
|
|
332
|
+
conn = self._current()
|
|
333
|
+
owned = conn is None
|
|
334
|
+
if owned:
|
|
335
|
+
conn = self._acquire()
|
|
336
|
+
sql = self._adapt(sql, params)
|
|
337
|
+
try:
|
|
338
|
+
last: Exception | None = None
|
|
339
|
+
for _ in range(max(1, attempts)):
|
|
340
|
+
try:
|
|
341
|
+
if prepare:
|
|
342
|
+
sql = self.prepare(sql)
|
|
343
|
+
if hasattr(conn, "execute"):
|
|
344
|
+
cur = conn.execute(sql, params)
|
|
345
|
+
else: # psycopg-style cursor protocol
|
|
346
|
+
cur = conn.cursor()
|
|
347
|
+
cur.execute(sql, params)
|
|
348
|
+
rows = cur.fetchall() if cur.description is not None else []
|
|
349
|
+
cols = [d[0] for d in cur.description] if cur.description else []
|
|
350
|
+
lastrowid = getattr(cur, "lastrowid", None)
|
|
351
|
+
res = Result(rows, cols, lastrowid, cur.rowcount)
|
|
352
|
+
try:
|
|
353
|
+
cur.close()
|
|
354
|
+
except Exception:
|
|
355
|
+
pass
|
|
356
|
+
if owned:
|
|
357
|
+
conn.commit()
|
|
358
|
+
return res
|
|
359
|
+
except Exception as exc: # noqa: BLE001
|
|
360
|
+
last = exc
|
|
361
|
+
try:
|
|
362
|
+
conn.rollback()
|
|
363
|
+
except Exception:
|
|
364
|
+
pass
|
|
365
|
+
if not self._is_transient(exc):
|
|
366
|
+
raise
|
|
367
|
+
raise TransientDBError(
|
|
368
|
+
f"transient failure after {attempts} attempts: {last}")
|
|
369
|
+
finally:
|
|
370
|
+
if owned:
|
|
371
|
+
self._pool.put(conn)
|
|
372
|
+
|
|
373
|
+
def stream(self, sql, params=(), *, chunksize=1000):
|
|
374
|
+
"""Yield ``Result`` pages without loading the full result set."""
|
|
375
|
+
conn = self._acquire()
|
|
376
|
+
sql = self._adapt(sql, params)
|
|
377
|
+
try:
|
|
378
|
+
cur = conn.cursor() if hasattr(conn, "cursor") else None
|
|
379
|
+
if cur is None:
|
|
380
|
+
res = self.execute(sql, params)
|
|
381
|
+
yield res
|
|
382
|
+
return
|
|
383
|
+
cur.execute(sql, tuple(params))
|
|
384
|
+
cols = [d[0] for d in cur.description] if cur.description else []
|
|
385
|
+
while True:
|
|
386
|
+
rows = cur.fetchmany(chunksize)
|
|
387
|
+
if not rows:
|
|
388
|
+
break
|
|
389
|
+
yield Result(rows, cols, None, len(rows))
|
|
390
|
+
try:
|
|
391
|
+
cur.close()
|
|
392
|
+
except Exception:
|
|
393
|
+
pass
|
|
394
|
+
finally:
|
|
395
|
+
self._pool.put(conn)
|
|
396
|
+
|
|
397
|
+
@contextmanager
|
|
398
|
+
def transaction(self):
|
|
399
|
+
if self._current() is not None:
|
|
400
|
+
yield self
|
|
401
|
+
return
|
|
402
|
+
conn = self._acquire()
|
|
403
|
+
self._local.conn = conn
|
|
404
|
+
# Drivers in autocommit mode (Postgres/MySQL pools) must leave it for
|
|
405
|
+
# the duration, or every statement commits and rollback is a no-op.
|
|
406
|
+
prev_autocommit = None
|
|
407
|
+
try:
|
|
408
|
+
if isinstance(getattr(conn, "autocommit", None), bool):
|
|
409
|
+
prev_autocommit = conn.autocommit
|
|
410
|
+
conn.autocommit = False
|
|
411
|
+
elif hasattr(conn, "execute"):
|
|
412
|
+
try:
|
|
413
|
+
conn.execute("BEGIN")
|
|
414
|
+
except Exception:
|
|
415
|
+
pass
|
|
416
|
+
yield self
|
|
417
|
+
conn.commit()
|
|
418
|
+
except Exception:
|
|
419
|
+
try:
|
|
420
|
+
conn.rollback()
|
|
421
|
+
except Exception:
|
|
422
|
+
pass
|
|
423
|
+
raise
|
|
424
|
+
finally:
|
|
425
|
+
if prev_autocommit is not None:
|
|
426
|
+
try:
|
|
427
|
+
conn.autocommit = prev_autocommit
|
|
428
|
+
except Exception:
|
|
429
|
+
pass
|
|
430
|
+
self._local.conn = None
|
|
431
|
+
self._pool.put(conn)
|
|
432
|
+
|
|
433
|
+
def close(self):
|
|
434
|
+
while True:
|
|
435
|
+
try:
|
|
436
|
+
conn = self._pool.get_nowait()
|
|
437
|
+
except queue.Empty:
|
|
438
|
+
break
|
|
439
|
+
with self._create_lock:
|
|
440
|
+
self._created -= 1
|
|
441
|
+
try:
|
|
442
|
+
conn.close()
|
|
443
|
+
except Exception:
|
|
444
|
+
pass
|
|
445
|
+
|
|
446
|
+
def __del__(self): # best-effort: never leak pool connections at GC
|
|
447
|
+
try:
|
|
448
|
+
self.close()
|
|
449
|
+
except Exception:
|
|
450
|
+
pass
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
class SQLiteDB(_PooledDB):
|
|
454
|
+
"""SQLite driver over stdlib sqlite3 with a pooled wrapper + transactions."""
|
|
455
|
+
|
|
456
|
+
paramstyle = "qmark"
|
|
457
|
+
|
|
458
|
+
def __init__(self, path=":memory:", pool_size=5, timeout=10.0, **kw):
|
|
459
|
+
if path == ":memory:":
|
|
460
|
+
pool_size = 1
|
|
461
|
+
self.path = path
|
|
462
|
+
self._timeout = timeout
|
|
463
|
+
self._kw = kw
|
|
464
|
+
super().__init__(pool_size=pool_size, timeout=timeout)
|
|
465
|
+
|
|
466
|
+
def _connect(self):
|
|
467
|
+
return sqlite3.connect(self.path, check_same_thread=False,
|
|
468
|
+
timeout=self._timeout)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def snapshot(models):
|
|
472
|
+
return {m.__table__: m.schema_sql() for m in models}
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def autogen(models, outdir="migrations"):
|
|
476
|
+
"""Diff models into SQL up/down migration files stored in outdir."""
|
|
477
|
+
os.makedirs(outdir, exist_ok=True)
|
|
478
|
+
ups = ["-- PyWeb autogenerated migration (up)."]
|
|
479
|
+
downs = ["-- PyWeb autogenerated migration (down)."]
|
|
480
|
+
for m in models:
|
|
481
|
+
ups.append(m.schema_sql() + ";")
|
|
482
|
+
for m in reversed(list(models)):
|
|
483
|
+
downs.append(f"DROP TABLE IF EXISTS {m.__table__};")
|
|
484
|
+
up_path = os.path.join(outdir, "schema.up.sql")
|
|
485
|
+
down_path = os.path.join(outdir, "schema.down.sql")
|
|
486
|
+
with open(up_path, "w") as f:
|
|
487
|
+
f.write("\n".join(ups) + "\n")
|
|
488
|
+
with open(down_path, "w") as f:
|
|
489
|
+
f.write("\n".join(downs) + "\n")
|
|
490
|
+
return {"up": up_path, "down": down_path}
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _split_statements(script):
|
|
494
|
+
lines = [ln for ln in script.splitlines() if not ln.strip().startswith("--")]
|
|
495
|
+
return [s.strip() for s in "\n".join(lines).split(";") if s.strip()]
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def apply(db, outdir="migrations", direction="up"):
|
|
499
|
+
if direction not in ("up", "down"):
|
|
500
|
+
raise ValueError(f"unknown direction: {direction!r}")
|
|
501
|
+
path = os.path.join(outdir, f"schema.{direction}.sql")
|
|
502
|
+
if not os.path.exists(path):
|
|
503
|
+
raise FileNotFoundError(f"no such migration file: {path}")
|
|
504
|
+
with open(path) as f:
|
|
505
|
+
statements = _split_statements(f.read())
|
|
506
|
+
with db.transaction():
|
|
507
|
+
for stmt in statements:
|
|
508
|
+
db.execute(stmt)
|
|
509
|
+
return len(statements)
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
class PostgresDB(_PooledDB):
|
|
513
|
+
"""PostgreSQL driver over psycopg (v3) or psycopg2.
|
|
514
|
+
|
|
515
|
+
Retries deadlocks (40P01) and serialization failures (40001);
|
|
516
|
+
autocommits single statements outside explicit transactions.
|
|
517
|
+
"""
|
|
518
|
+
|
|
519
|
+
paramstyle = "format"
|
|
520
|
+
|
|
521
|
+
TRANSIENT_CODES = frozenset({"40P01", "40001", "55P03", "08006", "08003"})
|
|
522
|
+
|
|
523
|
+
def __init__(self, dsn=None, *, pool_size=5, timeout=10.0,
|
|
524
|
+
connect=None, **kwargs):
|
|
525
|
+
if connect is not None:
|
|
526
|
+
self._factory = connect
|
|
527
|
+
self._psycopg = None
|
|
528
|
+
else:
|
|
529
|
+
try:
|
|
530
|
+
import psycopg as _pg
|
|
531
|
+
self._factory = lambda: _pg.connect(dsn or "", **kwargs)
|
|
532
|
+
self._psycopg = _pg
|
|
533
|
+
except ImportError:
|
|
534
|
+
try:
|
|
535
|
+
import psycopg2 as _pg2
|
|
536
|
+
self._factory = lambda: _pg2.connect(dsn or "", **kwargs)
|
|
537
|
+
self._psycopg = _pg2
|
|
538
|
+
except ImportError as e:
|
|
539
|
+
raise RuntimeError(
|
|
540
|
+
"PostgresDB requires the 'psycopg' package: "
|
|
541
|
+
"pip install \"psycopg[binary]\"") from e
|
|
542
|
+
super().__init__(pool_size=pool_size, timeout=timeout)
|
|
543
|
+
|
|
544
|
+
def _connect(self):
|
|
545
|
+
conn = self._factory()
|
|
546
|
+
try:
|
|
547
|
+
conn.autocommit = True
|
|
548
|
+
except Exception:
|
|
549
|
+
pass
|
|
550
|
+
return conn
|
|
551
|
+
|
|
552
|
+
def _is_transient(self, exc):
|
|
553
|
+
code = getattr(exc, "sqlstate", None) or getattr(exc, "pgcode", None)
|
|
554
|
+
if code in self.TRANSIENT_CODES:
|
|
555
|
+
return True
|
|
556
|
+
msg = str(exc).lower()
|
|
557
|
+
return any(k in msg for k in ("deadlock", "serialization failure",
|
|
558
|
+
"connection reset", "server closed"))
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
class MySQLDB(_PooledDB):
|
|
562
|
+
"""MySQL driver over mysql-connector-python or PyMySQL.
|
|
563
|
+
|
|
564
|
+
Retries deadlocks (1213) and lock timeouts (1205).
|
|
565
|
+
"""
|
|
566
|
+
|
|
567
|
+
paramstyle = "format"
|
|
568
|
+
|
|
569
|
+
TRANSIENT_CODES = frozenset({1213, 1205, 2006, 2013})
|
|
570
|
+
|
|
571
|
+
def __init__(self, dsn=None, *, pool_size=5, timeout=10.0,
|
|
572
|
+
connect=None, **kwargs):
|
|
573
|
+
if connect is not None:
|
|
574
|
+
self._factory = connect
|
|
575
|
+
else:
|
|
576
|
+
self._factory = self._default_factory(dsn, kwargs)
|
|
577
|
+
super().__init__(pool_size=pool_size, timeout=timeout)
|
|
578
|
+
|
|
579
|
+
@staticmethod
|
|
580
|
+
def _default_factory(dsn, kwargs):
|
|
581
|
+
try:
|
|
582
|
+
import mysql.connector as _mc
|
|
583
|
+
|
|
584
|
+
def make():
|
|
585
|
+
if dsn:
|
|
586
|
+
from urllib.parse import urlparse as _up
|
|
587
|
+
u = _up(dsn)
|
|
588
|
+
return _mc.connect(
|
|
589
|
+
host=u.hostname or "localhost",
|
|
590
|
+
port=u.port or 3306,
|
|
591
|
+
user=u.username or "",
|
|
592
|
+
password=u.password or "",
|
|
593
|
+
database=(u.path or "/")[1:] or None,
|
|
594
|
+
**kwargs)
|
|
595
|
+
return _mc.connect(**kwargs)
|
|
596
|
+
return make
|
|
597
|
+
except ImportError:
|
|
598
|
+
pass
|
|
599
|
+
try:
|
|
600
|
+
import pymysql as _pm
|
|
601
|
+
|
|
602
|
+
def make2():
|
|
603
|
+
if dsn:
|
|
604
|
+
from urllib.parse import urlparse as _up2
|
|
605
|
+
u = _up2(dsn)
|
|
606
|
+
return _pm.connect(
|
|
607
|
+
host=u.hostname or "localhost",
|
|
608
|
+
port=u.port or 3306,
|
|
609
|
+
user=u.username or "",
|
|
610
|
+
password=u.password or "",
|
|
611
|
+
database=(u.path or "/")[1:] or None,
|
|
612
|
+
**kwargs)
|
|
613
|
+
return _pm.connect(**kwargs)
|
|
614
|
+
return make2
|
|
615
|
+
except ImportError as e:
|
|
616
|
+
raise RuntimeError(
|
|
617
|
+
"MySQLDB requires 'mysql-connector-python' or 'PyMySQL': "
|
|
618
|
+
"pip install mysql-connector-python") from e
|
|
619
|
+
|
|
620
|
+
def _connect(self):
|
|
621
|
+
return self._factory()
|
|
622
|
+
|
|
623
|
+
def _is_transient(self, exc):
|
|
624
|
+
code = getattr(exc, "errno", None)
|
|
625
|
+
if code in self.TRANSIENT_CODES:
|
|
626
|
+
return True
|
|
627
|
+
msg = str(exc).lower()
|
|
628
|
+
return "deadlock" in msg or "lock wait timeout" in msg
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def connect(url: str, **kwargs):
|
|
632
|
+
"""Open a database from a URL: ``sqlite://``, ``postgres://``,
|
|
633
|
+
``postgresql://``, ``mysql://``. ``:memory:`` SQLite for tests."""
|
|
634
|
+
from urllib.parse import urlparse as _up
|
|
635
|
+
if url in (":memory:", "sqlite:///:memory:", "sqlite://"):
|
|
636
|
+
return SQLiteDB(":memory:")
|
|
637
|
+
u = _up(url)
|
|
638
|
+
scheme = u.scheme.lower()
|
|
639
|
+
if scheme in ("sqlite", "sqlite3", ""):
|
|
640
|
+
# SQLAlchemy convention: sqlite:///app.db is relative to the working
|
|
641
|
+
# directory, sqlite:////var/data/app.db is absolute.
|
|
642
|
+
if "://" in url:
|
|
643
|
+
rest = url.split("://", 1)[1]
|
|
644
|
+
path = rest[1:] if rest.startswith("/") else rest
|
|
645
|
+
else:
|
|
646
|
+
path = url # a plain filesystem path
|
|
647
|
+
return SQLiteDB(path or ":memory:", **kwargs)
|
|
648
|
+
if scheme in ("postgres", "postgresql"):
|
|
649
|
+
return PostgresDB(url, **kwargs)
|
|
650
|
+
if scheme == "mysql":
|
|
651
|
+
return MySQLDB(url, **kwargs)
|
|
652
|
+
raise ValueError(f"unknown database scheme: {scheme!r} in {url!r}")
|