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.
Files changed (54) hide show
  1. pyweb/__init__.py +71 -0
  2. pyweb/app.py +37 -0
  3. pyweb/app_loader.py +139 -0
  4. pyweb/asgi.py +71 -0
  5. pyweb/auth.py +701 -0
  6. pyweb/bench.py +101 -0
  7. pyweb/browser.py +232 -0
  8. pyweb/build.py +295 -0
  9. pyweb/cache.py +201 -0
  10. pyweb/cli/__init__.py +465 -0
  11. pyweb/cli/__main__.py +3 -0
  12. pyweb/compiler/__init__.py +5 -0
  13. pyweb/compiler/ast.py +45 -0
  14. pyweb/compiler/codegen/__init__.py +9 -0
  15. pyweb/compiler/codegen/ir.py +50 -0
  16. pyweb/compiler/errors.py +29 -0
  17. pyweb/compiler/lower.py +733 -0
  18. pyweb/compiler/parser.py +532 -0
  19. pyweb/compiler/pipeline.py +158 -0
  20. pyweb/compiler/pyjs.py +1087 -0
  21. pyweb/compiler/rpc.py +35 -0
  22. pyweb/context.py +193 -0
  23. pyweb/css.py +115 -0
  24. pyweb/db/__init__.py +652 -0
  25. pyweb/db/migrate.py +143 -0
  26. pyweb/decorators.py +49 -0
  27. pyweb/deploy.py +102 -0
  28. pyweb/forms.py +108 -0
  29. pyweb/hosting.py +139 -0
  30. pyweb/jobs.py +248 -0
  31. pyweb/live.py +88 -0
  32. pyweb/lsp.py +214 -0
  33. pyweb/models.py +269 -0
  34. pyweb/npm.py +270 -0
  35. pyweb/observability.py +313 -0
  36. pyweb/platform.py +69 -0
  37. pyweb/plugins.py +112 -0
  38. pyweb/py.typed +0 -0
  39. pyweb/realtime.py +189 -0
  40. pyweb/rpc.py +137 -0
  41. pyweb/runtime/browser/runtime.js +1151 -0
  42. pyweb/runtime/server/__init__.py +445 -0
  43. pyweb/security.py +162 -0
  44. pyweb/serve.py +335 -0
  45. pyweb/ssr.py +260 -0
  46. pyweb/sync.py +118 -0
  47. pyweb/testing.py +154 -0
  48. pyweb/uploads.py +94 -0
  49. pyweb_stack-0.1.0.dist-info/METADATA +198 -0
  50. pyweb_stack-0.1.0.dist-info/RECORD +54 -0
  51. pyweb_stack-0.1.0.dist-info/WHEEL +5 -0
  52. pyweb_stack-0.1.0.dist-info/entry_points.txt +2 -0
  53. pyweb_stack-0.1.0.dist-info/licenses/LICENSE +21 -0
  54. 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}")