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.
- litewriter-0.1.0/CHANGELOG.md +8 -0
- litewriter-0.1.0/LICENSE +21 -0
- litewriter-0.1.0/MANIFEST.in +9 -0
- litewriter-0.1.0/PKG-INFO +408 -0
- litewriter-0.1.0/README.md +383 -0
- litewriter-0.1.0/pyproject.toml +89 -0
- litewriter-0.1.0/setup.cfg +4 -0
- litewriter-0.1.0/src/litewriter/__init__.py +59 -0
- litewriter-0.1.0/src/litewriter/build.py +706 -0
- litewriter-0.1.0/src/litewriter/connect.py +41 -0
- litewriter-0.1.0/src/litewriter/errors.py +37 -0
- litewriter-0.1.0/src/litewriter/expr.py +234 -0
- litewriter-0.1.0/src/litewriter/fn.py +100 -0
- litewriter-0.1.0/src/litewriter/inbox.py +192 -0
- litewriter-0.1.0/src/litewriter/py.typed +1 -0
- litewriter-0.1.0/src/litewriter/q.py +827 -0
- litewriter-0.1.0/src/litewriter/wake.py +131 -0
- litewriter-0.1.0/src/litewriter/watch.py +154 -0
- litewriter-0.1.0/src/litewriter/writer.py +988 -0
- litewriter-0.1.0/src/litewriter.egg-info/SOURCES.txt +27 -0
- litewriter-0.1.0/tests/conftest.py +48 -0
- litewriter-0.1.0/tests/test_build.py +325 -0
- litewriter-0.1.0/tests/test_fn.py +40 -0
- litewriter-0.1.0/tests/test_inbox.py +87 -0
- litewriter-0.1.0/tests/test_more.py +202 -0
- litewriter-0.1.0/tests/test_props.py +436 -0
- litewriter-0.1.0/tests/test_q.py +235 -0
- litewriter-0.1.0/tests/test_robust.py +132 -0
- litewriter-0.1.0/tests/test_watch.py +217 -0
- litewriter-0.1.0/tests/test_writer.py +337 -0
|
@@ -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.
|
litewriter-0.1.0/LICENSE
ADDED
|
@@ -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,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`).
|