gnomon-postgres 1.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.
- gnomon/__init__.py +140 -0
- gnomon/_util.py +82 -0
- gnomon/aio.py +360 -0
- gnomon/asyncpg.py +171 -0
- gnomon/client.py +361 -0
- gnomon/django.py +68 -0
- gnomon/errors.py +65 -0
- gnomon/pytest_plugin.py +62 -0
- gnomon/sqlalchemy.py +32 -0
- gnomon/testing.py +129 -0
- gnomon/tools.json +40 -0
- gnomon/tools.py +103 -0
- gnomon_postgres-1.0.0.dist-info/METADATA +79 -0
- gnomon_postgres-1.0.0.dist-info/RECORD +18 -0
- gnomon_postgres-1.0.0.dist-info/WHEEL +5 -0
- gnomon_postgres-1.0.0.dist-info/entry_points.txt +2 -0
- gnomon_postgres-1.0.0.dist-info/licenses/LICENSE +17 -0
- gnomon_postgres-1.0.0.dist-info/top_level.txt +1 -0
gnomon/__init__.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Helpers for apps on a PostgreSQL database with the gnomon extension.
|
|
2
|
+
|
|
3
|
+
Choosing a branch is plain SQL. In SQL-only mode it takes the setting *and* the routing
|
|
4
|
+
search_path, which ``gnomon.use()`` (session) and ``gnomon.begin()`` (one transaction, safe behind
|
|
5
|
+
transaction poolers) set together; with the C module loaded, ``SET gnomon.branch`` alone routes.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import contextlib
|
|
10
|
+
import os
|
|
11
|
+
import secrets
|
|
12
|
+
from typing import Iterator, Optional
|
|
13
|
+
from urllib.parse import quote
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"routing_schema", "branch_options", "connection_options", "branch_dsn", "use", "begin", "create_branch", "create_scratch",
|
|
17
|
+
"drop_branch", "merge", "diff", "branch", "connect",
|
|
18
|
+
# the SDK
|
|
19
|
+
"Gnomon", "AsyncGnomon", "Run", "AsyncRun", "GnomonError",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def routing_schema(schema: str = "public", *, scratch: bool = False) -> str:
|
|
24
|
+
"""The routing schema of a user schema; scratch branches have their own (``__s``)."""
|
|
25
|
+
return f"gnomon_r_{schema}" + ("__s" if scratch else "")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def branch_options(branch: str, *, c_module: bool = False, schemas: tuple[str, ...] = ("public",),
|
|
29
|
+
as_of: Optional[str] = None, scratch: bool = False) -> str:
|
|
30
|
+
"""The libpq ``options`` value that puts a connection on a branch.
|
|
31
|
+
``scratch``: the branch is a scratch branch (it is read through the scratch routing schemas)."""
|
|
32
|
+
opts = [f"-c gnomon.branch={branch}"]
|
|
33
|
+
if as_of:
|
|
34
|
+
opts.append(f"-c gnomon.as_of={as_of}")
|
|
35
|
+
if not c_module:
|
|
36
|
+
path = ",".join(p for s in schemas for p in (routing_schema(s, scratch=scratch), s))
|
|
37
|
+
opts.append(f"-c search_path={path}")
|
|
38
|
+
return " ".join(opts)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def connection_options(conn, branch: str, *, c_module: bool = False) -> str:
|
|
42
|
+
"""The ``options`` value the database computes for ``branch`` (``gnomon.connection_options``):
|
|
43
|
+
unlike :func:`branch_options`, it knows the branch's kind, a confined role's mask profile and
|
|
44
|
+
the branch's schema version."""
|
|
45
|
+
return conn.execute("select gnomon.connection_options(%s, %s)", (branch, c_module)).fetchone()[0]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def branch_dsn(dsn: str, branch: str, *, options: Optional[str] = None, **kw) -> str:
|
|
49
|
+
"""A connection string (URL or key=value) whose sessions start on ``branch``. ``options``: a
|
|
50
|
+
value from :func:`connection_options` instead of the one built here."""
|
|
51
|
+
opts = options or branch_options(branch, **kw)
|
|
52
|
+
if "://" in dsn:
|
|
53
|
+
sep = "&" if "?" in dsn else "?"
|
|
54
|
+
return f"{dsn}{sep}options={quote(opts)}"
|
|
55
|
+
return f"{dsn} options='{opts}'"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def use(conn, branch: str = "main", as_of: Optional[str] = None) -> str:
|
|
59
|
+
"""Put a psycopg connection's session on a branch (``main`` resets it)."""
|
|
60
|
+
with conn.cursor() as cur:
|
|
61
|
+
cur.execute("select gnomon.use(%s, %s)", (branch, as_of))
|
|
62
|
+
return cur.fetchone()[0]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def begin(conn, branch: str, as_of: Optional[str] = None) -> None:
|
|
66
|
+
"""Put the current transaction on a branch (SET LOCAL semantics, for poolers)."""
|
|
67
|
+
with conn.cursor() as cur:
|
|
68
|
+
cur.execute("select gnomon.begin(%s, %s)", (branch, as_of))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def create_branch(conn, name: str, parent: str = "main", *, scratch: bool = False,
|
|
72
|
+
at: Optional[str] = None) -> int:
|
|
73
|
+
with conn.cursor() as cur:
|
|
74
|
+
cur.execute("select gnomon.create_branch(%s, %s, %s, %s)", (name, parent, "scratch" if scratch else "logged", at))
|
|
75
|
+
return cur.fetchone()[0]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def create_scratch(conn, prefix: str = "test", parent: str = "main") -> str:
|
|
79
|
+
name = f"{prefix}-{os.getpid():x}-{secrets.token_hex(4)}"
|
|
80
|
+
create_branch(conn, name, parent, scratch=True)
|
|
81
|
+
return name
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def drop_branch(conn, name: str, cascade: bool = True) -> None:
|
|
85
|
+
with conn.cursor() as cur:
|
|
86
|
+
cur.execute("select gnomon.drop_branch(%s, %s)", (name, cascade))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def merge(conn, source: str, target: Optional[str] = None) -> dict:
|
|
90
|
+
with conn.cursor() as cur:
|
|
91
|
+
cur.execute("select gnomon.merge(%s, %s)", (source, target))
|
|
92
|
+
mid = cur.fetchone()[0]
|
|
93
|
+
cur.execute("select id, status, stats from gnomon.merge where id = %s", (mid,))
|
|
94
|
+
i, status, stats = cur.fetchone()
|
|
95
|
+
return {"id": i, "status": status, "stats": stats}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def diff(conn, a: str, b: Optional[str] = None) -> list[dict]:
|
|
99
|
+
with conn.cursor() as cur:
|
|
100
|
+
cur.execute("select tbl::text, key, kind, col, old, new from gnomon.diff(%s, %s)", (a, b))
|
|
101
|
+
cols = ["table", "key", "kind", "col", "old", "new"]
|
|
102
|
+
return [dict(zip(cols, r)) for r in cur.fetchall()]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@contextlib.contextmanager
|
|
106
|
+
def branch(conn, name: str, as_of: Optional[str] = None) -> Iterator[None]:
|
|
107
|
+
"""``with gnomon.branch(conn, "feature"):`` - the session reads and writes the branch inside."""
|
|
108
|
+
use(conn, name, as_of)
|
|
109
|
+
try:
|
|
110
|
+
yield
|
|
111
|
+
finally:
|
|
112
|
+
if not conn.closed:
|
|
113
|
+
if conn.info.transaction_status != 0: # not idle: leave the caller's transaction alone
|
|
114
|
+
conn.rollback()
|
|
115
|
+
use(conn, "main")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def connect(dsn: str, branch_name: Optional[str] = None, **kw):
|
|
119
|
+
"""psycopg.connect, optionally starting on a branch."""
|
|
120
|
+
import psycopg
|
|
121
|
+
|
|
122
|
+
conn = psycopg.connect(dsn, **kw)
|
|
123
|
+
if branch_name:
|
|
124
|
+
use(conn, branch_name)
|
|
125
|
+
return conn
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# The SDK: runs, review, core. Imported lazily so the helpers above need
|
|
129
|
+
# nothing but psycopg.
|
|
130
|
+
def __getattr__(name):
|
|
131
|
+
if name in ("Gnomon", "Run", "Request", "Branches"):
|
|
132
|
+
from . import client
|
|
133
|
+
return getattr(client, name)
|
|
134
|
+
if name in ("AsyncGnomon", "AsyncRun", "AsyncRequest", "AsyncBranches"):
|
|
135
|
+
from . import aio
|
|
136
|
+
return getattr(aio, name)
|
|
137
|
+
if name in ("GnomonError",):
|
|
138
|
+
from . import errors
|
|
139
|
+
return getattr(errors, name)
|
|
140
|
+
raise AttributeError(name)
|
gnomon/_util.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Lossless JSON and the statement splitter (shared by the sync and async clients)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
from decimal import Decimal
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def parse_json(text: Any) -> Any:
|
|
11
|
+
"""JSON text to Python: integers stay exact (int), decimals become Decimal (spec: Values)."""
|
|
12
|
+
if text is None:
|
|
13
|
+
return None
|
|
14
|
+
if not isinstance(text, str):
|
|
15
|
+
return text
|
|
16
|
+
return json.loads(text, parse_float=Decimal)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def stable_json(v: Any) -> str:
|
|
20
|
+
"""JSON text with object keys sorted, compact, as the TypeScript SDK writes it (snapshots, digests)."""
|
|
21
|
+
def default(x):
|
|
22
|
+
return str(x)
|
|
23
|
+
return json.dumps(_num(v), sort_keys=True, separators=(",", ":"), default=default, ensure_ascii=False)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _num(v):
|
|
27
|
+
if isinstance(v, Decimal):
|
|
28
|
+
return int(v) if v == v.to_integral_value() else float(v) if str(float(v)) == str(v) else str(v)
|
|
29
|
+
if isinstance(v, dict):
|
|
30
|
+
return {k: _num(x) for k, x in v.items()}
|
|
31
|
+
if isinstance(v, list):
|
|
32
|
+
return [_num(x) for x in v]
|
|
33
|
+
return v
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
_DOLLAR = re.compile(r"\$([A-Za-z_][A-Za-z_0-9]*)?\$")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def split_sql(sql: str) -> list[str]:
|
|
40
|
+
"""Top-level statements of a script (the port of tools/sqlsplit.py)."""
|
|
41
|
+
out, buf, i, n = [], [], 0, len(sql)
|
|
42
|
+
while i < n:
|
|
43
|
+
c = sql[i]
|
|
44
|
+
if c == "-" and sql.startswith("--", i):
|
|
45
|
+
j = sql.find("\n", i)
|
|
46
|
+
j = n if j < 0 else j
|
|
47
|
+
buf.append(sql[i:j]); i = j; continue
|
|
48
|
+
if c == "/" and sql.startswith("/*", i):
|
|
49
|
+
j = sql.find("*/", i + 2)
|
|
50
|
+
j = n if j < 0 else j + 2
|
|
51
|
+
buf.append(sql[i:j]); i = j; continue
|
|
52
|
+
if c == "'":
|
|
53
|
+
j = i + 1
|
|
54
|
+
while j < n:
|
|
55
|
+
if sql[j] == "'" and j + 1 < n and sql[j + 1] == "'":
|
|
56
|
+
j += 2; continue
|
|
57
|
+
if sql[j] == "'":
|
|
58
|
+
break
|
|
59
|
+
j += 1
|
|
60
|
+
buf.append(sql[i:j + 1]); i = j + 1; continue
|
|
61
|
+
if c == '"':
|
|
62
|
+
j = sql.find('"', i + 1)
|
|
63
|
+
j = n - 1 if j < 0 else j
|
|
64
|
+
buf.append(sql[i:j + 1]); i = j + 1; continue
|
|
65
|
+
if c == "$":
|
|
66
|
+
m = _DOLLAR.match(sql, i)
|
|
67
|
+
if m:
|
|
68
|
+
tag = m.group(0)
|
|
69
|
+
j = sql.find(tag, i + len(tag))
|
|
70
|
+
j = n if j < 0 else j + len(tag)
|
|
71
|
+
buf.append(sql[i:j]); i = j; continue
|
|
72
|
+
if c == ";":
|
|
73
|
+
buf.append(";"); out.append("".join(buf)); buf = []; i += 1; continue
|
|
74
|
+
buf.append(c); i += 1
|
|
75
|
+
rest = "".join(buf)
|
|
76
|
+
if rest.strip():
|
|
77
|
+
out.append(rest)
|
|
78
|
+
def empty(s):
|
|
79
|
+
s = re.sub(r"--[^\n]*", "", s)
|
|
80
|
+
s = re.sub(r"/\*.*?\*/", "", s, flags=re.S)
|
|
81
|
+
return s.replace(";", "").strip() == ""
|
|
82
|
+
return [s for s in out if not empty(s)]
|
gnomon/aio.py
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
"""The gnomon SDK, async (the specification is sdk/spec/SPEC.md).
|
|
2
|
+
|
|
3
|
+
``client.py`` is generated from this file by ``tools/unasync.py``: change this one.
|
|
4
|
+
|
|
5
|
+
gn = AsyncGnomon(app="postgresql://app@db/app", runs="postgresql://runs@db/app")
|
|
6
|
+
async with await gn.start_run("Refund order 3", actor="support-bot") as run:
|
|
7
|
+
await run.sql("update orders set status = 'refunded' where id = 3")
|
|
8
|
+
summary = await run.summary()
|
|
9
|
+
if summary["checks"]["passed"] and summary["changes"]["rows"] <= 20:
|
|
10
|
+
await run.merge()
|
|
11
|
+
else:
|
|
12
|
+
await run.submit("Refund order 3")
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
import contextlib
|
|
18
|
+
import json
|
|
19
|
+
from typing import Any, AsyncIterator, Callable, Optional
|
|
20
|
+
|
|
21
|
+
import psycopg
|
|
22
|
+
import psycopg.conninfo
|
|
23
|
+
from psycopg.rows import dict_row
|
|
24
|
+
|
|
25
|
+
from ._util import parse_json, split_sql
|
|
26
|
+
from .errors import (GnomonError, IncompatibleVersionError, MergeConflictError, SerializationError,
|
|
27
|
+
to_gnomon_error)
|
|
28
|
+
|
|
29
|
+
SUPPORTED_API = (2, 2)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class AsyncAdapter:
|
|
33
|
+
"""A login: a connection string (a connection per call), a psycopg connection (used one call
|
|
34
|
+
at a time), or a pool with a ``connection()`` context manager (psycopg_pool)."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, source: Any):
|
|
37
|
+
self.source = source
|
|
38
|
+
self.lock = asyncio.Lock()
|
|
39
|
+
|
|
40
|
+
@contextlib.asynccontextmanager
|
|
41
|
+
async def connection(self) -> AsyncIterator[psycopg.AsyncConnection]:
|
|
42
|
+
s = self.source
|
|
43
|
+
if isinstance(s, str):
|
|
44
|
+
conn = await psycopg.AsyncConnection.connect(s, autocommit=True)
|
|
45
|
+
try:
|
|
46
|
+
yield conn
|
|
47
|
+
finally:
|
|
48
|
+
await conn.close()
|
|
49
|
+
elif hasattr(s, "connection") and not isinstance(s, psycopg.AsyncConnection):
|
|
50
|
+
async with s.connection() as conn:
|
|
51
|
+
yield conn
|
|
52
|
+
else:
|
|
53
|
+
async with self.lock:
|
|
54
|
+
yield s
|
|
55
|
+
|
|
56
|
+
async def query(self, sql: str, params: tuple = ()) -> list[dict]:
|
|
57
|
+
async with self.connection() as conn:
|
|
58
|
+
async with conn.cursor(row_factory=dict_row) as cur:
|
|
59
|
+
await cur.execute(sql, params)
|
|
60
|
+
return await cur.fetchall() if cur.description else []
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class AsyncGnomon:
|
|
64
|
+
"""The application's side: starts runs, reads them, accepts them; review; core."""
|
|
65
|
+
|
|
66
|
+
def __init__(self, app: Any, runs: Any = None, *, statement_timeout_ms: int = 30000, lock_timeout_ms: int = 5000,
|
|
67
|
+
max_rows: int = 1000, on_statement: Optional[Callable[[dict], None]] = None, skip_version_check: bool = False,
|
|
68
|
+
url: Optional[str] = None):
|
|
69
|
+
# url: the database's connection string (host, port, database), for run.url() and
|
|
70
|
+
# run.connection(); default: the app login's, when it is a string
|
|
71
|
+
self.url = url or (app if isinstance(app, str) else None)
|
|
72
|
+
self.app = AsyncAdapter(app)
|
|
73
|
+
self.runs = AsyncAdapter(runs) if runs is not None else None
|
|
74
|
+
self.statement_timeout_ms, self.lock_timeout_ms, self.max_rows = statement_timeout_ms, lock_timeout_ms, max_rows
|
|
75
|
+
self.on_statement = on_statement
|
|
76
|
+
self._checked = skip_version_check
|
|
77
|
+
self.requests = AsyncRequests(self)
|
|
78
|
+
self.branches = AsyncBranches(self)
|
|
79
|
+
|
|
80
|
+
async def ready(self) -> None:
|
|
81
|
+
"""Check once that the extension speaks this SDK's API."""
|
|
82
|
+
if self._checked:
|
|
83
|
+
return
|
|
84
|
+
try:
|
|
85
|
+
rows = await self.app.query("select gnomon.api_version() as v")
|
|
86
|
+
except psycopg.Error as e:
|
|
87
|
+
raise IncompatibleVersionError("this server's gnomon has no gnomon.api_version(): it predates this SDK (needs API 2)", cause=e)
|
|
88
|
+
v = rows[0]["v"]
|
|
89
|
+
if not SUPPORTED_API[0] <= v <= SUPPORTED_API[1]:
|
|
90
|
+
raise IncompatibleVersionError(f"gnomon API {v} on the server; this SDK speaks {SUPPORTED_API[0]}..{SUPPORTED_API[1]}")
|
|
91
|
+
self._checked = True
|
|
92
|
+
|
|
93
|
+
def _event(self, login: str, run: Optional[str], sql: str, ok: bool, error: Optional[GnomonError] = None) -> None:
|
|
94
|
+
if self.on_statement:
|
|
95
|
+
self.on_statement({"login": login, "run": run, "statement": sql, "ok": ok, "error": error})
|
|
96
|
+
|
|
97
|
+
async def query(self, sql: str, params: tuple = ()) -> list[dict]:
|
|
98
|
+
"""A statement on the app login, errors typed."""
|
|
99
|
+
await self.ready()
|
|
100
|
+
try:
|
|
101
|
+
rows = await self.app.query(sql, params)
|
|
102
|
+
except psycopg.Error as e:
|
|
103
|
+
ge = to_gnomon_error(e)
|
|
104
|
+
self._event("app", None, sql, False, ge)
|
|
105
|
+
raise ge from e
|
|
106
|
+
self._event("app", None, sql, True)
|
|
107
|
+
return rows
|
|
108
|
+
|
|
109
|
+
async def value(self, sql: str, params: tuple = ()) -> Any:
|
|
110
|
+
rows = await self.query(sql, params)
|
|
111
|
+
return next(iter(rows[0].values())) if rows else None
|
|
112
|
+
|
|
113
|
+
async def json(self, sql: str, params: tuple = ()) -> Any:
|
|
114
|
+
return parse_json(await self.value(f"select ({sql})::text", params))
|
|
115
|
+
|
|
116
|
+
async def start_run(self, task: Optional[str] = None, *, actor: Optional[str] = None, kind: str = "logged",
|
|
117
|
+
ttl: Optional[str] = None, parent: str = "main") -> "AsyncRun":
|
|
118
|
+
if self.runs is None:
|
|
119
|
+
raise GnomonError("start_run needs the runs login: AsyncGnomon(app=..., runs=...)")
|
|
120
|
+
branch = await self.value("select gnomon.run_start(%s, %s, %s, %s::interval, %s)", (task, actor, kind, ttl, parent))
|
|
121
|
+
return AsyncRun(self, branch, task=task, actor=actor)
|
|
122
|
+
|
|
123
|
+
def run(self, branch: str, *, task: Optional[str] = None, actor: Optional[str] = None) -> "AsyncRun":
|
|
124
|
+
return AsyncRun(self, branch, task=task, actor=actor)
|
|
125
|
+
|
|
126
|
+
# ---- core
|
|
127
|
+
async def status(self) -> dict:
|
|
128
|
+
return await self.json("select gnomon.status()")
|
|
129
|
+
|
|
130
|
+
async def doctor(self) -> list[dict]:
|
|
131
|
+
return await self.query("select check_name, status, detail from gnomon.doctor()")
|
|
132
|
+
|
|
133
|
+
async def describe(self, schema: Optional[str] = None) -> dict:
|
|
134
|
+
return await self.json("select gnomon.describe(%s)", (schema,))
|
|
135
|
+
|
|
136
|
+
async def diff(self, a: str, b: Optional[str] = None) -> list[dict]:
|
|
137
|
+
rows = await self.query("select tbl::text as table, key::text as key, kind, col as column, old::text as old, new::text as new from gnomon.diff(%s, %s)", (a, b))
|
|
138
|
+
return [{**r, "key": parse_json(r["key"]), "old": parse_json(r["old"]), "new": parse_json(r["new"])} for r in rows]
|
|
139
|
+
|
|
140
|
+
async def history(self, table: str, key: Any, *, branch: str = "main") -> list[dict]:
|
|
141
|
+
rows = await self.query("select vkey, xid::text as xid, role, op, at, actor, reason, row_image::text as row_image, on_branch from gnomon.history(%s, %s::jsonb, %s)",
|
|
142
|
+
(table, json.dumps(key), branch))
|
|
143
|
+
return [{**r, "row_image": parse_json(r["row_image"])} for r in rows]
|
|
144
|
+
|
|
145
|
+
async def blame(self, table: str, key: Any, *, branch: str = "main") -> list[dict]:
|
|
146
|
+
rows = await self.query("select col, value::text as value, xid::text as xid, role, actor, reason, at, on_branch from gnomon.blame(%s, %s::jsonb, %s)",
|
|
147
|
+
(table, json.dumps(key), branch))
|
|
148
|
+
return [{**r, "value": parse_json(r["value"])} for r in rows]
|
|
149
|
+
|
|
150
|
+
async def revert(self, table: str, at: str, *, keys: Optional[list] = None, filter: Optional[str] = None,
|
|
151
|
+
columns: Optional[list] = None, branch: str = "main", dry_run: bool = False) -> dict:
|
|
152
|
+
return await self.json("select gnomon.revert(%s, %s, %s::jsonb, %s, %s::text[], %s, %s)",
|
|
153
|
+
(table, at, json.dumps(keys) if keys is not None else None, filter, columns, branch, dry_run))
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class AsyncRun:
|
|
157
|
+
"""One agent run: a branch, its envelope, and what the application does with it."""
|
|
158
|
+
|
|
159
|
+
def __init__(self, gnomon: AsyncGnomon, branch: str, *, task: Optional[str] = None, actor: Optional[str] = None):
|
|
160
|
+
self.gnomon, self.branch, self.task, self.actor = gnomon, branch, task, actor
|
|
161
|
+
self.state = "open"
|
|
162
|
+
self._seq = 0
|
|
163
|
+
|
|
164
|
+
async def __aenter__(self) -> "AsyncRun":
|
|
165
|
+
return self
|
|
166
|
+
|
|
167
|
+
async def __aexit__(self, *exc) -> None:
|
|
168
|
+
if self.state == "open":
|
|
169
|
+
await self.discard()
|
|
170
|
+
|
|
171
|
+
def _check_open(self) -> None:
|
|
172
|
+
if self.state != "open":
|
|
173
|
+
raise GnomonError(f"run {self.branch} is {self.state}", code="branch_closed")
|
|
174
|
+
|
|
175
|
+
async def _envelope(self, cur, reason: Optional[str], timeout_ms: Optional[int]) -> None:
|
|
176
|
+
g = self.gnomon
|
|
177
|
+
self._seq += 1
|
|
178
|
+
await cur.execute("select gnomon.begin(%s)", (self.branch,))
|
|
179
|
+
await cur.execute("select gnomon.note(%s, %s, %s::jsonb)", (self.actor or "agent", reason,
|
|
180
|
+
json.dumps({"run": self.branch, "call": self._seq, "task": self.task})))
|
|
181
|
+
await cur.execute("select set_config('statement_timeout', %s, true), set_config('lock_timeout', %s, true)",
|
|
182
|
+
(str(timeout_ms or g.statement_timeout_ms), str(g.lock_timeout_ms)))
|
|
183
|
+
|
|
184
|
+
async def sql(self, statement: str, params: tuple = (), *, reason: Optional[str] = None, timeout_ms: Optional[int] = None,
|
|
185
|
+
max_rows: Optional[int] = None) -> dict:
|
|
186
|
+
"""One statement on the run (the envelope). Rows past the cap: ``truncated``. Retried on a
|
|
187
|
+
serialization failure (the envelope held nothing else)."""
|
|
188
|
+
self._check_open()
|
|
189
|
+
g = self.gnomon
|
|
190
|
+
await g.ready()
|
|
191
|
+
if len(split_sql(statement)) > 1:
|
|
192
|
+
raise GnomonError("one statement per call: this one holds several (split it, or use transaction())", code="one_statement", sqlstate="42601")
|
|
193
|
+
cap = max_rows or g.max_rows
|
|
194
|
+
for attempt in range(4):
|
|
195
|
+
try:
|
|
196
|
+
async with g.runs.connection() as conn:
|
|
197
|
+
async with conn.transaction():
|
|
198
|
+
async with conn.cursor(row_factory=dict_row) as cur:
|
|
199
|
+
await self._envelope(cur, reason, timeout_ms)
|
|
200
|
+
# (the extended protocol: psycopg sends parameters, so PostgreSQL refuses a second statement)
|
|
201
|
+
await cur.execute(statement, params or None, prepare=False)
|
|
202
|
+
rows = await cur.fetchall() if cur.description else []
|
|
203
|
+
rowcount = cur.rowcount
|
|
204
|
+
g._event("runs", self.branch, statement, True)
|
|
205
|
+
return {"rows": rows[:cap], "row_count": rowcount, "truncated": len(rows) > cap}
|
|
206
|
+
except psycopg.Error as e:
|
|
207
|
+
ge = to_gnomon_error(e)
|
|
208
|
+
g._event("runs", self.branch, statement, False, ge)
|
|
209
|
+
if isinstance(ge, SerializationError) and attempt < 3:
|
|
210
|
+
await asyncio.sleep(0.02 * 2 ** attempt)
|
|
211
|
+
continue
|
|
212
|
+
raise ge from e
|
|
213
|
+
|
|
214
|
+
@contextlib.asynccontextmanager
|
|
215
|
+
async def transaction(self, *, reason: Optional[str] = None, timeout_ms: Optional[int] = None) -> AsyncIterator[Any]:
|
|
216
|
+
"""Several statements in one envelope: ``async with run.transaction() as tx: await tx.execute(...)``."""
|
|
217
|
+
self._check_open()
|
|
218
|
+
g = self.gnomon
|
|
219
|
+
await g.ready()
|
|
220
|
+
try:
|
|
221
|
+
async with g.runs.connection() as conn:
|
|
222
|
+
async with conn.transaction():
|
|
223
|
+
async with conn.cursor(row_factory=dict_row) as cur:
|
|
224
|
+
await self._envelope(cur, reason, timeout_ms)
|
|
225
|
+
yield cur
|
|
226
|
+
except psycopg.Error as e:
|
|
227
|
+
raise to_gnomon_error(e) from e
|
|
228
|
+
|
|
229
|
+
async def diff(self) -> list[dict]:
|
|
230
|
+
return await self.gnomon.diff(self.branch)
|
|
231
|
+
|
|
232
|
+
async def summary(self, *, run_checks: bool = True) -> dict:
|
|
233
|
+
return await self.gnomon.json("select gnomon.run_summary(%s, %s)", (self.branch, run_checks))
|
|
234
|
+
|
|
235
|
+
async def checks(self) -> list[dict]:
|
|
236
|
+
return await self.gnomon.query("select check_name, passed, detail from gnomon.run_checks(%s)", (self.branch,))
|
|
237
|
+
|
|
238
|
+
async def merge(self, *, close: bool = True) -> dict:
|
|
239
|
+
g = self.gnomon
|
|
240
|
+
mid = await g.value("select gnomon.merge(%s, null, 'atomic', null, null, null, %s)", (self.branch, close))
|
|
241
|
+
m = await g.json("select to_jsonb(m) from gnomon.merge m where id = %s", (mid,))
|
|
242
|
+
if m["status"] == "conflicts":
|
|
243
|
+
c = await g.json("select coalesce(jsonb_agg(to_jsonb(c)), '[]') from gnomon.conflict c where merge_id = %s", (mid,))
|
|
244
|
+
raise MergeConflictError(f"merging run {self.branch} stopped on {len(c)} conflict(s)", mid, c)
|
|
245
|
+
self.state = "merged"
|
|
246
|
+
return {"merge": mid, "status": m["status"], "stats": m["stats"]}
|
|
247
|
+
|
|
248
|
+
async def submit(self, title: Optional[str] = None, body: Optional[str] = None) -> "AsyncRequest":
|
|
249
|
+
cr = await self.gnomon.value("select gnomon.open_change_request(%s, %s, %s)",
|
|
250
|
+
(self.branch, title or self.task or f"Run {self.branch}", body))
|
|
251
|
+
self.state = "submitted"
|
|
252
|
+
return AsyncRequest(self.gnomon, cr)
|
|
253
|
+
|
|
254
|
+
async def discard(self) -> None:
|
|
255
|
+
if self.state == "discarded":
|
|
256
|
+
return
|
|
257
|
+
await self.gnomon.query("select gnomon.run_end(%s, false)", (self.branch,))
|
|
258
|
+
self.state = "discarded"
|
|
259
|
+
|
|
260
|
+
async def url(self, base: Optional[str] = None) -> str:
|
|
261
|
+
"""A connection string pinned to the run, for a tool that wants one: a login of the run's own
|
|
262
|
+
(gnomon.run_login), confined to the run's branch, dropped when the run ends."""
|
|
263
|
+
self._check_open()
|
|
264
|
+
b = base or self.gnomon.url
|
|
265
|
+
if not b:
|
|
266
|
+
raise GnomonError("run.url() needs a base connection string (the url option, or an argument)", code="run_url_base")
|
|
267
|
+
login = await self.gnomon.json("select gnomon.run_login(%s)", (self.branch,))
|
|
268
|
+
return psycopg.conninfo.make_conninfo(b, user=login["role"], password=login["password"])
|
|
269
|
+
|
|
270
|
+
async def connection(self, base: Optional[str] = None) -> psycopg.AsyncConnection:
|
|
271
|
+
"""A connection pinned to the run (close it when done)."""
|
|
272
|
+
return await psycopg.AsyncConnection.connect(await self.url(base))
|
|
273
|
+
|
|
274
|
+
async def checkpoint(self, name: str) -> str:
|
|
275
|
+
return await self.gnomon.value("select gnomon.checkpoint(%s, %s)", (f"{self.branch}:{name}", self.branch))
|
|
276
|
+
|
|
277
|
+
async def rewind(self, name: str) -> dict:
|
|
278
|
+
return await self.gnomon.json("select gnomon.rewind(%s, %s)", (self.branch, f"{self.branch}:{name}"))
|
|
279
|
+
|
|
280
|
+
async def dry_run(self, statements: list[str], *, checks: bool = True) -> dict:
|
|
281
|
+
return await self.gnomon.json("select gnomon.dry_run(%s::text[], %s, %s)", (statements, self.branch, checks))
|
|
282
|
+
|
|
283
|
+
async def describe(self, schema: Optional[str] = None) -> dict:
|
|
284
|
+
async with self.gnomon.runs.connection() as conn:
|
|
285
|
+
async with conn.transaction():
|
|
286
|
+
async with conn.cursor() as cur:
|
|
287
|
+
await cur.execute("select gnomon.begin(%s)", (self.branch,))
|
|
288
|
+
await cur.execute("select gnomon.describe(%s)::text", (schema,))
|
|
289
|
+
return parse_json((await cur.fetchone())[0])
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
class AsyncRequests:
|
|
293
|
+
def __init__(self, g: AsyncGnomon):
|
|
294
|
+
self.gnomon = g
|
|
295
|
+
|
|
296
|
+
async def open(self) -> list["AsyncRequest"]:
|
|
297
|
+
rows = await self.gnomon.query("select id from gnomon.change_request where status in ('open', 'approved') order by id")
|
|
298
|
+
return [AsyncRequest(self.gnomon, r["id"]) for r in rows]
|
|
299
|
+
|
|
300
|
+
def get(self, id: int) -> "AsyncRequest":
|
|
301
|
+
return AsyncRequest(self.gnomon, id)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
class AsyncRequest:
|
|
305
|
+
"""A change request: what a product's own Approve, Reject and Undo buttons call."""
|
|
306
|
+
|
|
307
|
+
def __init__(self, g: AsyncGnomon, id: int):
|
|
308
|
+
self.gnomon, self.id = g, id
|
|
309
|
+
|
|
310
|
+
async def status(self) -> dict:
|
|
311
|
+
return await self.gnomon.json("select gnomon.change_request_status(%s)", (self.id,))
|
|
312
|
+
|
|
313
|
+
async def diff(self) -> list[dict]:
|
|
314
|
+
return await self.gnomon.diff((await self.status())["branch"])
|
|
315
|
+
|
|
316
|
+
async def approve(self, body: Optional[str] = None) -> None:
|
|
317
|
+
await self.gnomon.query("select gnomon.review(%s, 'approve', %s)", (self.id, body))
|
|
318
|
+
|
|
319
|
+
async def reject(self, note: Optional[str] = None) -> None:
|
|
320
|
+
await self.gnomon.query("select gnomon.review(%s, 'reject', %s)", (self.id, note))
|
|
321
|
+
|
|
322
|
+
async def comment(self, body: str, *, table: Optional[str] = None, key: Any = None, column: Optional[str] = None) -> int:
|
|
323
|
+
return await self.gnomon.value("select gnomon.comment(%s, %s, %s::regclass, %s::jsonb, %s)",
|
|
324
|
+
(self.id, body, table, json.dumps(key) if key is not None else None, column))
|
|
325
|
+
|
|
326
|
+
async def merge(self, *, close: bool = True) -> int:
|
|
327
|
+
return await self.gnomon.value("select gnomon.merge(%s, null, 'atomic', null, null, null, %s)", ((await self.status())["branch"], close))
|
|
328
|
+
|
|
329
|
+
async def undo(self) -> dict:
|
|
330
|
+
s = await self.status()
|
|
331
|
+
if not s.get("merge"):
|
|
332
|
+
raise GnomonError(f"change request {self.id} is not merged")
|
|
333
|
+
return await self.gnomon.json("select gnomon.undo_merge(%s)", (s["merge"]["id"],))
|
|
334
|
+
|
|
335
|
+
async def wait(self, timeout_s: float = 300) -> dict:
|
|
336
|
+
return await self.gnomon.json("select gnomon.wait_for_review(%s, %s::interval)", (self.id, f"{timeout_s} seconds"))
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
class AsyncBranches:
|
|
340
|
+
def __init__(self, g: AsyncGnomon):
|
|
341
|
+
self.gnomon = g
|
|
342
|
+
|
|
343
|
+
async def create(self, name: str, *, parent: str = "main", kind: str = "logged", at: Optional[str] = None) -> str:
|
|
344
|
+
await self.gnomon.query("select gnomon.create_branch(%s, %s, %s, %s)", (name, parent, kind, at))
|
|
345
|
+
return name
|
|
346
|
+
|
|
347
|
+
async def scratch(self, prefix: str = "test", parent: str = "main") -> str:
|
|
348
|
+
return await self.gnomon.value("select gnomon.create_scratch(%s, %s)", (prefix, parent))
|
|
349
|
+
|
|
350
|
+
async def drop(self, name: str) -> None:
|
|
351
|
+
await self.gnomon.query("select gnomon.drop_branch(%s, true)", (name,))
|
|
352
|
+
|
|
353
|
+
async def rebase(self, name: str, *, dry_run: bool = False) -> dict:
|
|
354
|
+
return await self.gnomon.json("select gnomon.rebase(%s, null, %s)", (name, dry_run))
|
|
355
|
+
|
|
356
|
+
async def list(self) -> list[dict]:
|
|
357
|
+
return await self.gnomon.query("select * from gnomon.branches order by created_at")
|
|
358
|
+
|
|
359
|
+
async def options(self, name: str, *, c_module: bool = False) -> str:
|
|
360
|
+
return await self.gnomon.value("select gnomon.connection_options(%s, %s)", (name, c_module))
|