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 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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [xqo.plugins]
2
+ sqlite = xqo_sqlite:SqlitePlugin
@@ -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