gnomon-postgres 1.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,17 @@
1
+ PostgreSQL License
2
+
3
+ Copyright (c) 2026, The Gnomon Contributors
4
+
5
+ Permission to use, copy, modify, and distribute this software and its documentation for any
6
+ purpose, without fee, and without a written agreement is hereby granted, provided that the
7
+ above copyright notice and this paragraph and the following two paragraphs appear in all copies.
8
+
9
+ IN NO EVENT SHALL THE GNOMON CONTRIBUTORS BE LIABLE TO ANY PARTY FOR DIRECT, INDIRECT, SPECIAL,
10
+ INCIDENTAL, OR CONSEQUENTIAL DAMAGES, INCLUDING LOST PROFITS, ARISING OUT OF THE USE OF THIS
11
+ SOFTWARE AND ITS DOCUMENTATION, EVEN IF THE GNOMON CONTRIBUTORS HAVE BEEN ADVISED OF THE
12
+ POSSIBILITY OF SUCH DAMAGE.
13
+
14
+ THE GNOMON CONTRIBUTORS SPECIFICALLY DISCLAIM ANY WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
15
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED
16
+ HEREUNDER IS ON AN "AS IS" BASIS, AND THE GNOMON CONTRIBUTORS HAVE NO OBLIGATIONS TO PROVIDE
17
+ MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
@@ -0,0 +1,79 @@
1
+ Metadata-Version: 2.4
2
+ Name: gnomon-postgres
3
+ Version: 1.0.0
4
+ Summary: Branches, time travel and merges for PostgreSQL apps: psycopg helpers, SQLAlchemy, Django and pytest fixtures for the gnomon extension
5
+ License-Expression: PostgreSQL
6
+ Project-URL: Homepage, https://gnomon.bitsinfra.com/
7
+ Project-URL: Repository, https://github.com/bitsinfra/gnomon
8
+ Project-URL: Issues, https://github.com/bitsinfra/gnomon/issues
9
+ Requires-Python: >=3.9
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Requires-Dist: psycopg>=3.1
13
+ Provides-Extra: sqlalchemy
14
+ Requires-Dist: sqlalchemy>=2; extra == "sqlalchemy"
15
+ Provides-Extra: django
16
+ Requires-Dist: django>=4.2; extra == "django"
17
+ Provides-Extra: asyncpg
18
+ Requires-Dist: asyncpg>=0.29; extra == "asyncpg"
19
+ Provides-Extra: test
20
+ Requires-Dist: pytest>=7; extra == "test"
21
+ Dynamic: license-file
22
+
23
+ # gnomon-postgres (Python)
24
+
25
+ Branches, time travel and merges for PostgreSQL apps on the gnomon extension: the SDK (runs for
26
+ agents and tasks, review, the core), psycopg helpers, SQLAlchemy and Django, and pytest fixtures
27
+ with snapshots. `import gnomon`.
28
+
29
+ ```python
30
+ from gnomon import Gnomon
31
+
32
+ gn = Gnomon(app="dbname=shop user=shop_app", runs="dbname=shop user=shop_runs")
33
+ with gn.start_run("Refund order 3", actor="support-bot") as run: # a branch for this task
34
+ run.sql("update orders set status = 'refunded' where id = 3")
35
+ if run.summary()["checks"]["passed"]: run.merge()
36
+ else: run.submit("Refund order 3") # a change request instead
37
+ ```
38
+
39
+ Leaving the `with` block without `merge()` or `submit()` discards the branch. `run.url(base)` and
40
+ `run.connection(base)` give a tool a connection string, or a psycopg connection, pinned to the run
41
+ (a login of the run's own, confined to its branch, dropped when the run ends). With asyncpg:
42
+ `AsyncGnomon(app=asyncpg_source(pool), runs=asyncpg_source(runs_pool))` (`from gnomon.asyncpg import
43
+ asyncpg_source`; the `asyncpg` extra). `AsyncGnomon` is the
44
+ same API for `psycopg.AsyncConnection` (the sync client is generated from it, so they cannot
45
+ drift). `gn.requests`, `gn.branches`, `history`, `blame`, `revert`, `status()` and `doctor()` are
46
+ the review and core layers. Tools for agent frameworks bound to one run:
47
+ `gnomon.tools.anthropic_tools(run)`, `openai_agents_tools(run)`, `langchain_tools(run)`,
48
+ `mcp_handler(run)`. The two logins, the envelope and the errors are as in the TypeScript SDK
49
+ ([sdk/ts/README.md](../../sdk/ts/README.md)); both pass the same conformance scenarios
50
+ ([sdk/spec](../../sdk/spec)).
51
+
52
+ ## Helpers
53
+
54
+ ```python
55
+ import gnomon, psycopg
56
+ conn = psycopg.connect(gnomon.branch_dsn("dbname=shop", "feature")) # sessions start on a branch
57
+ with gnomon.branch(conn, "feature"): ... # or switch one connection
58
+ opts = gnomon.connection_options(admin_conn, "feature") # what the database says the
59
+ # branch needs (schema versions, masks)
60
+ ```
61
+
62
+ `use`, `begin` (one transaction, for poolers), `create_branch`, `create_scratch`, `drop_branch`,
63
+ `diff`, `merge`. SQLAlchemy: `gnomon.sqlalchemy.branch_engine(url, branch)` and
64
+ `attach_branch(engine, get_branch)` (each transaction on the branch a callable names). Django:
65
+ `gnomon.django.BranchMiddleware` (a branch per request from `X-Gnomon-Branch`).
66
+
67
+ ## Tests on branches
68
+
69
+ ```python
70
+ def test_signup(gnomon_conn, gnomon_snapshot): # a psycopg connection on a fresh scratch branch
71
+ signup(gnomon_conn, "a@x.io")
72
+ gnomon_snapshot.assert_match(gnomon_conn) # the branch's diff against a stored file
73
+ ```
74
+
75
+ `GNOMON_TEST_DSN=... pytest` (the plugin loads itself when the package is installed;
76
+ `--gnomon-update-snapshots` records, `--gnomon-keep` keeps the branches).
77
+ `gnomon.testing.assert_no_changes` and `assert_changed` check a branch's diff directly.
78
+
79
+ Install: `pip install gnomon-postgres` (extras: `sqlalchemy`, `django`, `test`).
@@ -0,0 +1,57 @@
1
+ # gnomon-postgres (Python)
2
+
3
+ Branches, time travel and merges for PostgreSQL apps on the gnomon extension: the SDK (runs for
4
+ agents and tasks, review, the core), psycopg helpers, SQLAlchemy and Django, and pytest fixtures
5
+ with snapshots. `import gnomon`.
6
+
7
+ ```python
8
+ from gnomon import Gnomon
9
+
10
+ gn = Gnomon(app="dbname=shop user=shop_app", runs="dbname=shop user=shop_runs")
11
+ with gn.start_run("Refund order 3", actor="support-bot") as run: # a branch for this task
12
+ run.sql("update orders set status = 'refunded' where id = 3")
13
+ if run.summary()["checks"]["passed"]: run.merge()
14
+ else: run.submit("Refund order 3") # a change request instead
15
+ ```
16
+
17
+ Leaving the `with` block without `merge()` or `submit()` discards the branch. `run.url(base)` and
18
+ `run.connection(base)` give a tool a connection string, or a psycopg connection, pinned to the run
19
+ (a login of the run's own, confined to its branch, dropped when the run ends). With asyncpg:
20
+ `AsyncGnomon(app=asyncpg_source(pool), runs=asyncpg_source(runs_pool))` (`from gnomon.asyncpg import
21
+ asyncpg_source`; the `asyncpg` extra). `AsyncGnomon` is the
22
+ same API for `psycopg.AsyncConnection` (the sync client is generated from it, so they cannot
23
+ drift). `gn.requests`, `gn.branches`, `history`, `blame`, `revert`, `status()` and `doctor()` are
24
+ the review and core layers. Tools for agent frameworks bound to one run:
25
+ `gnomon.tools.anthropic_tools(run)`, `openai_agents_tools(run)`, `langchain_tools(run)`,
26
+ `mcp_handler(run)`. The two logins, the envelope and the errors are as in the TypeScript SDK
27
+ ([sdk/ts/README.md](../../sdk/ts/README.md)); both pass the same conformance scenarios
28
+ ([sdk/spec](../../sdk/spec)).
29
+
30
+ ## Helpers
31
+
32
+ ```python
33
+ import gnomon, psycopg
34
+ conn = psycopg.connect(gnomon.branch_dsn("dbname=shop", "feature")) # sessions start on a branch
35
+ with gnomon.branch(conn, "feature"): ... # or switch one connection
36
+ opts = gnomon.connection_options(admin_conn, "feature") # what the database says the
37
+ # branch needs (schema versions, masks)
38
+ ```
39
+
40
+ `use`, `begin` (one transaction, for poolers), `create_branch`, `create_scratch`, `drop_branch`,
41
+ `diff`, `merge`. SQLAlchemy: `gnomon.sqlalchemy.branch_engine(url, branch)` and
42
+ `attach_branch(engine, get_branch)` (each transaction on the branch a callable names). Django:
43
+ `gnomon.django.BranchMiddleware` (a branch per request from `X-Gnomon-Branch`).
44
+
45
+ ## Tests on branches
46
+
47
+ ```python
48
+ def test_signup(gnomon_conn, gnomon_snapshot): # a psycopg connection on a fresh scratch branch
49
+ signup(gnomon_conn, "a@x.io")
50
+ gnomon_snapshot.assert_match(gnomon_conn) # the branch's diff against a stored file
51
+ ```
52
+
53
+ `GNOMON_TEST_DSN=... pytest` (the plugin loads itself when the package is installed;
54
+ `--gnomon-update-snapshots` records, `--gnomon-keep` keeps the branches).
55
+ `gnomon.testing.assert_no_changes` and `assert_changed` check a branch's diff directly.
56
+
57
+ Install: `pip install gnomon-postgres` (extras: `sqlalchemy`, `django`, `test`).
@@ -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)
@@ -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)]