bazaar-contrib-sqlite 0.3.0rc1__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 (32) hide show
  1. bazaar_contrib_sqlite/__init__.py +26 -0
  2. bazaar_contrib_sqlite/compiler.py +74 -0
  3. bazaar_contrib_sqlite/database.py +323 -0
  4. bazaar_contrib_sqlite/executor.py +673 -0
  5. bazaar_contrib_sqlite/py.typed +0 -0
  6. bazaar_contrib_sqlite/resources/add_column.sql.tpl +3 -0
  7. bazaar_contrib_sqlite/resources/busy_timeout.sql.tpl +1 -0
  8. bazaar_contrib_sqlite/resources/copy.sql.tpl +3 -0
  9. bazaar_contrib_sqlite/resources/create_index.sql.tpl +13 -0
  10. bazaar_contrib_sqlite/resources/create_table.sql.tpl +21 -0
  11. bazaar_contrib_sqlite/resources/create_trigger.sql.tpl +7 -0
  12. bazaar_contrib_sqlite/resources/delete.sql.tpl +6 -0
  13. bazaar_contrib_sqlite/resources/drop_column.sql.tpl +1 -0
  14. bazaar_contrib_sqlite/resources/drop_index.sql.tpl +1 -0
  15. bazaar_contrib_sqlite/resources/drop_table.sql.tpl +1 -0
  16. bazaar_contrib_sqlite/resources/drop_trigger.sql.tpl +1 -0
  17. bazaar_contrib_sqlite/resources/fragments.sql.tpl +111 -0
  18. bazaar_contrib_sqlite/resources/insert.sql.tpl +23 -0
  19. bazaar_contrib_sqlite/resources/rename_column.sql.tpl +1 -0
  20. bazaar_contrib_sqlite/resources/rename_table.sql.tpl +1 -0
  21. bazaar_contrib_sqlite/resources/search_delete.sql.tpl +8 -0
  22. bazaar_contrib_sqlite/resources/search_insert.sql.tpl +8 -0
  23. bazaar_contrib_sqlite/resources/search_rebuild.sql.tpl +2 -0
  24. bazaar_contrib_sqlite/resources/search_table.sql.tpl +7 -0
  25. bazaar_contrib_sqlite/resources/search_update.sql.tpl +12 -0
  26. bazaar_contrib_sqlite/resources/select.sql.tpl +44 -0
  27. bazaar_contrib_sqlite/resources/update.sql.tpl +10 -0
  28. bazaar_contrib_sqlite-0.3.0rc1.dist-info/METADATA +35 -0
  29. bazaar_contrib_sqlite-0.3.0rc1.dist-info/RECORD +32 -0
  30. bazaar_contrib_sqlite-0.3.0rc1.dist-info/WHEEL +4 -0
  31. bazaar_contrib_sqlite-0.3.0rc1.dist-info/entry_points.txt +3 -0
  32. bazaar_contrib_sqlite-0.3.0rc1.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,26 @@
1
+ """Lifespan-owned SQLite implementation of the public storage contract."""
2
+
3
+ from contextlib import asynccontextmanager
4
+
5
+ from bazaar_contrib_storage import STORAGE, backend_key
6
+ from bazaar_core import Context, Contrib
7
+
8
+ from .database import SqliteConfig, SqliteDatabase, open_database
9
+
10
+ __all__ = ["SqliteConfig", "SqliteDatabase", "open_database", "sqlite"]
11
+
12
+
13
+ @asynccontextmanager
14
+ async def lifespan(ctx: Context, config: SqliteConfig):
15
+ async with open_database(config) as database:
16
+ with ctx.accept(STORAGE).register("sqlite", database):
17
+ ctx.offer(backend_key("sqlite"), database)
18
+ yield
19
+
20
+
21
+ sqlite = Contrib(
22
+ config=SqliteConfig,
23
+ accepts=[STORAGE],
24
+ offers=[backend_key("sqlite")],
25
+ lifespan=lifespan,
26
+ )
@@ -0,0 +1,74 @@
1
+ """Render SQLite statements from packaged templates and collect bound values."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from functools import cache
6
+
7
+ import bazaar_contrib_storage as storage
8
+ from jinja2 import Environment, PackageLoader, StrictUndefined
9
+
10
+
11
+ def quote(name: str) -> str:
12
+ return '"' + name.replace('"', '""') + '"'
13
+
14
+
15
+ def literal(value: storage.Scalar) -> str:
16
+ if value is None:
17
+ return "NULL"
18
+ if isinstance(value, bytes):
19
+ return "X'" + value.hex() + "'"
20
+ if isinstance(value, str):
21
+ return "'" + value.replace("'", "''") + "'"
22
+ if isinstance(value, bool):
23
+ return str(int(value))
24
+ return str(value)
25
+
26
+
27
+ @cache
28
+ def load_templates() -> Environment:
29
+ """Load all packaged SQL before serving queries on the event loop."""
30
+ templates = Environment(
31
+ loader=PackageLoader("bazaar_contrib_sqlite", "resources"),
32
+ undefined=StrictUndefined,
33
+ autoescape=False,
34
+ auto_reload=False,
35
+ trim_blocks=False,
36
+ lstrip_blocks=True,
37
+ )
38
+ templates.filters.update(identifier=quote, literal=literal)
39
+ for name in templates.list_templates(extensions=["tpl"]):
40
+ templates.get_template(name)
41
+ return templates
42
+
43
+
44
+ class Compiler:
45
+ def __init__(self, templates: Environment, *, inline: bool = False) -> None:
46
+ self.templates = templates
47
+ self.parameters: list[storage.Scalar] = []
48
+ self.inline = inline
49
+
50
+ def bind(self, value: storage.Scalar) -> str:
51
+ if self.inline:
52
+ return literal(value)
53
+ self.parameters.append(value)
54
+ return "?"
55
+
56
+ def render(self, template: str, **context: object) -> str:
57
+ return self.templates.get_template(template).render(bind=self.bind, **context)
58
+
59
+
60
+ def schema_sql(
61
+ operation: storage.SchemaOperation, templates: Environment
62
+ ) -> tuple[str, ...]:
63
+ compiler = Compiler(templates, inline=True)
64
+ if isinstance(operation, storage.CreateIndex) and operation.index.kind == "search":
65
+ names = (
66
+ "search_table.sql.tpl",
67
+ "search_insert.sql.tpl",
68
+ "search_delete.sql.tpl",
69
+ "search_update.sql.tpl",
70
+ "search_rebuild.sql.tpl",
71
+ )
72
+ else:
73
+ names = (f"{operation.kind}.sql.tpl",)
74
+ return tuple(compiler.render(name, operation=operation) for name in names)
@@ -0,0 +1,323 @@
1
+ """SQLite sessions, connection ownership, and public storage errors."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from contextlib import AsyncExitStack, asynccontextmanager, contextmanager
6
+ from enum import Enum, auto
7
+ from pathlib import Path
8
+ lazy import asyncio
9
+ lazy import os
10
+ lazy import sqlite3
11
+ lazy from collections.abc import AsyncIterator, Awaitable, Callable, Iterator
12
+ lazy from typing import Self
13
+
14
+ import bazaar_contrib_storage as storage
15
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
16
+ lazy import aiosqlite
17
+ lazy from jinja2 import Environment
18
+
19
+ from .compiler import Compiler, load_templates, schema_sql
20
+ from .executor import (
21
+ SqliteExecutor,
22
+ SqliteExecutorClosedError,
23
+ SqliteQueryTimeoutError,
24
+ SqliteSession,
25
+ )
26
+
27
+
28
+ class SqliteConfig(BaseModel):
29
+ model_config = ConfigDict(extra="forbid", frozen=True)
30
+ path: Path | None = None
31
+ busy_timeout_ms: int = Field(default=5_000, gt=0)
32
+ max_idle_readers: int = Field(default=2, gt=0)
33
+ max_readers: int | None = Field(default=None, gt=0)
34
+ reader_idle_timeout: float = Field(default=60, gt=0, allow_inf_nan=False)
35
+ shutdown_timeout: float = Field(default=20, gt=0, allow_inf_nan=False)
36
+
37
+ @model_validator(mode="after")
38
+ def pool_size(self) -> Self:
39
+ if self.max_readers is not None and self.max_readers < self.max_idle_readers:
40
+ raise ValueError("max_readers must be at least max_idle_readers")
41
+ return self
42
+
43
+
44
+ @contextmanager
45
+ def storage_errors() -> Iterator[None]:
46
+ try:
47
+ yield
48
+ except SqliteExecutorClosedError as error:
49
+ raise storage.StorageClosedError(str(error)) from error
50
+ except SqliteQueryTimeoutError as error:
51
+ raise storage.StorageQueryTimeoutError(str(error)) from error
52
+ except sqlite3.IntegrityError as error:
53
+ raise storage.StorageIntegrityError(str(error)) from error
54
+ except sqlite3.OperationalError as error:
55
+ code = getattr(error, "sqlite_errorcode", 0) & 0xFF
56
+ if code in {
57
+ sqlite3.SQLITE_BUSY,
58
+ sqlite3.SQLITE_LOCKED,
59
+ sqlite3.SQLITE_IOERR,
60
+ sqlite3.SQLITE_CANTOPEN,
61
+ sqlite3.SQLITE_FULL,
62
+ }:
63
+ raise storage.StorageUnavailableError(str(error)) from error
64
+ if "no such module: fts5" in str(error) or "no such tokenizer: trigram" in str(
65
+ error
66
+ ):
67
+ raise storage.StorageQueryError(
68
+ "text search requires SQLite FTS5 with the trigram tokenizer"
69
+ ) from error
70
+ raise storage.StorageQueryError(str(error)) from error
71
+ except sqlite3.Error as error:
72
+ raise storage.StorageQueryError(str(error)) from error
73
+ except OSError as error:
74
+ raise storage.StorageUnavailableError(str(error)) from error
75
+
76
+
77
+ class _State(Enum):
78
+ OPEN = auto()
79
+ CLOSED = auto()
80
+
81
+
82
+ class Session:
83
+ def __init__(
84
+ self, executor: SqliteExecutor | SqliteSession, templates: Environment
85
+ ) -> None:
86
+ self._executor = executor
87
+ self._templates = templates
88
+ self._state = _State.OPEN
89
+
90
+ def _require_open(self) -> None:
91
+ if self._state is _State.CLOSED:
92
+ raise storage.StorageClosedError("transaction session has closed")
93
+
94
+ async def select(self, request: storage.Select) -> tuple[storage.Record, ...]:
95
+ self._require_open()
96
+ compiler = Compiler(self._templates)
97
+ statement = compiler.render("select.sql.tpl", request=request)
98
+ with storage_errors():
99
+ rows = await self._executor.fetchall(statement, compiler.parameters)
100
+ return tuple(dict(row) for row in rows)
101
+
102
+ async def _mutation(self, statement: str, compiler: Compiler) -> storage.Mutation:
103
+ self._require_open()
104
+ with storage_errors():
105
+ result = await self._executor.execute(statement, compiler.parameters)
106
+ return storage.Mutation(
107
+ count=max(result.rowcount, len(result.rows)),
108
+ rows=tuple(dict(row) for row in result.rows),
109
+ )
110
+
111
+ async def copy(self, request: storage.Copy) -> storage.Mutation:
112
+ compiler = Compiler(self._templates)
113
+ statement = compiler.render("copy.sql.tpl", request=request)
114
+ return await self._mutation(statement, compiler)
115
+
116
+ async def table_exists(self, name: str) -> bool:
117
+ self._require_open()
118
+ with storage_errors():
119
+ row = await self._executor.fetchone(
120
+ "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ? "
121
+ "UNION ALL SELECT 1 FROM sqlite_temp_master WHERE type = 'table' AND name = ?",
122
+ (name, name),
123
+ )
124
+ return row is not None
125
+
126
+ async def insert(self, request: storage.Insert) -> storage.Mutation:
127
+ compiler = Compiler(self._templates)
128
+ return await self._mutation(
129
+ compiler.render("insert.sql.tpl", request=request), compiler
130
+ )
131
+
132
+ async def update(self, request: storage.Update) -> storage.Mutation:
133
+ compiler = Compiler(self._templates)
134
+ return await self._mutation(
135
+ compiler.render("update.sql.tpl", request=request), compiler
136
+ )
137
+
138
+ async def delete(self, request: storage.Delete) -> storage.Mutation:
139
+ compiler = Compiler(self._templates)
140
+ return await self._mutation(
141
+ compiler.render("delete.sql.tpl", request=request), compiler
142
+ )
143
+
144
+ async def apply_schema(
145
+ self, operations: tuple[storage.SchemaOperation, ...]
146
+ ) -> None:
147
+ self._require_open()
148
+ with storage_errors():
149
+ for operation in operations:
150
+ if (
151
+ isinstance(operation, storage.CreateIndex)
152
+ and operation.if_not_exists
153
+ ):
154
+ row = await self._executor.fetchone(
155
+ "SELECT type FROM sqlite_master WHERE name = ?",
156
+ (operation.index.name,),
157
+ )
158
+ expected = "table" if operation.index.kind == "search" else "index"
159
+ if row is not None and row[0] == expected:
160
+ continue
161
+ if isinstance(operation, storage.DropIndex):
162
+ row = await self._executor.fetchone(
163
+ "SELECT type FROM sqlite_master WHERE name = ?",
164
+ (operation.name,),
165
+ )
166
+ if row is not None and row[0] == "table":
167
+ triggers = await self._executor.fetchall(
168
+ "SELECT name FROM sqlite_master WHERE type = 'trigger' AND tbl_name = ? "
169
+ "AND (instr(sql, ?) > 0 OR instr(sql, ?) > 0 OR instr(sql, ?) > 0)",
170
+ (
171
+ operation.table,
172
+ 'INSERT INTO "' + operation.name + '"',
173
+ "INSERT INTO " + operation.name + " ",
174
+ "INSERT INTO " + operation.name + "(",
175
+ ),
176
+ )
177
+ for trigger in triggers:
178
+ for statement in schema_sql(
179
+ storage.DropTrigger(
180
+ name=trigger[0],
181
+ table=operation.table,
182
+ if_exists=True,
183
+ ),
184
+ self._templates,
185
+ ):
186
+ await self._executor.execute(statement)
187
+ for statement in schema_sql(
188
+ storage.DropTable(table=operation.name), self._templates
189
+ ):
190
+ await self._executor.execute(statement)
191
+ continue
192
+ for statement in schema_sql(operation, self._templates):
193
+ await self._executor.execute(statement)
194
+
195
+
196
+ class SqliteDatabase(Session):
197
+ def __init__(self, executor: SqliteExecutor, templates: Environment) -> None:
198
+ super().__init__(executor, templates)
199
+ self._writer = executor
200
+
201
+ async def read[T](
202
+ self,
203
+ operation: Callable[[storage.Session], Awaitable[T]],
204
+ *,
205
+ snapshot: bool = False,
206
+ query_budget: float | None = None,
207
+ ) -> T:
208
+ with storage_errors():
209
+ async with self._writer.reader() as reader, AsyncExitStack() as stack:
210
+ if snapshot:
211
+ await stack.enter_async_context(reader.transaction())
212
+ if query_budget is not None:
213
+ await stack.enter_async_context(reader.query_budget(query_budget))
214
+ bound = Session(reader, self._templates)
215
+ try:
216
+ return await operation(bound)
217
+ finally:
218
+ bound._state = _State.CLOSED
219
+
220
+ async def write[T](
221
+ self,
222
+ operation: Callable[[storage.Session], Awaitable[T]],
223
+ *,
224
+ transactional: bool = False,
225
+ ) -> T:
226
+ async def apply(session: SqliteSession) -> T:
227
+ bound = Session(session, self._templates)
228
+ try:
229
+ return await operation(bound)
230
+ finally:
231
+ bound._state = _State.CLOSED
232
+
233
+ with storage_errors():
234
+ if transactional:
235
+ return await self._writer.transaction_write(apply)
236
+ return await self._writer.write(apply)
237
+
238
+ async def transaction[T](
239
+ self, operation: Callable[[storage.Session], Awaitable[T]]
240
+ ) -> T:
241
+ return await self.write(operation, transactional=True)
242
+
243
+ async def apply_schema(
244
+ self, operations: tuple[storage.SchemaOperation, ...]
245
+ ) -> None:
246
+ await self.transaction(lambda session: session.apply_schema(operations))
247
+
248
+ async def wait_failure(self) -> None:
249
+ await self._writer.wait_failure()
250
+
251
+
252
+ async def _connect(
253
+ path: Path, config: SqliteConfig, templates: Environment, *, query_only: bool
254
+ ) -> aiosqlite.Connection:
255
+ connection = await aiosqlite.connect(
256
+ path, timeout=config.busy_timeout_ms / 1000, isolation_level=None
257
+ )
258
+ try:
259
+ connection.row_factory = aiosqlite.Row
260
+ await connection.execute("PRAGMA journal_mode = WAL")
261
+ await connection.execute("PRAGMA synchronous = NORMAL")
262
+ await connection.execute("PRAGMA foreign_keys = ON")
263
+ await connection.execute(
264
+ templates.get_template("busy_timeout.sql.tpl").render(
265
+ timeout=config.busy_timeout_ms
266
+ )
267
+ )
268
+ if query_only:
269
+ await connection.execute("PRAGMA query_only = ON")
270
+ return connection
271
+ except BaseException:
272
+ await connection.close()
273
+ raise
274
+
275
+
276
+ @asynccontextmanager
277
+ async def open_database(config: SqliteConfig) -> AsyncIterator[SqliteDatabase]:
278
+ templates = await asyncio.to_thread(load_templates)
279
+ path = (
280
+ config.path
281
+ or Path(os.environ.get("BCN_HOME") or Path.home()) / ".bcn" / "bcn.sqlite3"
282
+ )
283
+ with storage_errors():
284
+ path = await asyncio.to_thread(lambda: path.expanduser().resolve())
285
+ await asyncio.to_thread(
286
+ path.parent.mkdir, mode=0o700, parents=True, exist_ok=True
287
+ )
288
+ writer = await _connect(path, config, templates, query_only=False)
289
+ readers: list[aiosqlite.Connection] = []
290
+ try:
291
+ with storage_errors():
292
+ if os.name != "nt":
293
+ await asyncio.to_thread(path.chmod, 0o600)
294
+ for _ in range(config.max_idle_readers):
295
+ readers.append(await _connect(path, config, templates, query_only=True))
296
+ async with asyncio.TaskGroup() as tasks:
297
+ executor = SqliteExecutor(
298
+ writer,
299
+ readers,
300
+ reader_factory=lambda: _connect(
301
+ path, config, templates, query_only=True
302
+ ),
303
+ max_idle_readers=config.max_idle_readers,
304
+ max_readers=config.max_readers,
305
+ reader_idle_timeout=config.reader_idle_timeout,
306
+ tasks=tasks,
307
+ )
308
+ await executor.start()
309
+ try:
310
+ yield SqliteDatabase(executor, templates)
311
+ finally:
312
+ try:
313
+ async with asyncio.timeout(config.shutdown_timeout):
314
+ await executor.stop()
315
+ except BaseException:
316
+ await asyncio.shield(executor.abort())
317
+ raise
318
+ finally:
319
+ tasks.cancel()
320
+ finally:
321
+ for reader in readers:
322
+ await reader.close()
323
+ await writer.close()