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.
@@ -0,0 +1,62 @@
1
+ """pytest fixtures: a scratch branch of one seeded database per test.
2
+
3
+ def test_signup(gnomon_conn): # a psycopg connection on a fresh branch
4
+ gnomon_conn.execute("insert into users ...")
5
+
6
+ Tests run in parallel against one database (pytest-xdist works): each gets its own branch, so
7
+ nothing a test writes is seen by another, and nothing reaches main. Configure the database with
8
+ ``--gnomon-dsn`` or ``GNOMON_TEST_DSN``.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import os
13
+
14
+ import pytest
15
+
16
+ from . import create_scratch, drop_branch, use
17
+
18
+
19
+ def pytest_addoption(parser):
20
+ parser.addoption("--gnomon-dsn", default=os.environ.get("GNOMON_TEST_DSN"), help="database with gnomon (seeded)")
21
+ parser.addoption("--gnomon-keep", action="store_true", help="keep test branches for inspection")
22
+ parser.addoption("--gnomon-update-snapshots", action="store_true", help="record branch snapshots instead of comparing")
23
+ parser.addoption("--gnomon-snapshot-dir", default=None, help="where snapshots live (default: __gnomon_snapshots__ beside the test)")
24
+
25
+
26
+ @pytest.fixture
27
+ def gnomon_snapshot(request):
28
+ """Compare the test branch's diff with a stored snapshot: ``gnomon_snapshot.assert_match(gnomon_conn)``."""
29
+ from .testing import Snapshot, snapshot_dir
30
+ return Snapshot(snapshot_dir(request), request.config.getoption("--gnomon-update-snapshots"))
31
+
32
+
33
+ @pytest.fixture(scope="session")
34
+ def gnomon_dsn(request):
35
+ dsn = request.config.getoption("--gnomon-dsn")
36
+ if not dsn:
37
+ pytest.skip("set --gnomon-dsn or GNOMON_TEST_DSN")
38
+ return dsn
39
+
40
+
41
+ @pytest.fixture
42
+ def gnomon_branch(request, gnomon_dsn):
43
+ import psycopg
44
+
45
+ with psycopg.connect(gnomon_dsn, autocommit=True) as admin:
46
+ name = create_scratch(admin, prefix="pytest")
47
+ yield name
48
+ if not request.config.getoption("--gnomon-keep"):
49
+ with psycopg.connect(gnomon_dsn, autocommit=True) as admin:
50
+ drop_branch(admin, name)
51
+
52
+
53
+ @pytest.fixture
54
+ def gnomon_conn(gnomon_dsn, gnomon_branch):
55
+ import psycopg
56
+
57
+ conn = psycopg.connect(gnomon_dsn, autocommit=True)
58
+ use(conn, gnomon_branch)
59
+ try:
60
+ yield conn
61
+ finally:
62
+ conn.close()
gnomon/sqlalchemy.py ADDED
@@ -0,0 +1,32 @@
1
+ """SQLAlchemy integration.
2
+
3
+ ``branch_engine(url, "feature")`` returns an engine whose connections start on a branch.
4
+ ``attach_branch(engine, lambda: current_branch)`` puts every transaction on whatever branch the
5
+ callable returns (``None`` or ``"main"`` for main), using gnomon.begin(), which is safe behind
6
+ transaction poolers.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from typing import Callable, Optional
11
+
12
+ from sqlalchemy import create_engine, event, text
13
+
14
+ from . import branch_options
15
+
16
+
17
+ def branch_engine(url: str, branch: str, *, c_module: bool = False, schemas=("public",), scratch: bool = False, **kw):
18
+ connect_args = dict(kw.pop("connect_args", {}))
19
+ connect_args["options"] = branch_options(branch, c_module=c_module, schemas=tuple(schemas), scratch=scratch)
20
+ return create_engine(url, connect_args=connect_args, **kw)
21
+
22
+
23
+ def attach_branch(engine, get_branch: Callable[[], Optional[str]]) -> None:
24
+ @event.listens_for(engine, "begin")
25
+ def _begin(conn): # pragma: no cover - exercised in tests
26
+ b = get_branch()
27
+ if b and b != "main":
28
+ conn.exec_driver_sql("select gnomon.begin(%(b)s)", {"b": b})
29
+
30
+
31
+ def use(conn, branch: str = "main") -> None:
32
+ conn.execute(text("select gnomon.use(:b)"), {"b": branch})
gnomon/testing.py ADDED
@@ -0,0 +1,129 @@
1
+ """The testing layer: snapshot assertions on a branch's diff, normalized as the
2
+ specification says (sdk/spec/SPEC.md, Snapshots), with the golden files the TypeScript SDK checks.
3
+
4
+ def test_signup(gnomon_conn, gnomon_snapshot):
5
+ ...
6
+ gnomon_snapshot.assert_match(gnomon_conn) # pytest --gnomon-update-snapshots to record
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from decimal import Decimal
12
+ from pathlib import Path
13
+ from typing import Any, Iterable, Optional
14
+
15
+ from ._util import parse_json, stable_json
16
+
17
+
18
+ def _short(t: str) -> str:
19
+ return t.split(".")[-1]
20
+
21
+
22
+ def _sort_key(k: dict):
23
+ out = []
24
+ for name in sorted(k):
25
+ v = k[name]
26
+ out.append((0, v, "") if isinstance(v, (int, float, Decimal)) and not isinstance(v, bool) else (1, 0, str(v)))
27
+ return out
28
+
29
+
30
+ def normalize_diff(diff: Iterable[dict], *, ignore: Iterable[str] = (), foreign_keys: Iterable[dict] = ()) -> list[str]:
31
+ rows = [{"table": str(d["table"]), "key": d["key"], "kind": d["kind"],
32
+ "col": d.get("column", d.get("col")), "old": d.get("old"), "new": d.get("new")} for d in diff]
33
+ ignore = set(ignore)
34
+ by_table: dict[str, list[dict]] = {}
35
+ for r in rows:
36
+ ks = by_table.setdefault(r["table"], [])
37
+ if not any(stable_json(k) == stable_json(r["key"]) for k in ks):
38
+ ks.append(r["key"])
39
+ ph: dict[str, str] = {}
40
+ idx: dict[str, int] = {}
41
+ for t, ks in by_table.items():
42
+ for i, k in enumerate(sorted(ks, key=_sort_key)):
43
+ ph[f"{t}|{stable_json(k)}"] = f"<{_short(t)}:{i + 1}>"
44
+ idx[f"{t}|{stable_json(k)}"] = i + 1
45
+ fks = list(foreign_keys)
46
+
47
+ def value(table: str, key: dict, col: str, v: Any) -> str:
48
+ if len(key) == 1 and col in key:
49
+ return ph.get(f"{table}|{stable_json(key)}", stable_json(v))
50
+ for f in fks:
51
+ if f["table"] == table and f["columns"] == [col]:
52
+ p = ph.get(f"{f['references']}|{stable_json({f['referenced_columns'][0]: v})}")
53
+ if p:
54
+ return p
55
+ return stable_json(v)
56
+
57
+ lines: dict[str, tuple] = {}
58
+ for r in rows:
59
+ ident = f"{r['table']}|{stable_json(r['key'])}"
60
+ p, i = ph[ident], idx[ident]
61
+ if r["kind"] == "delete":
62
+ s = f"{r['table']} {p} delete"
63
+ lines[s] = (r["table"], i, s)
64
+ elif r["kind"] == "insert":
65
+ for c, v in (r["new"] or {}).items():
66
+ if c in ignore:
67
+ continue
68
+ s = f"{r['table']} {p} insert {c} = {value(r['table'], r['key'], c, v)}"
69
+ lines[s] = (r["table"], i, s)
70
+ elif r["col"] is not None and r["col"] not in ignore:
71
+ s = f"{r['table']} {p} update {r['col']}: {value(r['table'], r['key'], r['col'], r['old'])} -> {value(r['table'], r['key'], r['col'], r['new'])}"
72
+ lines[s] = (r["table"], i, s)
73
+ return [x[2] for x in sorted(lines.values())]
74
+
75
+
76
+ def branch_snapshot(conn, branch: str, *, ignore: Iterable[str] = ()) -> str:
77
+ """A branch's diff as snapshot text, through a psycopg connection."""
78
+ with conn.cursor() as cur:
79
+ cur.execute("select tbl::text, key::text, kind, col, old::text, new::text from gnomon.diff(%s)", (branch,))
80
+ diff = [{"table": t if "." in t else f"public.{t}", "key": parse_json(k), "kind": kind, "col": col, "old": parse_json(o), "new": parse_json(n)}
81
+ for t, k, kind, col, o, n in cur.fetchall()]
82
+ cur.execute("""select format('%%I.%%I', n.nspname, c.relname), (select array_agg(a.attname::text order by k.o) from unnest(f.conkey) with ordinality k(n, o) join pg_attribute a on a.attrelid = f.conrelid and a.attnum = k.n),
83
+ format('%%I.%%I', pn.nspname, p.relname), (select array_agg(a.attname::text order by k.o) from unnest(f.confkey) with ordinality k(n, o) join pg_attribute a on a.attrelid = f.confrelid and a.attnum = k.n)
84
+ from pg_constraint f join pg_class c on c.oid = f.conrelid join pg_namespace n on n.oid = c.relnamespace
85
+ join pg_class p on p.oid = f.confrelid join pg_namespace pn on pn.oid = p.relnamespace where f.contype = 'f'""")
86
+ fks = [{"table": a, "columns": b, "references": c, "referenced_columns": d} for a, b, c, d in cur.fetchall()]
87
+ return "\n".join(normalize_diff(diff, ignore=ignore, foreign_keys=fks)) + "\n"
88
+
89
+
90
+ class Snapshot:
91
+ """``assert_match`` compares a branch's diff with a stored file (recorded with --gnomon-update-snapshots)."""
92
+
93
+ def __init__(self, path: Path, update: bool):
94
+ self.path, self.update = path, update
95
+
96
+ def assert_match(self, conn, branch: Optional[str] = None, *, ignore: Iterable[str] = ()) -> None:
97
+ if branch is None:
98
+ with conn.cursor() as cur:
99
+ cur.execute("select gnomon.current_branch()")
100
+ branch = cur.fetchone()[0]
101
+ got = branch_snapshot(conn, branch, ignore=ignore)
102
+ if self.update or not self.path.exists():
103
+ self.path.parent.mkdir(parents=True, exist_ok=True)
104
+ self.path.write_text(got)
105
+ return
106
+ want = self.path.read_text()
107
+ assert got == want, f"the branch's changes differ from {self.path}:\n--- stored\n{want}--- now\n{got}"
108
+
109
+
110
+ def assert_no_changes(conn, branch: str, tables: Optional[Iterable[str]] = None) -> None:
111
+ with conn.cursor() as cur:
112
+ cur.execute("select tbl::text, key from gnomon.diff(%s)", (branch,))
113
+ hit = [(t, k) for t, k in cur.fetchall() if tables is None or any(t == x or t.endswith("." + x) for x in tables)]
114
+ assert not hit, f"expected no changes, found {len(hit)}: {hit[:5]}"
115
+
116
+
117
+ def assert_changed(conn, branch: str, table: str, key: dict, column: str, value: Any) -> None:
118
+ with conn.cursor() as cur:
119
+ cur.execute("select kind, col, new::text from gnomon.diff(%s, null, array[%s::regclass], %s::jsonb)", (branch, table, stable_json([key])))
120
+ for kind, col, new in cur.fetchall():
121
+ n = parse_json(new)
122
+ if (kind == "update" and col == column and n == value) or (kind == "insert" and (n or {}).get(column) == value):
123
+ return
124
+ raise AssertionError(f"expected {table} {key}.{column} = {value!r} on {branch}")
125
+
126
+
127
+ def snapshot_dir(request) -> Path:
128
+ base = Path(request.config.getoption("--gnomon-snapshot-dir") or os.path.join(os.path.dirname(str(request.fspath)), "__gnomon_snapshots__"))
129
+ return base / (request.node.name.replace("/", "_") + ".txt")
gnomon/tools.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "version": 1,
3
+ "tools": [
4
+ {
5
+ "name": "sql",
6
+ "description": "Run one SQL statement on your working copy of the database (a branch). Reads see your own changes; writes never reach the real data until a person accepts them. One statement per call. Upserts (INSERT ... ON CONFLICT) are not supported unless the server has gnomon's C module: write an UPDATE, then an INSERT ... SELECT ... WHERE NOT EXISTS. Results are cut at a row limit; `truncated` says when.",
7
+ "input": {"type": "object", "properties": {"statement": {"type": "string", "description": "one SQL statement"}, "reason": {"type": "string", "description": "why, for writes: kept with the change"}}, "required": ["statement"]}
8
+ },
9
+ {
10
+ "name": "describe",
11
+ "description": "Describe the database: tables you can read, their columns and keys, foreign keys both ways, which tables you can change, masked columns, row estimates. Call it before writing SQL.",
12
+ "input": {"type": "object", "properties": {"schema": {"type": "string", "description": "one schema, default all"}}, "required": []}
13
+ },
14
+ {
15
+ "name": "diff",
16
+ "description": "What you have changed so far, field by field.",
17
+ "input": {"type": "object", "properties": {}, "required": []}
18
+ },
19
+ {
20
+ "name": "dry_run",
21
+ "description": "Try statements without keeping them: returns the changes they would make, the checks and any error. Nothing is kept.",
22
+ "input": {"type": "object", "properties": {"statements": {"type": "array", "items": {"type": "string"}}}, "required": ["statements"]}
23
+ },
24
+ {
25
+ "name": "checkpoint",
26
+ "description": "Remember the current state of your changes under a name, to rewind to it later.",
27
+ "input": {"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]}
28
+ },
29
+ {
30
+ "name": "rewind",
31
+ "description": "Undo every change you made since a checkpoint.",
32
+ "input": {"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]}
33
+ },
34
+ {
35
+ "name": "done",
36
+ "description": "Say you are finished: a person reviews your changes and accepts or rejects them. Summarize what you did and why.",
37
+ "input": {"type": "object", "properties": {"summary": {"type": "string"}}, "required": ["summary"]}
38
+ }
39
+ ]
40
+ }
gnomon/tools.py ADDED
@@ -0,0 +1,103 @@
1
+ """Tools for models, bound to one run (sdk/spec/SPEC.md, Tools for models). The descriptions are
2
+ the specification's (tools.json, checked against sdk/spec/tools.json).
3
+
4
+ from gnomon.tools import anthropic_tools
5
+ tools = anthropic_tools(run) # @beta_tool functions for the Anthropic tool runner
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from ._util import stable_json
14
+ from .errors import GnomonError
15
+
16
+ TOOL_SPECS: list[dict] = json.loads((Path(__file__).parent / "tools.json").read_text())["tools"]
17
+
18
+
19
+ def run_tool(run, name: str, args: dict) -> tuple[str, bool]:
20
+ """Run one tool on a (sync) run: (text for the model, is_error). Errors are returned, not raised."""
21
+ try:
22
+ if name == "sql":
23
+ r = run.sql(str(args.get("statement", "")), reason=args.get("reason"))
24
+ v: Any = {"rows": r["rows"], "row_count": r["row_count"], "truncated": r["truncated"]}
25
+ elif name == "describe":
26
+ v = run.describe(args.get("schema"))
27
+ elif name == "diff":
28
+ v = run.diff()
29
+ elif name == "dry_run":
30
+ v = run.dry_run(list(args.get("statements") or []))
31
+ elif name == "checkpoint":
32
+ v = {"checkpoint": run.checkpoint(str(args["name"]))}
33
+ elif name == "rewind":
34
+ v = run.rewind(str(args["name"]))
35
+ elif name == "done":
36
+ summary = str(args.get("summary", ""))
37
+ v = {"change_request": run.submit(summary.split("\n")[0][:100] or None, summary).id, "note": "a person reviews it now"}
38
+ else:
39
+ raise GnomonError(f"unknown tool {name}")
40
+ return stable_json(v), False
41
+ except GnomonError as e:
42
+ return f"{type(e).__name__}: {e}" + (f"\nhint: {e.hint}" if e.hint else ""), True
43
+
44
+
45
+ def tool_definitions() -> list[dict]:
46
+ """Plain definitions (name, description, input_schema) for any framework or the Messages API."""
47
+ return [{"name": f"gnomon_{t['name']}", "description": t["description"], "input_schema": t["input"]} for t in TOOL_SPECS]
48
+
49
+
50
+ def handle_tool_use(run, block: dict) -> dict:
51
+ """Answer an Anthropic tool_use block with a tool_result block."""
52
+ text, err = run_tool(run, block["name"].removeprefix("gnomon_"), block.get("input") or {})
53
+ return {"type": "tool_result", "tool_use_id": block["id"], "content": text, "is_error": err}
54
+
55
+
56
+ def anthropic_tools(run) -> list:
57
+ """Functions decorated with the Anthropic SDK's @beta_tool (needs anthropic). The input schemas
58
+ are the specification's: beta_tool would infer an empty one from the functions' ``**kw``."""
59
+ from anthropic import beta_tool # type: ignore
60
+
61
+ def make(name: str):
62
+ def fn(**kw):
63
+ return run_tool(run, name, kw)[0]
64
+ return fn
65
+ return [beta_tool(make(t["name"]), name=f"gnomon_{t['name']}", description=t["description"], input_schema=t["input"])
66
+ for t in TOOL_SPECS]
67
+
68
+
69
+ def openai_agents_tools(run) -> list:
70
+ """FunctionTool objects for the OpenAI Agents SDK (needs openai-agents)."""
71
+ from agents import FunctionTool # type: ignore
72
+
73
+ def make(t):
74
+ async def invoke(_ctx, args_json: str) -> str:
75
+ return run_tool(run, t["name"], json.loads(args_json or "{}"))[0]
76
+ return FunctionTool(name=f"gnomon_{t['name']}", description=t["description"],
77
+ params_json_schema={**t["input"], "additionalProperties": False}, on_invoke_tool=invoke, strict_json_schema=False)
78
+ return [make(t) for t in TOOL_SPECS]
79
+
80
+
81
+ def langchain_tools(run) -> list:
82
+ """StructuredTool objects for LangChain and LangGraph (needs langchain-core)."""
83
+ from langchain_core.tools import StructuredTool # type: ignore
84
+
85
+ return [StructuredTool.from_function(func=(lambda name: (lambda **kw: run_tool(run, name, kw)[0]))(t["name"]),
86
+ name=f"gnomon_{t['name']}", description=t["description"], args_schema=t["input"]) for t in TOOL_SPECS]
87
+
88
+
89
+ def mcp_handler(run):
90
+ """A JSON-RPC handler bound to the run (tools/list, tools/call) for any MCP transport."""
91
+ def handle(req: dict) -> dict:
92
+ m, p = req.get("method"), req.get("params") or {}
93
+ if m == "initialize":
94
+ return {"jsonrpc": "2.0", "id": req.get("id"), "result": {"protocolVersion": p.get("protocolVersion", "2025-06-18"),
95
+ "capabilities": {"tools": {}}, "serverInfo": {"name": "gnomon", "version": "1.0.0"}}}
96
+ if m == "tools/list":
97
+ return {"jsonrpc": "2.0", "id": req.get("id"), "result": {"tools": [{"name": t["name"], "description": t["description"], "inputSchema": t["input"]} for t in TOOL_SPECS],
98
+ "ttlMs": 3600000, "cacheScope": "private"}}
99
+ if m == "tools/call":
100
+ text, err = run_tool(run, p.get("name", ""), p.get("arguments") or {})
101
+ return {"jsonrpc": "2.0", "id": req.get("id"), "result": {"content": [{"type": "text", "text": text}], "isError": err, "resultType": "complete"}}
102
+ return {"jsonrpc": "2.0", "id": req.get("id"), "error": {"code": -32601, "message": f"method not found: {m}"}}
103
+ return handle
@@ -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,18 @@
1
+ gnomon/__init__.py,sha256=VAeoRn9SWkDUYfqR8tMMxGxh9h8XXjURPYsKGsHVTWc,5702
2
+ gnomon/_util.py,sha256=3bUAl6VLN8Z6pi07EhhHIUemGLvPFths55LRaWs2_m8,2798
3
+ gnomon/aio.py,sha256=Olf-_huDimv9OkQ0jWWoxf5hOXPj75OLwVGIfueE8dE,18258
4
+ gnomon/asyncpg.py,sha256=haL9WMtpJ3HM_JdkpSMfDD3H8iKpbAg0Aqk69ZRdqsA,5724
5
+ gnomon/client.py,sha256=NLTco_gvD8zs1nxaBBpm0TD2ZVHKNmJZvgi3JcLzM2w,17298
6
+ gnomon/django.py,sha256=GkA1ei2kYytV17hMaIrMuQeYYncv5abD_u9H6MXF4s4,2730
7
+ gnomon/errors.py,sha256=lPpAgDEJJ0XyWjILutc1pe-os-WBPwLCqcAWiaSjl-0,2654
8
+ gnomon/pytest_plugin.py,sha256=yirXfCCkuEGJ6zwB4NNXLuWA02rwdQxrt01et5YsCPc,2168
9
+ gnomon/sqlalchemy.py,sha256=DO47QryXYNQbERvY1VF09BFqALPFKOcSyqX6nG8FxxA,1256
10
+ gnomon/testing.py,sha256=KqhX3mRTjV3Mi6D4U7kBSiHHWam9JjCa-tF40ICTeHI,6507
11
+ gnomon/tools.json,sha256=-nqaRQF-3Ppu4bjLAu3CcaWrLh0AmRDkzDmjSDafzHE,2327
12
+ gnomon/tools.py,sha256=bSD_vfg7Maum6EK9JZO37C3pC9R0JkFOG7cBTUMVPbA,5145
13
+ gnomon_postgres-1.0.0.dist-info/licenses/LICENSE,sha256=gMGd1ia320QA8YQVbOpx-VjCmZKQiAhpF6uRj3Rb96g,1004
14
+ gnomon_postgres-1.0.0.dist-info/METADATA,sha256=d4s_h2SRNOWZ5dw-eiIvAv21ymWMF8NJJrDrV_2jSEo,3936
15
+ gnomon_postgres-1.0.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
16
+ gnomon_postgres-1.0.0.dist-info/entry_points.txt,sha256=U5Ktr_pn9DXztumm1rhWGfZ4a6MYK23X86EFr7s2Qh4,41
17
+ gnomon_postgres-1.0.0.dist-info/top_level.txt,sha256=OmatCqPQU5O9PKfuUnk9ISukfjNNHIqGJrkis6wc0pU,7
18
+ gnomon_postgres-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [pytest11]
2
+ gnomon = gnomon.pytest_plugin
@@ -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 @@
1
+ gnomon