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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,2 @@
1
+ [xqo.plugins]
2
+ postgres = xqo_postgres:PostgresPlugin
@@ -0,0 +1,6 @@
1
+ xqo<4,>=3.0
2
+ psycopg[binary]>=3.1
3
+
4
+ [dev]
5
+ pytest>=8
6
+ pytest-timeout>=2.3
@@ -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")