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 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))