xqo-sqlite 3.0.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.
- xqo_sqlite/__init__.py +134 -0
- xqo_sqlite-3.0.0.dist-info/METADATA +38 -0
- xqo_sqlite-3.0.0.dist-info/RECORD +7 -0
- xqo_sqlite-3.0.0.dist-info/WHEEL +5 -0
- xqo_sqlite-3.0.0.dist-info/entry_points.txt +2 -0
- xqo_sqlite-3.0.0.dist-info/licenses/LICENSE +21 -0
- xqo_sqlite-3.0.0.dist-info/top_level.txt +1 -0
xqo_sqlite/__init__.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""xqo-sqlite: the "sqlite" event store for xqo.
|
|
2
|
+
|
|
3
|
+
pip install xqo-sqlite
|
|
4
|
+
"storage": {"backend": "sqlite"} # logs/events.db
|
|
5
|
+
"storage": {"backend": "sqlite", "path": "events.db", "busy_timeout_ms": 5000}
|
|
6
|
+
|
|
7
|
+
One SQLite file with indexed events: task pages, deletes and history reads don't scan the log. One server process
|
|
8
|
+
per data dir, like every xqo store. `xqo backup` writes a consistent copy through SQLite's online backup API.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import sqlite3
|
|
14
|
+
import threading
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Iterable, Iterator
|
|
17
|
+
|
|
18
|
+
from xqo.api import SEED_KINDS, EventStore, Plugin, register_store, store_path
|
|
19
|
+
|
|
20
|
+
__version__ = "3.0.0"
|
|
21
|
+
KEYS = {"backend", "path", "busy_timeout_ms"}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SqliteStore(EventStore):
|
|
25
|
+
"""One SQLite file with indexed events: reads of one task tree, kinds and recent events don't scan the log.
|
|
26
|
+
WAL mode (readers never block the writer), busy_timeout (a second writer waits instead of failing),
|
|
27
|
+
synchronous=NORMAL (a crash loses at most the last commits, never the file), a quick_check when it opens."""
|
|
28
|
+
backend = "sqlite"
|
|
29
|
+
SCHEMA = """
|
|
30
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
31
|
+
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
32
|
+
ts TEXT, kind TEXT, task_id TEXT, root_id TEXT, data TEXT NOT NULL);
|
|
33
|
+
CREATE INDEX IF NOT EXISTS events_root ON events(root_id);
|
|
34
|
+
CREATE INDEX IF NOT EXISTS events_task ON events(task_id);
|
|
35
|
+
CREATE INDEX IF NOT EXISTS events_kind ON events(kind);
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, path: str | Path, busy_timeout_ms: int = 5000):
|
|
39
|
+
self.path = Path(path)
|
|
40
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
self._lock = threading.Lock()
|
|
42
|
+
try:
|
|
43
|
+
self.db = sqlite3.connect(str(self.path), timeout=busy_timeout_ms / 1000, check_same_thread=False,
|
|
44
|
+
isolation_level=None)
|
|
45
|
+
self.db.execute(f"PRAGMA busy_timeout={int(busy_timeout_ms)}")
|
|
46
|
+
self.db.execute("PRAGMA journal_mode=WAL")
|
|
47
|
+
self.db.execute("PRAGMA synchronous=NORMAL")
|
|
48
|
+
ok = self.db.execute("PRAGMA quick_check").fetchone()[0]
|
|
49
|
+
if ok != "ok":
|
|
50
|
+
raise ValueError(f"storage: {self.path} failed its integrity check ({ok}). Restore a backup, or "
|
|
51
|
+
f"recover the readable rows: sqlite3 {self.path} \".recover\" | sqlite3 fixed.db")
|
|
52
|
+
self.db.executescript(self.SCHEMA)
|
|
53
|
+
except sqlite3.DatabaseError as e:
|
|
54
|
+
raise ValueError(f"storage: can't open {self.path}: {e}") from None
|
|
55
|
+
|
|
56
|
+
def _rows(self, sql: str, args: tuple = ()) -> list[dict]:
|
|
57
|
+
with self._lock:
|
|
58
|
+
rows = self.db.execute(sql, args).fetchall()
|
|
59
|
+
return [json.loads(r[0]) for r in rows]
|
|
60
|
+
|
|
61
|
+
def append(self, d: dict) -> None:
|
|
62
|
+
with self._lock:
|
|
63
|
+
self.db.execute("INSERT INTO events(ts, kind, task_id, root_id, data) VALUES (?, ?, ?, ?, ?)",
|
|
64
|
+
(d.get("ts"), d.get("kind"), d.get("task_id"), d.get("root_task_id"), json.dumps(d)))
|
|
65
|
+
|
|
66
|
+
def recent(self, limit: int) -> list[dict]:
|
|
67
|
+
return self._rows("SELECT data FROM (SELECT seq, data FROM events ORDER BY seq DESC LIMIT ?) ORDER BY seq",
|
|
68
|
+
(int(limit),))
|
|
69
|
+
|
|
70
|
+
def seeds(self) -> list[dict]:
|
|
71
|
+
marks = ",".join("?" * len(SEED_KINDS))
|
|
72
|
+
return self._rows(f"SELECT data FROM events WHERE kind IN ({marks}) ORDER BY seq", SEED_KINDS)
|
|
73
|
+
|
|
74
|
+
def tree(self, root_id: str) -> list[dict]:
|
|
75
|
+
return self._rows("SELECT data FROM events WHERE root_id = ? OR task_id = ? ORDER BY seq", (root_id, root_id))
|
|
76
|
+
|
|
77
|
+
def by_kind_prefix(self, prefix: str) -> list[dict]:
|
|
78
|
+
esc = prefix.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
|
79
|
+
return self._rows("SELECT data FROM events WHERE kind LIKE ? ESCAPE '\\' ORDER BY seq", (esc + "%",))
|
|
80
|
+
|
|
81
|
+
def purge(self, root_id: str, ids: set[str], keep_kinds: Iterable[str]) -> int:
|
|
82
|
+
keep = tuple(keep_kinds) or ("",)
|
|
83
|
+
tids = tuple(ids) or ("",)
|
|
84
|
+
with self._lock:
|
|
85
|
+
cur = self.db.execute(
|
|
86
|
+
f"DELETE FROM events WHERE kind NOT IN ({','.join('?' * len(keep))}) AND "
|
|
87
|
+
f"(root_id = ? OR task_id = ? OR task_id IN ({','.join('?' * len(tids))}))",
|
|
88
|
+
(*keep, root_id, root_id, *tids))
|
|
89
|
+
return cur.rowcount
|
|
90
|
+
|
|
91
|
+
def iter_all(self) -> Iterator[dict]:
|
|
92
|
+
yield from self._rows("SELECT data FROM events ORDER BY seq")
|
|
93
|
+
|
|
94
|
+
def count(self) -> int:
|
|
95
|
+
with self._lock:
|
|
96
|
+
return self.db.execute("SELECT COUNT(*) FROM events").fetchone()[0]
|
|
97
|
+
|
|
98
|
+
def backup_files(self, scratch: Path) -> dict[str, Path]:
|
|
99
|
+
"""A consistent copy through SQLite's online backup API (never a raw copy of a live database)."""
|
|
100
|
+
scratch.mkdir(parents=True, exist_ok=True)
|
|
101
|
+
dst = scratch / self.path.name
|
|
102
|
+
dst.unlink(missing_ok=True)
|
|
103
|
+
out = sqlite3.connect(str(dst))
|
|
104
|
+
try:
|
|
105
|
+
with self._lock:
|
|
106
|
+
self.db.backup(out)
|
|
107
|
+
finally:
|
|
108
|
+
out.close()
|
|
109
|
+
return {self.path.name: dst}
|
|
110
|
+
|
|
111
|
+
def describe(self) -> dict:
|
|
112
|
+
return {"backend": self.backend, "path": str(self.path), "events": self.count()}
|
|
113
|
+
|
|
114
|
+
def close(self) -> None:
|
|
115
|
+
with self._lock:
|
|
116
|
+
self.db.close()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _factory(cfg: dict, logs: Path) -> SqliteStore:
|
|
121
|
+
unknown = set(cfg) - KEYS
|
|
122
|
+
if unknown:
|
|
123
|
+
raise ValueError(f"storage: unknown setting(s) {', '.join(sorted(unknown))} for sqlite; "
|
|
124
|
+
f"known: {', '.join(sorted(KEYS))}")
|
|
125
|
+
return SqliteStore(store_path(cfg, logs, "events.db"), int(cfg.get("busy_timeout_ms", 5000)))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class SqlitePlugin(Plugin):
|
|
129
|
+
name = "sqlite"
|
|
130
|
+
description = "SQLite storage backend for the event log"
|
|
131
|
+
|
|
132
|
+
@classmethod
|
|
133
|
+
def register(cls) -> None:
|
|
134
|
+
register_store("sqlite", _factory)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: xqo-sqlite
|
|
3
|
+
Version: 3.0.0
|
|
4
|
+
Summary: SQLite event store for xqo
|
|
5
|
+
Author: dudikkocak
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/dudikkocak/xqo
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: xqo<4,>=3.0
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
14
|
+
Requires-Dist: pytest-timeout>=2.3; extra == "dev"
|
|
15
|
+
Dynamic: license-file
|
|
16
|
+
|
|
17
|
+
# xqo-sqlite
|
|
18
|
+
|
|
19
|
+
The `sqlite` event store for [xqo](https://github.com/dudikkocak/xqo): one SQLite file with indexed events, so task
|
|
20
|
+
pages, deletes and history reads don't scan the log.
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install xqo-sqlite
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
```json
|
|
27
|
+
"storage": {"backend": "sqlite"}
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
| Key | Default | |
|
|
31
|
+
|---|---|---|
|
|
32
|
+
| `path` | `events.db` | relative to the data dir's `logs/` folder |
|
|
33
|
+
| `busy_timeout_ms` | `5000` | how long a second writer waits instead of failing |
|
|
34
|
+
|
|
35
|
+
WAL mode (readers never block the writer), `synchronous=NORMAL` (a crash loses at most the last commits, never the
|
|
36
|
+
file), and a quick integrity check when it opens. `xqo backup` writes a consistent copy with SQLite's online
|
|
37
|
+
backup API. `XQO_STORAGE=sqlite` picks it for one run. Switching backends starts an empty store: events are not
|
|
38
|
+
copied between stores.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
xqo_sqlite/__init__.py,sha256=_MdOP18rcJgqBj-jNkWOrNJRbh70HXSlVY32V-yj7Zs,5881
|
|
2
|
+
xqo_sqlite-3.0.0.dist-info/licenses/LICENSE,sha256=PHtf4TI2P6oulwhOKBF8SgeOkDLp9YnLdq46mw6506s,1067
|
|
3
|
+
xqo_sqlite-3.0.0.dist-info/METADATA,sha256=NNOWvSpoqMrV_CYn_Q48t94oXVmwiQLyS5ECK8LYyjk,1250
|
|
4
|
+
xqo_sqlite-3.0.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
5
|
+
xqo_sqlite-3.0.0.dist-info/entry_points.txt,sha256=eqW5PJKdd3q5cORmJkMyw9xOIZhqgE3rdVK3dhLRHUU,47
|
|
6
|
+
xqo_sqlite-3.0.0.dist-info/top_level.txt,sha256=iZ71a3h6KknBc-cE9KkwOP0WvqF1XbCVnZu6WWhqTtQ,11
|
|
7
|
+
xqo_sqlite-3.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 dudikkocak
|
|
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 @@
|
|
|
1
|
+
xqo_sqlite
|