xqo-postgres 3.0.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.
- xqo_postgres-3.0.0/LICENSE +21 -0
- xqo_postgres-3.0.0/PKG-INFO +38 -0
- xqo_postgres-3.0.0/README.md +21 -0
- xqo_postgres-3.0.0/pyproject.toml +28 -0
- xqo_postgres-3.0.0/setup.cfg +4 -0
- xqo_postgres-3.0.0/src/xqo_postgres/__init__.py +116 -0
- xqo_postgres-3.0.0/src/xqo_postgres.egg-info/PKG-INFO +38 -0
- xqo_postgres-3.0.0/src/xqo_postgres.egg-info/SOURCES.txt +11 -0
- xqo_postgres-3.0.0/src/xqo_postgres.egg-info/dependency_links.txt +1 -0
- xqo_postgres-3.0.0/src/xqo_postgres.egg-info/entry_points.txt +2 -0
- xqo_postgres-3.0.0/src/xqo_postgres.egg-info/requires.txt +6 -0
- xqo_postgres-3.0.0/src/xqo_postgres.egg-info/top_level.txt +1 -0
- xqo_postgres-3.0.0/tests/test_postgres.py +47 -0
|
@@ -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,38 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: xqo-postgres
|
|
3
|
+
Version: 3.0.0
|
|
4
|
+
Summary: Postgres 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
|
+
Requires-Dist: psycopg[binary]>=3.1
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
15
|
+
Requires-Dist: pytest-timeout>=2.3; extra == "dev"
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
|
|
18
|
+
# xqo-postgres
|
|
19
|
+
|
|
20
|
+
The `postgres` event store for [xqo](https://github.com/dudikkocak/xqo): for several machines or a hosted dashboard
|
|
21
|
+
that share one database. A single local HQ is better served by `xqo-sqlite`.
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install xqo-postgres # brings psycopg 3
|
|
25
|
+
export XQO_PG_DSN="postgresql://xqo:secret@db.example:5432/xqo"
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
```json
|
|
29
|
+
"storage": {"backend": "postgres", "dsn_env": "XQO_PG_DSN", "table": "xqo_events"}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
| Key | Default | |
|
|
33
|
+
|---|---|---|
|
|
34
|
+
| `dsn_env` | `XQO_PG_DSN` | the environment variable that holds the connection string (never put it in agents.json) |
|
|
35
|
+
| `table` | `xqo_events` | lowercase letters, digits and `_`; created with its indexes on first start |
|
|
36
|
+
|
|
37
|
+
Back it up with `pg_dump`: `xqo backup` leaves database contents to Postgres's own tools. The tests need a
|
|
38
|
+
database: `XQO_TEST_PG_DSN=postgresql://... pytest`.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# xqo-postgres
|
|
2
|
+
|
|
3
|
+
The `postgres` event store for [xqo](https://github.com/dudikkocak/xqo): for several machines or a hosted dashboard
|
|
4
|
+
that share one database. A single local HQ is better served by `xqo-sqlite`.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
pip install xqo-postgres # brings psycopg 3
|
|
8
|
+
export XQO_PG_DSN="postgresql://xqo:secret@db.example:5432/xqo"
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```json
|
|
12
|
+
"storage": {"backend": "postgres", "dsn_env": "XQO_PG_DSN", "table": "xqo_events"}
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
| Key | Default | |
|
|
16
|
+
|---|---|---|
|
|
17
|
+
| `dsn_env` | `XQO_PG_DSN` | the environment variable that holds the connection string (never put it in agents.json) |
|
|
18
|
+
| `table` | `xqo_events` | lowercase letters, digits and `_`; created with its indexes on first start |
|
|
19
|
+
|
|
20
|
+
Back it up with `pg_dump`: `xqo backup` leaves database contents to Postgres's own tools. The tests need a
|
|
21
|
+
database: `XQO_TEST_PG_DSN=postgresql://... pytest`.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "xqo-postgres"
|
|
7
|
+
version = "3.0.0"
|
|
8
|
+
description = "Postgres event store for xqo"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [{name = "dudikkocak"}]
|
|
13
|
+
dependencies = [
|
|
14
|
+
"xqo>=3.0,<4",
|
|
15
|
+
"psycopg[binary]>=3.1",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.optional-dependencies]
|
|
19
|
+
dev = ["pytest>=8", "pytest-timeout>=2.3"]
|
|
20
|
+
|
|
21
|
+
[project.urls]
|
|
22
|
+
Homepage = "https://github.com/dudikkocak/xqo"
|
|
23
|
+
|
|
24
|
+
[project.entry-points."xqo.plugins"]
|
|
25
|
+
postgres = "xqo_postgres:PostgresPlugin"
|
|
26
|
+
|
|
27
|
+
[tool.setuptools.packages.find]
|
|
28
|
+
where = ["src"]
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""xqo-postgres: the "postgres" event store for xqo.
|
|
2
|
+
|
|
3
|
+
pip install xqo-postgres # psycopg 3
|
|
4
|
+
export XQO_PG_DSN="postgresql://xqo:secret@db.example:5432/xqo"
|
|
5
|
+
"storage": {"backend": "postgres", "dsn_env": "XQO_PG_DSN", "table": "xqo_events"}
|
|
6
|
+
|
|
7
|
+
Use it when several machines or a hosted dashboard need one shared database; a single local HQ is better served by
|
|
8
|
+
xqo-sqlite. Back it up with pg_dump (xqo backup leaves database contents to Postgres's own tools).
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import threading
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Iterable, Iterator
|
|
18
|
+
|
|
19
|
+
from xqo.api import SEED_KINDS, EventStore, Plugin, register_store
|
|
20
|
+
|
|
21
|
+
__version__ = "3.0.0"
|
|
22
|
+
KEYS = {"backend", "dsn_env", "table"}
|
|
23
|
+
|
|
24
|
+
TABLE = re.compile(r"^[a-z_][a-z0-9_]{0,62}$")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class PostgresStore(EventStore):
|
|
28
|
+
backend = "postgres"
|
|
29
|
+
|
|
30
|
+
def __init__(self, dsn: str, table: str = "xqo_events"):
|
|
31
|
+
try:
|
|
32
|
+
import psycopg
|
|
33
|
+
except ImportError:
|
|
34
|
+
raise ValueError("storage: the postgres backend needs psycopg: pip install xqo-postgres") from None
|
|
35
|
+
if not TABLE.match(table):
|
|
36
|
+
raise ValueError("storage.table: lowercase letters, digits and _ only")
|
|
37
|
+
self.table = table
|
|
38
|
+
self._lock = threading.Lock()
|
|
39
|
+
try:
|
|
40
|
+
self.db = psycopg.connect(dsn, autocommit=True, connect_timeout=10)
|
|
41
|
+
self.db.execute(f"""
|
|
42
|
+
CREATE TABLE IF NOT EXISTS {table} (
|
|
43
|
+
seq BIGSERIAL PRIMARY KEY, ts TEXT, kind TEXT, task_id TEXT, root_id TEXT, data JSONB NOT NULL);
|
|
44
|
+
CREATE INDEX IF NOT EXISTS {table}_root ON {table}(root_id);
|
|
45
|
+
CREATE INDEX IF NOT EXISTS {table}_task ON {table}(task_id);
|
|
46
|
+
CREATE INDEX IF NOT EXISTS {table}_kind ON {table}(kind);""")
|
|
47
|
+
except psycopg.Error as e:
|
|
48
|
+
raise ValueError(f"storage: can't use Postgres: {str(e).strip().splitlines()[0]}") from None
|
|
49
|
+
|
|
50
|
+
def _rows(self, sql: str, args: tuple = ()) -> list[dict]:
|
|
51
|
+
with self._lock:
|
|
52
|
+
rows = self.db.execute(sql, args).fetchall()
|
|
53
|
+
return [r[0] if isinstance(r[0], dict) else json.loads(r[0]) for r in rows]
|
|
54
|
+
|
|
55
|
+
def append(self, d: dict) -> None:
|
|
56
|
+
with self._lock:
|
|
57
|
+
self.db.execute(f"INSERT INTO {self.table}(ts, kind, task_id, root_id, data) VALUES (%s, %s, %s, %s, %s)",
|
|
58
|
+
(d.get("ts"), d.get("kind"), d.get("task_id"), d.get("root_task_id"), json.dumps(d)))
|
|
59
|
+
|
|
60
|
+
def recent(self, limit: int) -> list[dict]:
|
|
61
|
+
return self._rows(f"SELECT data FROM (SELECT seq, data FROM {self.table} ORDER BY seq DESC LIMIT %s) r "
|
|
62
|
+
"ORDER BY seq", (int(limit),))
|
|
63
|
+
|
|
64
|
+
def seeds(self) -> list[dict]:
|
|
65
|
+
return self._rows(f"SELECT data FROM {self.table} WHERE kind = ANY(%s) ORDER BY seq", (list(SEED_KINDS),))
|
|
66
|
+
|
|
67
|
+
def tree(self, root_id: str) -> list[dict]:
|
|
68
|
+
return self._rows(f"SELECT data FROM {self.table} WHERE root_id = %s OR task_id = %s ORDER BY seq",
|
|
69
|
+
(root_id, root_id))
|
|
70
|
+
|
|
71
|
+
def by_kind_prefix(self, prefix: str) -> list[dict]:
|
|
72
|
+
esc = prefix.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
|
73
|
+
return self._rows(f"SELECT data FROM {self.table} WHERE kind LIKE %s ORDER BY seq", (esc + "%",))
|
|
74
|
+
|
|
75
|
+
def purge(self, root_id: str, ids: set[str], keep_kinds: Iterable[str]) -> int:
|
|
76
|
+
with self._lock:
|
|
77
|
+
cur = self.db.execute(
|
|
78
|
+
f"DELETE FROM {self.table} WHERE NOT (kind = ANY(%s)) AND "
|
|
79
|
+
"(root_id = %s OR task_id = %s OR task_id = ANY(%s))",
|
|
80
|
+
(list(keep_kinds), root_id, root_id, list(ids)))
|
|
81
|
+
return cur.rowcount
|
|
82
|
+
|
|
83
|
+
def iter_all(self) -> Iterator[dict]:
|
|
84
|
+
yield from self._rows(f"SELECT data FROM {self.table} ORDER BY seq")
|
|
85
|
+
|
|
86
|
+
def count(self) -> int:
|
|
87
|
+
with self._lock:
|
|
88
|
+
return self.db.execute(f"SELECT COUNT(*) FROM {self.table}").fetchone()[0]
|
|
89
|
+
|
|
90
|
+
def describe(self) -> dict:
|
|
91
|
+
return {"backend": self.backend, "table": self.table, "events": self.count()}
|
|
92
|
+
|
|
93
|
+
def close(self) -> None:
|
|
94
|
+
with self._lock:
|
|
95
|
+
self.db.close()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _factory(cfg: dict, logs: Path) -> PostgresStore:
|
|
99
|
+
unknown = set(cfg) - KEYS
|
|
100
|
+
if unknown:
|
|
101
|
+
raise ValueError(f"storage: unknown setting(s) {', '.join(sorted(unknown))} for postgres; "
|
|
102
|
+
f"known: {', '.join(sorted(KEYS))}")
|
|
103
|
+
env = cfg.get("dsn_env") or "XQO_PG_DSN"
|
|
104
|
+
dsn = os.getenv(env)
|
|
105
|
+
if not dsn:
|
|
106
|
+
raise ValueError(f"storage: set the environment variable {env} to the Postgres DSN (storage.dsn_env)")
|
|
107
|
+
return PostgresStore(dsn, str(cfg.get("table") or "xqo_events"))
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class PostgresPlugin(Plugin):
|
|
111
|
+
name = "postgres"
|
|
112
|
+
description = "Postgres storage backend for the event log"
|
|
113
|
+
|
|
114
|
+
@classmethod
|
|
115
|
+
def register(cls) -> None:
|
|
116
|
+
register_store("postgres", _factory)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: xqo-postgres
|
|
3
|
+
Version: 3.0.0
|
|
4
|
+
Summary: Postgres 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
|
+
Requires-Dist: psycopg[binary]>=3.1
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
15
|
+
Requires-Dist: pytest-timeout>=2.3; extra == "dev"
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
|
|
18
|
+
# xqo-postgres
|
|
19
|
+
|
|
20
|
+
The `postgres` event store for [xqo](https://github.com/dudikkocak/xqo): for several machines or a hosted dashboard
|
|
21
|
+
that share one database. A single local HQ is better served by `xqo-sqlite`.
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install xqo-postgres # brings psycopg 3
|
|
25
|
+
export XQO_PG_DSN="postgresql://xqo:secret@db.example:5432/xqo"
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
```json
|
|
29
|
+
"storage": {"backend": "postgres", "dsn_env": "XQO_PG_DSN", "table": "xqo_events"}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
| Key | Default | |
|
|
33
|
+
|---|---|---|
|
|
34
|
+
| `dsn_env` | `XQO_PG_DSN` | the environment variable that holds the connection string (never put it in agents.json) |
|
|
35
|
+
| `table` | `xqo_events` | lowercase letters, digits and `_`; created with its indexes on first start |
|
|
36
|
+
|
|
37
|
+
Back it up with `pg_dump`: `xqo backup` leaves database contents to Postgres's own tools. The tests need a
|
|
38
|
+
database: `XQO_TEST_PG_DSN=postgresql://... pytest`.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/xqo_postgres/__init__.py
|
|
5
|
+
src/xqo_postgres.egg-info/PKG-INFO
|
|
6
|
+
src/xqo_postgres.egg-info/SOURCES.txt
|
|
7
|
+
src/xqo_postgres.egg-info/dependency_links.txt
|
|
8
|
+
src/xqo_postgres.egg-info/entry_points.txt
|
|
9
|
+
src/xqo_postgres.egg-info/requires.txt
|
|
10
|
+
src/xqo_postgres.egg-info/top_level.txt
|
|
11
|
+
tests/test_postgres.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
xqo_postgres
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""xqo-postgres: the storage contract and restarts against a real database (XQO_TEST_PG_DSN), plus config checks."""
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import uuid
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from xqo.config import build
|
|
9
|
+
from xqo.store import open_store
|
|
10
|
+
from xqo.testing import check_event_store, check_restart_keeps_tasks
|
|
11
|
+
from xqo_postgres import PostgresPlugin, PostgresStore
|
|
12
|
+
|
|
13
|
+
PG_DSN = os.getenv("XQO_TEST_PG_DSN") # e.g. postgresql://xqo@/xqo_test?host=/tmp&port=55432
|
|
14
|
+
needs_pg = pytest.mark.skipif(not PG_DSN, reason="set XQO_TEST_PG_DSN to test against Postgres")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@needs_pg
|
|
18
|
+
def test_contract():
|
|
19
|
+
check_event_store(PostgresStore(PG_DSN, "t_" + uuid.uuid4().hex[:12]))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@needs_pg
|
|
23
|
+
def test_all_tasks_survive_a_restart(tmp_path, monkeypatch):
|
|
24
|
+
monkeypatch.setenv("XQO_PG_DSN", PG_DSN)
|
|
25
|
+
table = "b_" + uuid.uuid4().hex[:12]
|
|
26
|
+
again = check_restart_keeps_tasks(tmp_path, {"backend": "postgres", "table": table})
|
|
27
|
+
assert again.store.describe()["table"] == table
|
|
28
|
+
assert not (tmp_path / "logs" / "events.jsonl").exists()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_found_as_a_plugin_and_needs_its_dsn(tmp_path, monkeypatch):
|
|
32
|
+
monkeypatch.delenv("XQO_PG_DSN", raising=False)
|
|
33
|
+
cfg = tmp_path / "agents.json"
|
|
34
|
+
cfg.write_text(json.dumps({"agents": [{"name": "planner", "skills": ["planning"]}],
|
|
35
|
+
"storage": {"backend": "postgres"}}))
|
|
36
|
+
with pytest.raises(ValueError, match="XQO_PG_DSN"):
|
|
37
|
+
build(cfg, tmp_path / "a", str(tmp_path / "l" / "e.jsonl"), mock=True, departments="none")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_bad_config_stops_startup(tmp_path):
|
|
41
|
+
PostgresPlugin.register()
|
|
42
|
+
with pytest.raises(ValueError, match="dsn_env"):
|
|
43
|
+
open_store({"backend": "postgres", "dsn": "postgresql://x"}, tmp_path / "events.jsonl")
|
|
44
|
+
with pytest.raises(ValueError, match="unknown setting.*for postgres"):
|
|
45
|
+
open_store({"backend": "postgres", "path": "x"}, tmp_path / "events.jsonl")
|
|
46
|
+
with pytest.raises(ValueError, match="lowercase"):
|
|
47
|
+
PostgresStore("postgresql://unused", "Bad-Name")
|