litedbmodel-runtime 2.0.0__tar.gz

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.
@@ -0,0 +1,43 @@
1
+ Metadata-Version: 2.4
2
+ Name: litedbmodel-runtime
3
+ Version: 2.0.0
4
+ Summary: Thin multi-language runtime for litedbmodel v2 SCP §8 bundles (Python port). Interprets the published SqlBundle (sql + fragment tree + Expression-IR param slots + transaction plan, dialect-tagged) and executes it against a SQL driver — semantics-identical to the TS reference. Delegates Expression-IR evaluation to behavior-contracts.
5
+ Author: foo-ogawa
6
+ License: MIT
7
+ Keywords: orm,sql,ir,runtime,conformance,litedbmodel
8
+ Requires-Python: >=3.9
9
+ Description-Content-Type: text/markdown
10
+ Requires-Dist: behavior-contracts==0.2.0
11
+ Provides-Extra: test
12
+ Requires-Dist: pytest>=7.0; extra == "test"
13
+ Provides-Extra: livedb
14
+ Requires-Dist: psycopg[binary]>=3.1; extra == "livedb"
15
+ Requires-Dist: pymysql>=1.1; extra == "livedb"
16
+
17
+ # litedbmodel-runtime (Python)
18
+
19
+ The Python leg of the litedbmodel v2 SCP multi-language runtime. Interprets the language-neutral
20
+ §8 published bundle (`SqlBundle`) and executes it against a DB-API SQL driver,
21
+ semantics-identical to the TS reference (`src/scp`).
22
+
23
+ **Status: WS7a scaffold.** The buildable package skeleton + the conformance runner entry point are
24
+ here; the runtime body (render / execute / transaction) is **WS7b**.
25
+
26
+ ## behavior-contracts dependency
27
+
28
+ The runtime delegates the CLOSED Expression-IR evaluation to the shared common core
29
+ [`behavior-contracts`](https://pypi.org/project/behavior-contracts/) — **consumed from PyPI**
30
+ (`behavior-contracts==0.2.0`), exactly as the TS reference imports it from npm. No local path
31
+ dependency (the `check-no-local-deps` gate forbids `../`-escaping deps).
32
+
33
+ ## Layout
34
+
35
+ ```
36
+ python/
37
+ pyproject.toml # PyPI package (litedbmodel-runtime), version-synced from package.json
38
+ litedbmodel_runtime/
39
+ __init__.py
40
+ runtime.py # WS7b: the §8 bundle interpreter surface
41
+ vectors_runner.py # conformance runner entry (WS7b body)
42
+ tests/ # WS7b runtime tests
43
+ ```
@@ -0,0 +1,27 @@
1
+ # litedbmodel-runtime (Python)
2
+
3
+ The Python leg of the litedbmodel v2 SCP multi-language runtime. Interprets the language-neutral
4
+ §8 published bundle (`SqlBundle`) and executes it against a DB-API SQL driver,
5
+ semantics-identical to the TS reference (`src/scp`).
6
+
7
+ **Status: WS7a scaffold.** The buildable package skeleton + the conformance runner entry point are
8
+ here; the runtime body (render / execute / transaction) is **WS7b**.
9
+
10
+ ## behavior-contracts dependency
11
+
12
+ The runtime delegates the CLOSED Expression-IR evaluation to the shared common core
13
+ [`behavior-contracts`](https://pypi.org/project/behavior-contracts/) — **consumed from PyPI**
14
+ (`behavior-contracts==0.2.0`), exactly as the TS reference imports it from npm. No local path
15
+ dependency (the `check-no-local-deps` gate forbids `../`-escaping deps).
16
+
17
+ ## Layout
18
+
19
+ ```
20
+ python/
21
+ pyproject.toml # PyPI package (litedbmodel-runtime), version-synced from package.json
22
+ litedbmodel_runtime/
23
+ __init__.py
24
+ runtime.py # WS7b: the §8 bundle interpreter surface
25
+ vectors_runner.py # conformance runner entry (WS7b body)
26
+ tests/ # WS7b runtime tests
27
+ ```
@@ -0,0 +1,60 @@
1
+ """litedbmodel v2 SCP — Python runtime (WS7b, #31).
2
+
3
+ The Python leg of the multi-language SCP runtime. It interprets the language-neutral §8 published
4
+ bundle (``SqlBundle``: sql text + fragment tree + closed-set Expression-IR param slots +
5
+ transaction plan, dialect-tagged) and executes it against a SQL driver, semantics-identical to
6
+ the TS reference (``src/scp``). The generic Expression-IR evaluation (SKIP guards, param slots)
7
+ and the plan/map/wire/output orchestration are delegated to the shared common core
8
+ ``behavior-contracts`` (PyPI) — this package re-implements NO generic evaluator, only the
9
+ SQL-backend concerns (render → bind → execute → assembly + gate-first transaction), exactly like
10
+ the TS runtime.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from .dialect import SQLITE, POSTGRES, MYSQL, Dialect, dialect_for, to_dollar_placeholders
16
+ from .driver import Driver, MysqlDriver, PostgresDriver, PreparedStatement, RunInfo, SqliteDriver
17
+ from .errors import SqlFailure, map_sqlite_error
18
+ from .render import RenderedSql, WHERE_SLOT, render_operation
19
+ from .runtime import (
20
+ ENTITY_ROOT,
21
+ SCOPE_PORT,
22
+ execute_bundle,
23
+ execute_transaction_bundle,
24
+ order_by_nulls,
25
+ render_operation_bundle,
26
+ )
27
+
28
+ __version__ = "2.0.0"
29
+
30
+ __all__ = [
31
+ "__version__",
32
+ # dialect
33
+ "SQLITE",
34
+ "POSTGRES",
35
+ "MYSQL",
36
+ "Dialect",
37
+ "dialect_for",
38
+ "to_dollar_placeholders",
39
+ # driver seam
40
+ "Driver",
41
+ "PreparedStatement",
42
+ "RunInfo",
43
+ "SqliteDriver",
44
+ "PostgresDriver",
45
+ "MysqlDriver",
46
+ # errors
47
+ "SqlFailure",
48
+ "map_sqlite_error",
49
+ # render
50
+ "RenderedSql",
51
+ "WHERE_SLOT",
52
+ "render_operation",
53
+ "render_operation_bundle",
54
+ # runtime
55
+ "ENTITY_ROOT",
56
+ "SCOPE_PORT",
57
+ "execute_bundle",
58
+ "execute_transaction_bundle",
59
+ "order_by_nulls",
60
+ ]
@@ -0,0 +1,99 @@
1
+ """litedbmodel v2 SCP — dialect strategy table (Python port of ``src/scp/dialect.ts``).
2
+
3
+ The SINGLE SOURCE OF TRUTH for every SQL-dialect difference the render pipeline needs, ported
4
+ byte-for-byte from the TS reference (spec §4/§5/§8/§10). The dialect axis is compiled ONCE
5
+ TS-side; the published bundle carries `?` placeholders and a `dialect` tag, and this module only
6
+ needs the render-time concerns a thin runtime touches:
7
+
8
+ - ``finalize_placeholders`` — the `?`→`$N` final one-pass (Postgres only; SQLite/MySQL identity).
9
+ - ``order_by_nulls`` — deterministic NULLS ordering (native for PG/SQLite, `IS NULL` emulation
10
+ for MySQL) — the WS6-flagged dialect primitive exercised by the conformance `dialect` suite.
11
+
12
+ The INSERT-conflict / guard-INSERT strategy methods are NOT needed by the runtime: those are a
13
+ compile-time concern (the published bundle's `operations[*].sql` already carries the fully
14
+ rendered conflict clause — e.g. `ON CONFLICT DO NOTHING`), so the runtime never re-derives them.
15
+ This mirrors the TS runtime, which likewise only calls `finalizePlaceholders` / `orderByNulls`.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from typing import Callable, Dict
21
+
22
+ # Known dialects (spec §4 breadth: PG/MySQL/SQLite).
23
+ DIALECT_NAMES = ("sqlite", "postgres", "mysql")
24
+
25
+
26
+ def to_dollar_placeholders(sql: str) -> str:
27
+ """Replace each `?` with `$1, $2, …` left-to-right (Postgres §8 final one-pass).
28
+
29
+ Byte-identical to the TS `toDollarPlaceholders`: it runs ONCE over the fully-assembled,
30
+ param-flattened SQL text, so placeholder numbering is a plain running counter (the
31
+ number-reassignment problem cannot reappear). Every `?` on the compiled surface is a bound
32
+ param position — the render pipeline never emits a literal `?` inside a string literal.
33
+ """
34
+ n = 0
35
+ out = []
36
+ for ch in sql:
37
+ if ch == "?":
38
+ n += 1
39
+ out.append(f"${n}")
40
+ else:
41
+ out.append(ch)
42
+ return "".join(out)
43
+
44
+
45
+ def _identity(sql: str) -> str:
46
+ return sql
47
+
48
+
49
+ def _order_by_nulls_native(expr: str, direction: str, nulls: str) -> str:
50
+ # Postgres / SQLite (3.30+): native `NULLS FIRST/LAST`.
51
+ return f"{expr} {direction} NULLS {nulls}"
52
+
53
+
54
+ def _order_by_nulls_mysql(expr: str, direction: str, nulls: str) -> str:
55
+ # MySQL has no NULLS FIRST/LAST — emulate with a leading `IS NULL` sort key.
56
+ # In MySQL NULL sorts LOWEST; `expr IS NULL` is 1 for null, 0 otherwise.
57
+ # NULLS FIRST: nulls must come first → order the IS-NULL flag DESC (1 before 0).
58
+ # NULLS LAST: nulls must come last → order the IS-NULL flag ASC (0 before 1).
59
+ flag_dir = "DESC" if nulls == "FIRST" else "ASC"
60
+ return f"{expr} IS NULL {flag_dir}, {expr} {direction}"
61
+
62
+
63
+ class Dialect:
64
+ """A frozen dialect strategy: the render-time text producers a thin runtime consumes."""
65
+
66
+ __slots__ = ("name", "_finalize", "_order_by_nulls")
67
+
68
+ def __init__(
69
+ self,
70
+ name: str,
71
+ finalize: Callable[[str], str],
72
+ order_by_nulls: Callable[[str, str, str], str],
73
+ ) -> None:
74
+ self.name = name
75
+ self._finalize = finalize
76
+ self._order_by_nulls = order_by_nulls
77
+
78
+ def finalize_placeholders(self, sql: str) -> str:
79
+ return self._finalize(sql)
80
+
81
+ def order_by_nulls(self, expr: str, direction: str, nulls: str) -> str:
82
+ return self._order_by_nulls(expr, direction, nulls)
83
+
84
+
85
+ SQLITE = Dialect("sqlite", _identity, _order_by_nulls_native)
86
+ POSTGRES = Dialect("postgres", to_dollar_placeholders, _order_by_nulls_native)
87
+ MYSQL = Dialect("mysql", _identity, _order_by_nulls_mysql)
88
+
89
+ _DIALECTS: Dict[str, Dialect] = {"sqlite": SQLITE, "postgres": POSTGRES, "mysql": MYSQL}
90
+
91
+
92
+ def dialect_for(name: str) -> Dialect:
93
+ """Resolve a dialect name to its strategy (fail-closed — no silent default)."""
94
+ d = _DIALECTS.get(name)
95
+ if d is None:
96
+ raise ValueError(
97
+ f"scp dialect: unknown dialect '{name}' (known: {', '.join(_DIALECTS)})"
98
+ )
99
+ return d
@@ -0,0 +1,252 @@
1
+ """litedbmodel v2 SCP — SQL driver seam (WS7b).
2
+
3
+ The minimal synchronous SQL-driver surface the runtime needs, mirroring the TS `SqliteDb`
4
+ seam (`prepare(sql).all(...) / .run(...)`). The conformance bar executes against an in-process
5
+ stdlib ``sqlite3`` connection (:class:`SqliteDriver`) — the sanctioned in-proc substitute for a
6
+ docker integration DB (#31 AC; live PG/MySQL is deferred to a coordinated cross-language docker
7
+ pass). A psycopg / mysql-connector driver plugs into this SAME abstract seam later: implement
8
+ :class:`Driver.prepare` returning a :class:`PreparedStatement` (`all` / `run`) over the
9
+ paramstyle the bundle's dialect emits (`$N` for Postgres, `?`/`%s` for MySQL) — no runtime change.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ import sqlite3
16
+ from typing import Any, Dict, List, Optional, Protocol, Sequence
17
+
18
+
19
+ class RunInfo:
20
+ """The summary of a non-returning write: affected-row count + last insert rowid."""
21
+
22
+ __slots__ = ("changes", "last_insert_rowid")
23
+
24
+ def __init__(self, changes: int, last_insert_rowid: int) -> None:
25
+ self.changes = changes
26
+ self.last_insert_rowid = last_insert_rowid
27
+
28
+
29
+ class PreparedStatement(Protocol):
30
+ """A prepared statement: `all` returns the row list (SELECT/RETURNING); `run` a write summary."""
31
+
32
+ def all(self, params: Sequence[Any]) -> List[Dict[str, Any]]: ...
33
+
34
+ def run(self, params: Sequence[Any]) -> RunInfo: ...
35
+
36
+
37
+ class Driver(Protocol):
38
+ """The synchronous SQL-driver seam (mirrors the TS `SqliteDb`)."""
39
+
40
+ def prepare(self, sql: str) -> PreparedStatement: ...
41
+
42
+
43
+ class _SqlitePrepared:
44
+ """A prepared statement over a stdlib ``sqlite3`` connection."""
45
+
46
+ __slots__ = ("_conn", "_sql")
47
+
48
+ def __init__(self, conn: "sqlite3.Connection", sql: str) -> None:
49
+ self._conn = conn
50
+ self._sql = sql
51
+
52
+ def all(self, params: Sequence[Any]) -> List[Dict[str, Any]]:
53
+ cur = self._conn.execute(self._sql, tuple(params))
54
+ cols = [c[0] for c in cur.description] if cur.description is not None else []
55
+ rows = [dict(zip(cols, r)) for r in cur.fetchall()]
56
+ cur.close()
57
+ return rows
58
+
59
+ def run(self, params: Sequence[Any]) -> RunInfo:
60
+ cur = self._conn.execute(self._sql, tuple(params))
61
+ changes = cur.rowcount if cur.rowcount is not None else 0
62
+ last = cur.lastrowid if cur.lastrowid is not None else 0
63
+ cur.close()
64
+ return RunInfo(changes, last)
65
+
66
+
67
+ class SqliteDriver:
68
+ """An in-process stdlib ``sqlite3`` driver implementing the :class:`Driver` seam.
69
+
70
+ This is the runnable conformance seam: it binds `?` placeholders positionally, so a
71
+ Postgres-tagged bundle's `$N` text is NOT what runs here — the exec/tx vectors run only the
72
+ SQLite-tagged bundles (the §10 promise: same IR + input → same RESULT regardless of dialect
73
+ text). PG/MySQL SQL-text conformance is proven on the render axis; live PG/MySQL execution is
74
+ the coordinated docker pass.
75
+ """
76
+
77
+ __slots__ = ("conn",)
78
+
79
+ def __init__(self, conn: "sqlite3.Connection") -> None:
80
+ self.conn = conn
81
+
82
+ @classmethod
83
+ def in_memory(cls, schema: Sequence[str]) -> "SqliteDriver":
84
+ conn = sqlite3.connect(":memory:")
85
+ conn.execute("PRAGMA foreign_keys = ON")
86
+ for stmt in schema:
87
+ conn.execute(stmt)
88
+ conn.commit()
89
+ return cls(conn)
90
+
91
+ def prepare(self, sql: str) -> _SqlitePrepared:
92
+ return _SqlitePrepared(self.conn, sql)
93
+
94
+ def close(self) -> None:
95
+ self.conn.close()
96
+
97
+
98
+ # ── Live PostgreSQL / MySQL drivers (WS7g, #36) ────────────────────────────────
99
+ #
100
+ # The SAME synchronous `Driver` seam, now backed by REAL psycopg (Postgres) / PyMySQL (MySQL)
101
+ # connections — proving the deferred live-DB execution axis (spec §10 dialect axis). The runtime
102
+ # is UNCHANGED: it renders the dialect-tagged bundle (Postgres → `$N`, MySQL → `?`), binds the
103
+ # rendered params positionally, and calls `prepare(sql).all(...)` / `.run(...)`. Each live driver
104
+ # adapts the rendered placeholder text to its DB's native paramstyle (both DB-API drivers here use
105
+ # `%s`), and MySQL emulates the missing `RETURNING` at this seam (strip → execute → re-select the
106
+ # inserted PK) — the sanctioned dialect-behavior-by-convention (mirrors the WS6 TS ScpDialect).
107
+ #
108
+ # The transaction envelope: the runtime issues `prepare("BEGIN"|"COMMIT"|"ROLLBACK").run([])`.
109
+ # The live drivers run with autocommit ON so those literal statements control the transaction
110
+ # exactly like the SQLite seam's implicit-then-explicit tx — a real BEGIN…COMMIT on the live DB.
111
+
112
+ # `$1`, `$2`, … (Postgres render output).
113
+ _DOLLAR_RE = re.compile(r"\$\d+")
114
+ # `INSERT INTO <table> (...) ... RETURNING <cols>` — MySQL RETURNING emulation parse.
115
+ _RETURNING_RE = re.compile(r"\s+RETURNING\s+(.+?)\s*$", re.IGNORECASE | re.DOTALL)
116
+ _INSERT_TABLE_RE = re.compile(r"^\s*INSERT\s+(?:IGNORE\s+)?INTO\s+([A-Za-z_][A-Za-z0-9_]*)", re.IGNORECASE)
117
+
118
+
119
+ def _dollar_to_pyformat(sql: str) -> str:
120
+ """Postgres `$N` → DB-API `%s` (positional). Render already numbers left-to-right 1..N, so a
121
+ plain replace preserves order. Literal `%` is doubled so psycopg/pymysql don't treat it as a
122
+ format directive (the rendered SQL never contains a literal `%`, but this keeps the seam safe).
123
+ """
124
+ return _DOLLAR_RE.sub("%s", sql.replace("%", "%%"))
125
+
126
+
127
+ def _qmark_to_pyformat(sql: str) -> str:
128
+ """MySQL render keeps `?`; PyMySQL binds `%s`. Replace each `?` with `%s` (literal `%` doubled)."""
129
+ return sql.replace("%", "%%").replace("?", "%s")
130
+
131
+
132
+ class _LivePrepared:
133
+ """A prepared statement over a live DB-API connection (psycopg / PyMySQL).
134
+
135
+ `paramstyle_xform` adapts the rendered placeholder text; `emulate_returning` toggles the MySQL
136
+ RETURNING emulation. Transaction-control literals (BEGIN/COMMIT/ROLLBACK) execute verbatim.
137
+ """
138
+
139
+ __slots__ = ("_conn", "_sql", "_xform", "_emulate_returning")
140
+
141
+ def __init__(self, conn: Any, sql: str, xform, emulate_returning: bool) -> None:
142
+ self._conn = conn
143
+ self._sql = sql
144
+ self._xform = xform
145
+ self._emulate_returning = emulate_returning
146
+
147
+ def _fetch_all(self, cur) -> List[Dict[str, Any]]:
148
+ cols = [d[0] for d in cur.description] if cur.description is not None else []
149
+ return [dict(zip(cols, r)) for r in cur.fetchall()]
150
+
151
+ def all(self, params: Sequence[Any]) -> List[Dict[str, Any]]:
152
+ # MySQL has no RETURNING: strip it, run the INSERT, re-select the inserted PK's columns.
153
+ if self._emulate_returning:
154
+ m = _RETURNING_RE.search(self._sql)
155
+ if m is not None:
156
+ returning_cols = m.group(1)
157
+ insert_sql = self._sql[: m.start()]
158
+ table_m = _INSERT_TABLE_RE.match(insert_sql)
159
+ if table_m is None:
160
+ raise ValueError(
161
+ f"scp mysql driver: cannot emulate RETURNING for non-INSERT statement: {self._sql!r}"
162
+ )
163
+ table = table_m.group(1)
164
+ cur = self._conn.cursor()
165
+ cur.execute(self._xform(insert_sql), tuple(params))
166
+ last_id = cur.lastrowid
167
+ cur.close()
168
+ sel = self._conn.cursor()
169
+ sel.execute(f"SELECT {returning_cols} FROM {table} WHERE id = %s", (last_id,))
170
+ rows = self._fetch_all(sel)
171
+ sel.close()
172
+ return rows
173
+ cur = self._conn.cursor()
174
+ cur.execute(self._xform(self._sql), tuple(params))
175
+ rows = self._fetch_all(cur)
176
+ cur.close()
177
+ return rows
178
+
179
+ def run(self, params: Sequence[Any]) -> RunInfo:
180
+ cur = self._conn.cursor()
181
+ cur.execute(self._xform(self._sql), tuple(params))
182
+ changes = cur.rowcount if cur.rowcount is not None and cur.rowcount >= 0 else 0
183
+ last = cur.lastrowid if getattr(cur, "lastrowid", None) is not None else 0
184
+ cur.close()
185
+ return RunInfo(changes, last)
186
+
187
+
188
+ class PostgresDriver:
189
+ """A live Postgres driver (psycopg 3) implementing the :class:`Driver` seam.
190
+
191
+ Renders a `postgres`-tagged bundle → `$N` text; this driver rewrites `$N`→`%s` for psycopg and
192
+ executes REAL SQL over a live connection. Autocommit ON so the runtime's BEGIN/COMMIT/ROLLBACK
193
+ literals control the transaction (a genuine PG transaction for the gate-first write-tx).
194
+ """
195
+
196
+ __slots__ = ("conn",)
197
+
198
+ def __init__(self, conn: Any) -> None:
199
+ self.conn = conn
200
+
201
+ @classmethod
202
+ def connect(cls, *, host: str, port: int, user: str, password: str, dbname: str) -> "PostgresDriver":
203
+ import psycopg # imported lazily so the SQLite conformance never needs the driver installed
204
+
205
+ conn = psycopg.connect(host=host, port=port, user=user, password=password, dbname=dbname, autocommit=True)
206
+ return cls(conn)
207
+
208
+ def exec_ddl(self, statements: Sequence[str]) -> None:
209
+ cur = self.conn.cursor()
210
+ for stmt in statements:
211
+ cur.execute(stmt)
212
+ cur.close()
213
+
214
+ def prepare(self, sql: str) -> _LivePrepared:
215
+ return _LivePrepared(self.conn, sql, _dollar_to_pyformat, emulate_returning=False)
216
+
217
+ def close(self) -> None:
218
+ self.conn.close()
219
+
220
+
221
+ class MysqlDriver:
222
+ """A live MySQL driver (PyMySQL) implementing the :class:`Driver` seam.
223
+
224
+ Renders a `mysql`-tagged bundle → `?` text; this driver rewrites `?`→`%s` for PyMySQL. MySQL
225
+ 8.0 has NO `RETURNING`, so an INSERT…RETURNING is emulated at this seam (strip → INSERT →
226
+ re-select the AUTO_INCREMENT PK's columns) — the dialect-behavior-by-convention the WS6 TS
227
+ ScpDialect uses. Autocommit ON so the runtime's BEGIN/COMMIT/ROLLBACK literals bracket the tx.
228
+ """
229
+
230
+ __slots__ = ("conn",)
231
+
232
+ def __init__(self, conn: Any) -> None:
233
+ self.conn = conn
234
+
235
+ @classmethod
236
+ def connect(cls, *, host: str, port: int, user: str, password: str, dbname: str) -> "MysqlDriver":
237
+ import pymysql # lazy import (conformance bar never needs it)
238
+
239
+ conn = pymysql.connect(host=host, port=port, user=user, password=password, database=dbname, autocommit=True)
240
+ return cls(conn)
241
+
242
+ def exec_ddl(self, statements: Sequence[str]) -> None:
243
+ cur = self.conn.cursor()
244
+ for stmt in statements:
245
+ cur.execute(stmt)
246
+ cur.close()
247
+
248
+ def prepare(self, sql: str) -> _LivePrepared:
249
+ return _LivePrepared(self.conn, sql, _qmark_to_pyformat, emulate_returning=True)
250
+
251
+ def close(self) -> None:
252
+ self.conn.close()
@@ -0,0 +1,83 @@
1
+ """litedbmodel v2 SCP — Error Mapping (Python port of ``src/scp/errors.ts``, spec §11 item 5).
2
+
3
+ Maps a SQLite driver error (Python ``sqlite3`` exceptions, or a better-sqlite3-shaped
4
+ ``SQLITE_*`` code carried in a message tag) to a structured :class:`SqlFailure` with a stable
5
+ `kind` + the bc Execution-Plan Policy Kind the runtime honors (fail / retry / continue).
6
+
7
+ The mapping is closed and explicit (no silent catch-all that hides a driver error): an
8
+ unrecognized error maps to ``kind='driver_error'`` / ``policy='fail'`` — loud, and carrying the
9
+ original code + message. The `SQLITE_*` code family is mirrored from the TS reference; for the
10
+ stdlib ``sqlite3`` driver (whose exceptions do not carry a `SQLITE_*` string code on Python <3.11)
11
+ the code is derived from the message text ("UNIQUE constraint failed", "FOREIGN KEY constraint
12
+ failed", …) so the same kind/policy is produced as the better-sqlite3 seam.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ import sqlite3
19
+ from typing import Optional
20
+
21
+ # The SCP failure kinds and their honored bc Policy Kind.
22
+ _KIND_POLICY = {
23
+ "constraint_violation": "fail",
24
+ "foreign_key_violation": "fail",
25
+ "retryable": "retry",
26
+ "driver_error": "fail",
27
+ }
28
+
29
+
30
+ class SqlFailure(Exception):
31
+ """A mapped SCP failure: SCP `kind`, honored bc Policy Kind, the SQLite code, a message."""
32
+
33
+ def __init__(self, kind: str, policy: str, sqlite_code: Optional[str], message: str) -> None:
34
+ super().__init__(message)
35
+ self.kind = kind
36
+ self.policy = policy
37
+ self.sqlite_code = sqlite_code
38
+
39
+
40
+ def _code_from_bettersqlite_tag(message: str) -> Optional[str]:
41
+ """Extract a `SQLITE_*` code embedded by the TS seam (`[SQLITE_...] ...`) or a bare mention."""
42
+ m = re.search(r"(SQLITE_[A-Z_]+)", message)
43
+ return m.group(1) if m else None
44
+
45
+
46
+ def _code_from_stdlib(e: BaseException) -> Optional[str]:
47
+ """Derive a `SQLITE_*`-style code from a stdlib sqlite3 exception (type + message text)."""
48
+ # Python 3.11+ exposes sqlite_errorname (e.g. 'SQLITE_CONSTRAINT_UNIQUE'); prefer it.
49
+ name = getattr(e, "sqlite_errorname", None)
50
+ if isinstance(name, str) and name.startswith("SQLITE_"):
51
+ return name
52
+ msg = str(e)
53
+ if isinstance(e, sqlite3.IntegrityError):
54
+ if "FOREIGN KEY" in msg:
55
+ return "SQLITE_CONSTRAINT_FOREIGNKEY"
56
+ return "SQLITE_CONSTRAINT"
57
+ if isinstance(e, sqlite3.OperationalError):
58
+ if "locked" in msg:
59
+ return "SQLITE_LOCKED"
60
+ if "busy" in msg:
61
+ return "SQLITE_BUSY"
62
+ return None
63
+
64
+
65
+ def map_sqlite_error(e: BaseException) -> SqlFailure:
66
+ """Map a caught driver error to a :class:`SqlFailure` (byte-for-byte kind/policy with TS)."""
67
+ if isinstance(e, sqlite3.Error):
68
+ code = _code_from_stdlib(e)
69
+ else:
70
+ code = _code_from_bettersqlite_tag(str(e))
71
+
72
+ if code is None:
73
+ message = str(e)
74
+ return SqlFailure("driver_error", "fail", None, f"non-SQLite driver error: {message}")
75
+
76
+ tagged = f"[{code}] {e}"
77
+ if code == "SQLITE_CONSTRAINT_FOREIGNKEY":
78
+ return SqlFailure("foreign_key_violation", "fail", code, tagged)
79
+ if code.startswith("SQLITE_CONSTRAINT"):
80
+ return SqlFailure("constraint_violation", "fail", code, tagged)
81
+ if code in ("SQLITE_BUSY", "SQLITE_LOCKED"):
82
+ return SqlFailure("retryable", "retry", code, tagged)
83
+ return SqlFailure("driver_error", "fail", code, tagged)