litewriter 0.1.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,8 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ - One SQLite file. One writer thread. Reads stay on the calling thread.
6
+ - `isolated=True` is the default. A failed write undoes only that write.
7
+ - `Select`, `Insert`, `Update`, and `Delete`. `col`, `lit`, `param`, and `.as_()` build expressions.
8
+ - `db.watch` yields again when a commit changes a column the query reads.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Adam Bobowski
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,9 @@
1
+ include LICENSE
2
+ include README.md
3
+ include CHANGELOG.md
4
+ include src/litewriter/py.typed
5
+ graft tests
6
+ global-exclude *.so *.pyd *.html *.pyc
7
+ prune tests/__pycache__
8
+ prune src/*.egg-info
9
+ recursive-exclude * *.egg-info
@@ -0,0 +1,408 @@
1
+ Metadata-Version: 2.4
2
+ Name: litewriter
3
+ Version: 0.1.0
4
+ Summary: One SQLite writer thread and a query builder.
5
+ Author-email: Adam Bobowski <adam.bobowski@wratilabs.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/Bobowski/litewriter
8
+ Project-URL: Repository, https://github.com/Bobowski/litewriter
9
+ Project-URL: Issues, https://github.com/Bobowski/litewriter/issues
10
+ Project-URL: Changelog, https://github.com/Bobowski/litewriter/blob/main/CHANGELOG.md
11
+ Keywords: sqlite,apsw,writer,group-commit,wal
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.14
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Topic :: Database
18
+ Classifier: Topic :: Software Development :: Libraries
19
+ Classifier: Typing :: Typed
20
+ Requires-Python: >=3.14
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: apsw>=3.53.4.0
24
+ Dynamic: license-file
25
+
26
+ # Litewriter
27
+
28
+ ```text
29
+ pip install litewriter
30
+ ```
31
+
32
+ Source: https://github.com/Bobowski/litewriter
33
+
34
+ The index name is `litewriter`. The import is `litewriter`.
35
+ The class is `LiteWriter`. It is SQLite only.
36
+
37
+ One SQLite file. One writer thread. Writes that are waiting share one
38
+ `COMMIT`. Each OS thread reads on its own connection. A read does not
39
+ wait on that writer. A watch reads again when a commit changes a column
40
+ it reads.
41
+
42
+ ```python
43
+ from litewriter import LiteWriter
44
+ ```
45
+
46
+ ## A query
47
+
48
+ A query is a statement. The constructor takes the clauses.
49
+ `col` is a name. `lit` is a string literal. `param` is a parameter.
50
+ `sql(q)` returns one line of SQL. `query` returns a list of tuples.
51
+
52
+ ```python
53
+ from litewriter import Select, col, param, sql
54
+
55
+ page = Select(
56
+ col("m.id"),
57
+ col("m.body"),
58
+ ("as", ("lower", "u.name"), "author"),
59
+ from_=col("messages").as_("m"),
60
+ join=("left", col("users").as_("u"), col("m.author") == col("u.id")),
61
+ where=col("m.room") == param("room"),
62
+ order_by=col("m.at").desc(),
63
+ limit=50,
64
+ )
65
+ print(sql(page))
66
+
67
+ mine = page.where(col("m.author") == param("me")).limit(20)
68
+ print(sql(mine))
69
+ print(sql(page))
70
+
71
+ listed = Select(
72
+ "id",
73
+ from_="t",
74
+ where=[col("room") == param("room"), col("at") > 0],
75
+ )
76
+ print(sql(listed))
77
+
78
+ inner = Select("id", from_="t", where=col("ok") == 1)
79
+ print(sql(Select(col("s.id"), from_=inner.as_("s"))))
80
+ ```
81
+
82
+ ```text
83
+ SELECT m.id, m.body, lower(u.name) AS author FROM messages AS m LEFT JOIN users AS u ON m.author = u.id WHERE m.room = :room ORDER BY m.at DESC LIMIT 50
84
+ SELECT m.id, m.body, lower(u.name) AS author FROM messages AS m LEFT JOIN users AS u ON m.author = u.id WHERE m.room = :room AND m.author = :me ORDER BY m.at DESC LIMIT 20
85
+ SELECT m.id, m.body, lower(u.name) AS author FROM messages AS m LEFT JOIN users AS u ON m.author = u.id WHERE m.room = :room ORDER BY m.at DESC LIMIT 50
86
+ SELECT id FROM t WHERE room = :room AND at > 0
87
+ SELECT s.id FROM (SELECT id FROM t WHERE ok = 1) AS s
88
+ ```
89
+
90
+ The third line is the first line again. `page.where(...)` returns a new
91
+ statement. `page` stays as it was. `.where(a, b)` joins those arguments
92
+ with `AND`. A list is one argument. `.where([a, b])` does not compile.
93
+
94
+ In a constructor, a list of conditions is `AND`. A list that starts with
95
+ a name is a call. `["=", "room", ":room"]` is `room = :room`.
96
+ `("count", "*")` is `count(*)`. The string `count(*)` is not a name.
97
+
98
+ `&` and `|` are bitwise. An expression has no truth value. The words
99
+ `and` and `or` raise `TypeError`. Put conditions in a list in the
100
+ constructor. That list is `AND`.
101
+
102
+ `from_` and `with_` keep the underscore. `from` and `with` are Python
103
+ keywords.
104
+
105
+ A `Select` is a subquery. `from_=inner.as_("s")` is
106
+ `FROM (SELECT ...) AS s`. The same form works in a join, in `where`,
107
+ and in the select list.
108
+
109
+ A tuple is a call. `("lower", "name")` is `lower(name)`.
110
+ `("as", expr, "name")` is the same alias as `.as_("name")`.
111
+ `("lit", "open")` is the same literal as `lit("open")`.
112
+
113
+ `Select` has the select methods. `Insert` has the insert methods.
114
+ `Update` has `set`, `from_`, and `where`. `Delete` has `where`.
115
+ A union has `order_by` and `limit`. The constructor is the whole
116
+ statement. A method adds one clause. A dict of clauses still renders.
117
+
118
+ ## Values
119
+
120
+ A value becomes SQL:
121
+
122
+ | Value | SQL |
123
+ | --- | --- |
124
+ | `col("u.name")`, `col("*")`, `col("u.*")` | a name (a keyword such as `order` is quoted) |
125
+ | `lit("open")` or `"'open'"` or `("lit", "open")` | a string literal |
126
+ | `param("room")` or `":room"` | a parameter |
127
+ | `col("room") == param("room")` | `room = :room` |
128
+ | `Select("id", from_="t").as_("s")` | `(SELECT id FROM t) AS s` |
129
+ | `1`, `2.5`, `True`, `None`, `b"\x01"` | `1`, `2.5`, `TRUE`, `NULL`, `X'01'` |
130
+ | `("lower", "u.name")` or `["lower", "u.name"]` | a call: `lower(u.name)` |
131
+ | `("as", "messages", "m")` | `messages AS m` |
132
+ | `("as", ("lower", "u.name"), "author")` | `lower(u.name) AS author` |
133
+ | `{"select": ["id"], "from": "t"}` | `(SELECT id FROM t)` |
134
+
135
+ A call name that is not an operator is a function. LiteWriter does not
136
+ keep a list of functions. `json_extract`, `datetime`, and a function
137
+ that you register on the connection all work the same way.
138
+
139
+ A value from outside goes in as a parameter (`:name`), never as text.
140
+
141
+ ## Calls
142
+
143
+ The call name is not case sensitive.
144
+
145
+ | Call | SQL |
146
+ | --- | --- |
147
+ | `["=", a, b]`, also `!=` `<` `<=` `>` `>=` `is` `is not` `like` `glob` `regexp` `match` | `a = b` |
148
+ | `["and", a, b, c]`, `["or", ...]` | `a AND b AND c` |
149
+ | `["+", a, b]`, also `-` `*` `/` `%` `\|\|` `->` `->>` `&` `\|` `<<` `>>` | `a + b` |
150
+ | `["not", a]` | `NOT a` |
151
+ | `["in", a, x, y]`, `["in", a, query]`, `not in` | `a IN (x, y)` |
152
+ | `["between", a, lo, hi]`, `not between` | `a BETWEEN lo AND hi` |
153
+ | `["exists", query]`, `not exists` | `EXISTS (...)` |
154
+ | `["case", when, then, ..., else]` | `CASE WHEN ... END` |
155
+ | `["as", a, "name"]` | `a AS name` |
156
+ | `["cast", a, "integer"]` | `CAST(a AS INTEGER)` |
157
+ | `["collate", a, "nocase"]` | `a COLLATE NOCASE` |
158
+ | `["desc", a]`, `["asc", a, "nulls last"]` | `a DESC` |
159
+ | `["distinct", a]` | `DISTINCT a` |
160
+ | `["raw", "any SQL"]` | the text as it is |
161
+
162
+ Parentheses come out only where SQL needs them.
163
+ `["and", ["or", a, b], c]` is `(a OR b) AND c`.
164
+
165
+ ## Clauses
166
+
167
+ The key order in the dict does not matter. The output uses SQL order.
168
+
169
+ | Statement | Keys |
170
+ | --- | --- |
171
+ | select | `with`, `with_recursive`, `select` or `select_distinct`, `from`, `join`, `where`, `group_by`, `having`, `order_by`, `limit`, `offset` |
172
+ | compound | `union`, `union_all`, `intersect`, or `except` (a list of queries), `order_by`, `limit`, `offset` |
173
+ | insert | `insert_into` or `replace_into`, `columns`, `values` or the select keys, `on_conflict`, `do_nothing`, `do_update_set`, `returning` |
174
+ | update | `update`, `set`, `from`, `join`, `where`, `returning` |
175
+ | delete | `delete_from`, `where`, `returning` |
176
+
177
+ - `select`, `group_by`, `order_by`, `returning`, and `columns` are lists.
178
+ A call sits in its own list: `"select": [["count", "*"]]`.
179
+ - `from` is a table, `("as", "table", "t")`, a table function
180
+ (`("json_each", ":ids")`), or a subquery.
181
+ - `join` is `(kind, source, on)`. A list of those is many joins.
182
+ `kind` is `inner`, `left`, `right`, `full`, or `cross`.
183
+ A cross join is `("cross", source)`.
184
+ - `with` is a dict of name to query.
185
+ - `values` is one row (a dict) or many rows. A list of lists needs
186
+ `columns`.
187
+ - `set` and `do_update_set` are a dict of column to value.
188
+ - `on_conflict` is a list of columns. `[]` means any conflict.
189
+ `do_update` needs those column names.
190
+
191
+ An unknown key raises `WriterError`. A key that does not belong on that
192
+ statement raises `WriterError`. A bad name or a missing parameter does
193
+ too. An insert may contain the select keys. That form is
194
+ `INSERT INTO t SELECT ...`.
195
+
196
+ ## The file
197
+
198
+ This program replaces `example.sqlite3` in the current directory.
199
+ `async with` starts the writer and closes it at the end. `hz=0` commits
200
+ as soon as the job arrives. The default is 60.
201
+
202
+ ```python
203
+ import asyncio
204
+ from pathlib import Path
205
+
206
+ from litewriter import Insert, LiteWriter, Select, Tx, col, param
207
+
208
+ page = Select(
209
+ col("m.id"),
210
+ col("m.body"),
211
+ ("as", ("lower", "u.name"), "author"),
212
+ from_=col("messages").as_("m"),
213
+ join=("left", col("users").as_("u"), col("m.author") == col("u.id")),
214
+ where=col("m.room") == param("room"),
215
+ order_by=col("m.at").desc(),
216
+ limit=50,
217
+ )
218
+ mine = page.where(col("m.author") == param("me")).limit(20)
219
+
220
+ SCHEMA = """
221
+ CREATE TABLE users (
222
+ id INTEGER PRIMARY KEY,
223
+ name TEXT NOT NULL
224
+ );
225
+ CREATE TABLE messages (
226
+ id INTEGER PRIMARY KEY,
227
+ room INTEGER NOT NULL,
228
+ author INTEGER REFERENCES users (id),
229
+ body TEXT NOT NULL,
230
+ at INTEGER NOT NULL
231
+ );
232
+ """
233
+
234
+
235
+ def add_user(tx: Tx, name: str) -> int:
236
+ return tx.value(
237
+ Insert("users", values={"name": ":name"}, returning="id"),
238
+ name=name,
239
+ )
240
+
241
+
242
+ def send(tx: Tx, room: int, body: str, author: int, at: int) -> int:
243
+ return tx.value(
244
+ Insert(
245
+ "messages",
246
+ values={
247
+ "room": ":room",
248
+ "author": ":author",
249
+ "body": ":body",
250
+ "at": ":at",
251
+ },
252
+ returning="id",
253
+ ),
254
+ room=room,
255
+ author=author,
256
+ body=body,
257
+ at=at,
258
+ )
259
+
260
+
261
+ async def main() -> None:
262
+ path = Path("example.sqlite3")
263
+ path.unlink(missing_ok=True)
264
+ path.with_name(path.name + "-wal").unlink(missing_ok=True)
265
+ path.with_name(path.name + "-shm").unlink(missing_ok=True)
266
+ async with LiteWriter(path, hz=0) as db:
267
+ db.execute_script(SCHEMA)
268
+ user = await db.call(add_user, "Ada")
269
+ msg = await db.call(send, 8, "hi", user, 1)
270
+ print("msg", msg)
271
+ print("page", db.query(page, room=8))
272
+ print("mine", db.query(mine, room=8, me=user))
273
+ print("empty", db.one(page, room=9))
274
+ print("count", db.value(Select(("count", "*"), from_="messages")))
275
+ row = db.execute(
276
+ "SELECT name FROM users WHERE id = :id",
277
+ {"id": user},
278
+ ).fetchone()
279
+ print("name", tuple(row))
280
+ print("tables", sorted(db.tables(page)))
281
+ with db.reader() as reader:
282
+ print("reader", reader.query(page, room=8))
283
+ async with db.watch(page, room=8) as live:
284
+ found = aiter(live)
285
+ print("now", await anext(found))
286
+ await db.call(send, 8, "next", user, 2)
287
+ print("later", await anext(found))
288
+
289
+
290
+ asyncio.run(main())
291
+ ```
292
+
293
+ ```text
294
+ msg 1
295
+ page [(1, 'hi', 'ada')]
296
+ mine [(1, 'hi', 'ada')]
297
+ empty None
298
+ count 1
299
+ name ('Ada',)
300
+ tables ['messages', 'users']
301
+ reader [(1, 'hi', 'ada')]
302
+ now [(1, 'hi', 'ada')]
303
+ later [(2, 'next', 'ada'), (1, 'hi', 'ada')]
304
+ ```
305
+
306
+ The writer calls `fn(tx, *args, **kwargs)` on the writer thread, inside
307
+ `BEGIN IMMEDIATE`. You do not `COMMIT`. You do not keep `tx`. You do not
308
+ block. `tx.value` is the first column of the first row. `tx.one` is the
309
+ first row. `tx.query` is every row. `tx.execute` runs SQL you already
310
+ have. A sequence fills `?`. A mapping fills `:name`.
311
+
312
+ - `await db.call(fn, ...)` waits on this asyncio loop. The caller thread stays free.
313
+ - `db.push(fn, ...)` puts the write on the queue. It does not wait. Errors go to `on_error`.
314
+ - `db.submit(fn, ...)` returns a slot. `.result()` waits on the caller thread.
315
+ On an asyncio loop, use `call`.
316
+ - The result comes after the batch `COMMIT`.
317
+ - `hz=60` is the default. After a commit, the writer sleeps the rest of that 1/60 s.
318
+ When the inbox is empty, the writer parks. `hz=0` commits as fast as
319
+ jobs arrive. You can change `db.hz` while it runs.
320
+ - `isolated=True` is the default. It sets a SAVEPOINT for that write.
321
+ A failure undoes only that write. The error comes back after the rest
322
+ of the batch commits. `isolated(fn)` does the same thing.
323
+ - `isolated=False` shares the batch. A failure rolls back the whole batch.
324
+ The other writes in that batch get `WriterRolledBack`.
325
+ - `db.close()` drains the inbox, commits the last batch, and stops. A
326
+ write that arrives too late fails with `WriterRuntime`.
327
+ - `submit` returns `Slot[R]`, and `call` returns `R`, where `R` is the
328
+ return type of `fn`. Pyright checks the arguments against `fn`.
329
+
330
+ ## Reads
331
+
332
+ | Call | Returns |
333
+ | --- | --- |
334
+ | `db.reader()` | a new read-only connection. Open as many as you need |
335
+ | `db.query(q, **params)` | all rows, a list of tuples |
336
+ | `db.one(q, **params)` | the first row, or None |
337
+ | `db.value(q, **params)` | the first column of the first row. No row raises `WriterError` |
338
+ | `db.execute(q, params)` | an APSW cursor. `params` is a mapping or a sequence |
339
+ | `db.watch(q, **params)` | live rows. The writer must be started |
340
+ | `db.tables(q)` | the tables `q` reads |
341
+ | `db.offload(fn)` | `fn(conn)` on a worker thread, for a rare large read |
342
+
343
+ `q` is SQL text or a query. A dict of clauses is still a query.
344
+ `query`, `one`, and `value` take keyword arguments.
345
+ `execute` takes the bindings as one argument.
346
+
347
+ A read does not enter the writer queue. The writer does not need to be
348
+ started. The file must exist. A missing file raises `WriterRuntime`.
349
+ `db.query` uses one read-only connection for this OS thread. Another
350
+ thread opens its own connection. `db.reader()` opens one more.
351
+ Use that reader from the thread that opened it. Close it on that thread.
352
+ `db.close()` closes a reader opened on this thread. A reader on another
353
+ thread closes on its next use.
354
+
355
+ ## Watch
356
+
357
+ The first read is the rows now. A later read comes after a commit that
358
+ changed them. `async for rows in live` keeps going. The program above
359
+ stops after the second read. A slow reader gets the newest rows, not a
360
+ queue of old ones.
361
+
362
+ The commit does not run the query. SQLite names the columns the query
363
+ reads, once, when the watch opens. The writer records inserts, deletes,
364
+ and updated columns only while a watch is open. `BEGIN`, `COMMIT`,
365
+ `SAVEPOINT`, `RELEASE`, `ROLLBACK`, and `PRAGMA schema_version` are the
366
+ writer's own SQL. They add no user table.
367
+
368
+ An insert or a delete wakes every watch of that table. An update wakes
369
+ a watch when the updated column is one the query reads. `count(*)`
370
+ wakes on insert and delete only. After `COMMIT`, every matching watch
371
+ on one asyncio loop is set in one wake.
372
+
373
+ A write to `messages` does not wake a watch of `users`. An update of
374
+ `users.seen` does not wake `SELECT name FROM users`. The watch reads
375
+ again on this thread's read connection. Equal rows do not yield. A
376
+ rolled-back batch does not wake anyone. A schema change wakes every
377
+ watch. A view, a CTE, and raw SQL text all work, because SQLite
378
+ resolves them to base columns.
379
+
380
+ ## Install
381
+
382
+ CPython 3.14. The package is pure Python. `pip install litewriter` also
383
+ installs APSW. A `v*` tag on the package repository publishes it.
384
+
385
+ ```text
386
+ pip install litewriter
387
+ ```
388
+
389
+ From a checkout of this package:
390
+
391
+ ```text
392
+ uv sync
393
+ uv run pytest
394
+ ```
395
+
396
+ ## Threads
397
+
398
+ `submit`, `call`, and `push` are safe from any OS thread. The writer
399
+ owns the write connection. Each OS thread reads on its own connection.
400
+ A `reader()` stays on the thread that opened it. Jobs sit on a deque
401
+ and a Condition. After `COMMIT` the writer wakes each asyncio loop once
402
+ per batch through a socketpair mailbox. Matching watches on that loop
403
+ are set in that same wake. The mailbox is made on the loop's own thread
404
+ (the first `call` or `watch` there), because `add_reader` is not
405
+ thread-safe.
406
+
407
+ Each connection has a 64 MiB page cache and maps up to 1 GiB of the file
408
+ (`litewriter.connect.CACHE_KIB`, `MMAP_BYTES`).