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/pytest_plugin.py
ADDED
|
@@ -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,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
|