sql-write-gate 0.16.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.
Files changed (42) hide show
  1. sql_write_gate-0.16.0.dist-info/METADATA +482 -0
  2. sql_write_gate-0.16.0.dist-info/RECORD +42 -0
  3. sql_write_gate-0.16.0.dist-info/WHEEL +5 -0
  4. sql_write_gate-0.16.0.dist-info/entry_points.txt +2 -0
  5. sql_write_gate-0.16.0.dist-info/licenses/LICENSE +21 -0
  6. sql_write_gate-0.16.0.dist-info/top_level.txt +1 -0
  7. write_gate/__init__.py +7 -0
  8. write_gate/__main__.py +6 -0
  9. write_gate/adapters/__init__.py +33 -0
  10. write_gate/adapters/base.py +101 -0
  11. write_gate/adapters/duckdb.py +41 -0
  12. write_gate/adapters/mysql.py +115 -0
  13. write_gate/adapters/postgres.py +96 -0
  14. write_gate/adapters/sqlite.py +116 -0
  15. write_gate/approvals.py +205 -0
  16. write_gate/audit.py +120 -0
  17. write_gate/cases.py +30 -0
  18. write_gate/catalog.py +79 -0
  19. write_gate/cli.py +397 -0
  20. write_gate/config.py +117 -0
  21. write_gate/db.py +5 -0
  22. write_gate/decision.py +143 -0
  23. write_gate/engine.py +150 -0
  24. write_gate/guards/__init__.py +17 -0
  25. write_gate/guards/blast_radius.py +73 -0
  26. write_gate/guards/destructive.py +85 -0
  27. write_gate/guards/environment.py +41 -0
  28. write_gate/guards/freshness.py +102 -0
  29. write_gate/guards/pii.py +84 -0
  30. write_gate/guards/schema.py +147 -0
  31. write_gate/hooks.py +360 -0
  32. write_gate/init.py +84 -0
  33. write_gate/mcp_server.py +71 -0
  34. write_gate/mcp_tools.py +197 -0
  35. write_gate/parser.py +367 -0
  36. write_gate/paths.py +20 -0
  37. write_gate/policy.py +49 -0
  38. write_gate/proxy.py +273 -0
  39. write_gate/templates/GETTING_STARTED.md +20 -0
  40. write_gate/templates/catalog.json +32 -0
  41. write_gate/templates/policy.yaml +12 -0
  42. write_gate/wrapper.py +207 -0
@@ -0,0 +1,101 @@
1
+ """Adapter routing: postgres:// / mysql:// / sqlite:// URLs vs DuckDB file paths."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+ from typing import Mapping
8
+
9
+ from write_gate.paths import DB_PATH
10
+
11
+ BACKEND_DUCKDB = "duckdb"
12
+ BACKEND_POSTGRES = "postgres"
13
+ BACKEND_MYSQL = "mysql"
14
+ BACKEND_SQLITE = "sqlite"
15
+
16
+ _PG_PREFIXES = ("postgres://", "postgresql://")
17
+ _MYSQL_PREFIXES = ("mysql://", "mysql+pymysql://")
18
+ _SQLITE_PREFIXES = ("sqlite:///", "sqlite+aiosqlite://")
19
+
20
+
21
+ def is_postgres_url(value: str | None) -> bool:
22
+ if not value:
23
+ return False
24
+ lowered = value.strip().lower()
25
+ return lowered.startswith(_PG_PREFIXES)
26
+
27
+
28
+ def is_mysql_url(value: str | None) -> bool:
29
+ if not value:
30
+ return False
31
+ lowered = value.strip().lower()
32
+ return lowered.startswith(_MYSQL_PREFIXES)
33
+
34
+
35
+ def is_sqlite_url(value: str | None) -> bool:
36
+ if not value:
37
+ return False
38
+ lowered = value.strip().lower()
39
+ return lowered.startswith(_SQLITE_PREFIXES)
40
+
41
+
42
+ def detect_backend(target: str | None) -> str:
43
+ if is_sqlite_url(target):
44
+ return BACKEND_SQLITE
45
+ if is_mysql_url(target):
46
+ return BACKEND_MYSQL
47
+ if is_postgres_url(target):
48
+ return BACKEND_POSTGRES
49
+ return BACKEND_DUCKDB
50
+
51
+
52
+ def sqlglot_dialect(backend: str) -> str:
53
+ if backend in {BACKEND_POSTGRES, "postgresql", "pg"}:
54
+ return "postgres"
55
+ if backend == BACKEND_MYSQL:
56
+ return "mysql"
57
+ if backend == BACKEND_SQLITE:
58
+ return "sqlite"
59
+ return "duckdb"
60
+
61
+
62
+ def count_sql(table: str, predicate: str | None, *, backend: str = BACKEND_DUCKDB) -> str:
63
+ """SELECT COUNT(*) of rows matching the write predicate.
64
+
65
+ Same COUNT form for DuckDB, Postgres, MySQL, and SQLite (EXPLAIN is optional elsewhere).
66
+ `backend` is accepted so callers can be explicit; quoting is ANSI identifiers.
67
+ """
68
+ del backend # COUNT SQL is shared; dialect only affects WHERE rendering.
69
+ sql = f'SELECT COUNT(*) FROM "{table}"'
70
+ if predicate:
71
+ sql = f"{sql} WHERE {predicate}"
72
+ return sql
73
+
74
+
75
+ def resolve_target(
76
+ *,
77
+ database: str | None = None,
78
+ database_url: str | None = None,
79
+ db_path: str | Path | None = None,
80
+ environ: Mapping[str, str] | None = None,
81
+ ) -> tuple[str, str]:
82
+ """Return (backend, target).
83
+
84
+ Priority:
85
+ 1. ``database=``
86
+ 2. ``database_url=``
87
+ 3. ``db_path=`` (explicit path wins over env so tests/demo stay DuckDB)
88
+ 4. ``DATABASE_URL`` env
89
+ 5. default DuckDB warehouse
90
+ """
91
+ env = os.environ if environ is None else environ
92
+ for candidate in (database, database_url):
93
+ if candidate:
94
+ return detect_backend(candidate), candidate
95
+ if db_path is not None:
96
+ target = str(db_path)
97
+ return detect_backend(target), target
98
+ env_url = env.get("DATABASE_URL")
99
+ if env_url:
100
+ return detect_backend(env_url), env_url
101
+ return BACKEND_DUCKDB, str(DB_PATH)
@@ -0,0 +1,41 @@
1
+ """DuckDB warehouse helpers. User SQL must not be executed from here; use WriteGate."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import duckdb
8
+
9
+ from write_gate.adapters.base import BACKEND_DUCKDB, count_sql as _count_sql
10
+ from write_gate.paths import DB_PATH
11
+
12
+ DIALECT = "duckdb"
13
+ BACKEND = BACKEND_DUCKDB
14
+
15
+ ORDERS_DDL = """
16
+ CREATE TABLE orders (
17
+ order_id INTEGER PRIMARY KEY,
18
+ user_id INTEGER NOT NULL,
19
+ amount DOUBLE NOT NULL,
20
+ dt DATE NOT NULL,
21
+ email VARCHAR,
22
+ phone VARCHAR,
23
+ status VARCHAR NOT NULL
24
+ )
25
+ """
26
+
27
+
28
+ def count_sql(table: str, predicate: str | None) -> str:
29
+ """Blast-radius estimate: COUNT(*) of the matching predicate."""
30
+ return _count_sql(table, predicate, backend=BACKEND_DUCKDB)
31
+
32
+
33
+ def connect(db_path: Path | None = None, *, read_only: bool = False) -> duckdb.DuckDBPyConnection:
34
+ path = Path(db_path) if db_path else DB_PATH
35
+ path.parent.mkdir(parents=True, exist_ok=True)
36
+ return duckdb.connect(str(path), read_only=read_only)
37
+
38
+
39
+ def execute_user_sql(conn: duckdb.DuckDBPyConnection, sql: str):
40
+ """Run already-gated SQL. Called only by write_gate.wrapper.WriteGate."""
41
+ return conn.execute(sql)
@@ -0,0 +1,115 @@
1
+ """MySQL adapter. User SQL must not be executed from here; use WriteGate."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+ from urllib.parse import unquote, urlparse
7
+
8
+ from write_gate.adapters.base import BACKEND_MYSQL, count_sql as _count_sql
9
+
10
+ DIALECT = "mysql"
11
+ BACKEND = BACKEND_MYSQL
12
+
13
+ ORDERS_DDL = """
14
+ CREATE TABLE orders (
15
+ order_id INTEGER PRIMARY KEY,
16
+ user_id INTEGER NOT NULL,
17
+ amount DOUBLE NOT NULL,
18
+ dt DATE NOT NULL,
19
+ email VARCHAR(255),
20
+ phone VARCHAR(64),
21
+ status VARCHAR(64) NOT NULL
22
+ )
23
+ """
24
+
25
+
26
+ def count_sql(table: str, predicate: str | None) -> str:
27
+ """Blast-radius estimate: COUNT(*) of the matching predicate (not EXPLAIN)."""
28
+ return _count_sql(table, predicate, backend=BACKEND_MYSQL)
29
+
30
+
31
+ def explain_sql(table: str, predicate: str | None) -> str:
32
+ """Optional EXPLAIN form. Blast-radius uses count_sql() instead."""
33
+ return f"EXPLAIN {count_sql(table, predicate)}"
34
+
35
+
36
+ def _parse_mysql_dsn(dsn: str) -> dict[str, Any]:
37
+ """Parse mysql:// or mysql+pymysql:// into driver connect kwargs."""
38
+ parsed = urlparse(dsn.strip())
39
+ database = unquote(parsed.path.lstrip("/")) if parsed.path else ""
40
+ kwargs: dict[str, Any] = {
41
+ "host": parsed.hostname or "localhost",
42
+ "port": parsed.port or 3306,
43
+ "user": unquote(parsed.username) if parsed.username else None,
44
+ "password": unquote(parsed.password) if parsed.password is not None else "",
45
+ "database": database or None,
46
+ }
47
+ return {k: v for k, v in kwargs.items() if v is not None}
48
+
49
+
50
+ def _connect_raw(dsn: str, **kwargs: Any) -> Any:
51
+ """Open a driver connection. Tests may monkeypatch this."""
52
+ timeout = kwargs.pop("connect_timeout", 3)
53
+ params = _parse_mysql_dsn(dsn)
54
+ params.update(kwargs)
55
+ params.setdefault("connect_timeout", timeout)
56
+ try:
57
+ import pymysql
58
+
59
+ return pymysql.connect(**params)
60
+ except ImportError:
61
+ pass
62
+ try:
63
+ import mysql.connector
64
+
65
+ # mysql.connector uses connection_timeout, not connect_timeout.
66
+ if "connect_timeout" in params:
67
+ params["connection_timeout"] = params.pop("connect_timeout")
68
+ return mysql.connector.connect(**params)
69
+ except ImportError as exc:
70
+ raise ImportError(
71
+ "MySQL support requires pymysql. "
72
+ "Install with: pip install 'sql-write-gate[mysql]'"
73
+ ) from exc
74
+
75
+
76
+ class MySQLConnection:
77
+ """DuckDB-like execute/fetchone surface over pymysql or mysql.connector."""
78
+
79
+ def __init__(self, raw: Any) -> None:
80
+ self._raw = raw
81
+ try:
82
+ self._raw.autocommit = True
83
+ except Exception:
84
+ pass
85
+
86
+ def execute(self, sql: str):
87
+ cursor_fn = getattr(self._raw, "cursor", None)
88
+ if callable(cursor_fn):
89
+ cur = cursor_fn()
90
+ cur.execute(sql)
91
+ return cur
92
+ execute = getattr(self._raw, "execute", None)
93
+ if callable(execute):
94
+ return execute(sql)
95
+ raise RuntimeError("MySQL connection does not support execute/cursor")
96
+
97
+ def close(self) -> None:
98
+ close = getattr(self._raw, "close", None)
99
+ if callable(close):
100
+ close()
101
+
102
+ def commit(self) -> None:
103
+ commit = getattr(self._raw, "commit", None)
104
+ if callable(commit):
105
+ commit()
106
+
107
+
108
+ def connect(dsn: str, *, read_only: bool = False) -> MySQLConnection:
109
+ del read_only
110
+ return MySQLConnection(_connect_raw(dsn))
111
+
112
+
113
+ def execute_user_sql(conn: Any, sql: str):
114
+ """Run already-gated SQL. Called only by write_gate.wrapper.WriteGate."""
115
+ return conn.execute(sql)
@@ -0,0 +1,96 @@
1
+ """PostgreSQL adapter. User SQL must not be executed from here; use WriteGate."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from write_gate.adapters.base import BACKEND_POSTGRES, count_sql as _count_sql
8
+
9
+ DIALECT = "postgres"
10
+ BACKEND = BACKEND_POSTGRES
11
+
12
+ ORDERS_DDL = """
13
+ CREATE TABLE orders (
14
+ order_id INTEGER PRIMARY KEY,
15
+ user_id INTEGER NOT NULL,
16
+ amount DOUBLE PRECISION NOT NULL,
17
+ dt DATE NOT NULL,
18
+ email VARCHAR,
19
+ phone VARCHAR,
20
+ status VARCHAR NOT NULL
21
+ )
22
+ """
23
+
24
+
25
+ def count_sql(table: str, predicate: str | None) -> str:
26
+ """Blast-radius estimate: COUNT(*) of the matching predicate (not EXPLAIN)."""
27
+ return _count_sql(table, predicate, backend=BACKEND_POSTGRES)
28
+
29
+
30
+ def explain_sql(table: str, predicate: str | None) -> str:
31
+ """Optional EXPLAIN form. Blast-radius uses count_sql() instead."""
32
+ return f"EXPLAIN {count_sql(table, predicate)}"
33
+
34
+
35
+ def _connect_raw(dsn: str, **kwargs: Any) -> Any:
36
+ """Open a driver connection. Tests may monkeypatch this."""
37
+ timeout = kwargs.pop("connect_timeout", 3)
38
+ try:
39
+ import psycopg
40
+
41
+ return psycopg.connect(dsn, autocommit=True, connect_timeout=timeout, **kwargs)
42
+ except ImportError:
43
+ pass
44
+ try:
45
+ import psycopg2
46
+
47
+ conn = psycopg2.connect(dsn, connect_timeout=timeout, **kwargs)
48
+ conn.autocommit = True
49
+ return conn
50
+ except ImportError as exc:
51
+ raise ImportError(
52
+ "PostgreSQL support requires psycopg. "
53
+ "Install with: pip install 'sql-write-gate[postgres]'"
54
+ ) from exc
55
+
56
+
57
+ class PostgresConnection:
58
+ """DuckDB-like execute/fetchone surface over psycopg or psycopg2."""
59
+
60
+ def __init__(self, raw: Any) -> None:
61
+ self._raw = raw
62
+ try:
63
+ self._raw.autocommit = True
64
+ except Exception:
65
+ pass
66
+
67
+ def execute(self, sql: str):
68
+ cursor_fn = getattr(self._raw, "cursor", None)
69
+ if callable(cursor_fn):
70
+ cur = cursor_fn()
71
+ cur.execute(sql)
72
+ return cur
73
+ execute = getattr(self._raw, "execute", None)
74
+ if callable(execute):
75
+ return execute(sql)
76
+ raise RuntimeError("PostgreSQL connection does not support execute/cursor")
77
+
78
+ def close(self) -> None:
79
+ close = getattr(self._raw, "close", None)
80
+ if callable(close):
81
+ close()
82
+
83
+ def commit(self) -> None:
84
+ commit = getattr(self._raw, "commit", None)
85
+ if callable(commit):
86
+ commit()
87
+
88
+
89
+ def connect(dsn: str, *, read_only: bool = False) -> PostgresConnection:
90
+ del read_only
91
+ return PostgresConnection(_connect_raw(dsn))
92
+
93
+
94
+ def execute_user_sql(conn: Any, sql: str):
95
+ """Run already-gated SQL. Called only by write_gate.wrapper.WriteGate."""
96
+ return conn.execute(sql)
@@ -0,0 +1,116 @@
1
+ """SQLite adapter. User SQL must not be executed from here; use WriteGate."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sqlite3
6
+ from pathlib import Path
7
+ from typing import Any
8
+ from urllib.parse import unquote
9
+
10
+ from write_gate.adapters.base import BACKEND_SQLITE, count_sql as _count_sql
11
+
12
+ DIALECT = "sqlite"
13
+ BACKEND = BACKEND_SQLITE
14
+
15
+ ORDERS_DDL = """
16
+ CREATE TABLE orders (
17
+ order_id INTEGER PRIMARY KEY,
18
+ user_id INTEGER NOT NULL,
19
+ amount REAL NOT NULL,
20
+ dt DATE NOT NULL,
21
+ email TEXT,
22
+ phone TEXT,
23
+ status TEXT NOT NULL
24
+ )
25
+ """
26
+
27
+
28
+ def count_sql(table: str, predicate: str | None) -> str:
29
+ """Blast-radius estimate: COUNT(*) of the matching predicate (not EXPLAIN)."""
30
+ return _count_sql(table, predicate, backend=BACKEND_SQLITE)
31
+
32
+
33
+ def explain_sql(table: str, predicate: str | None) -> str:
34
+ """Optional EXPLAIN form. Blast-radius uses count_sql() instead."""
35
+ return f"EXPLAIN {count_sql(table, predicate)}"
36
+
37
+
38
+ def _parse_sqlite_path(dsn: str) -> str:
39
+ """Extract a filesystem path (or :memory:) from sqlite:/// / sqlite+aiosqlite://."""
40
+ raw = dsn.strip()
41
+ lower = raw.lower()
42
+ if lower.startswith("sqlite+aiosqlite://"):
43
+ rest = raw[len("sqlite+aiosqlite://") :]
44
+ elif lower.startswith("sqlite://"):
45
+ rest = raw[len("sqlite://") :]
46
+ else:
47
+ raise ValueError(f"not a sqlite URL: {dsn}")
48
+
49
+ if "?" in rest:
50
+ rest = rest.split("?", 1)[0]
51
+ rest = unquote(rest)
52
+
53
+ if rest in (":memory:", "/:memory:"):
54
+ return ":memory:"
55
+ # Four-slash absolute: //tmp/x.db -> /tmp/x.db
56
+ if rest.startswith("//"):
57
+ return rest[1:]
58
+ # Three-slash form: /tmp/x.db or /relative.db
59
+ if rest.startswith("/"):
60
+ return rest
61
+ return rest or ":memory:"
62
+
63
+
64
+ def _connect_raw(dsn: str, **kwargs: Any) -> Any:
65
+ """Open a stdlib sqlite3 connection. Tests may monkeypatch this."""
66
+ path = _parse_sqlite_path(dsn)
67
+ timeout = kwargs.pop("timeout", 3.0)
68
+ if path != ":memory:":
69
+ parent = Path(path).parent
70
+ if str(parent) not in ("", "."):
71
+ parent.mkdir(parents=True, exist_ok=True)
72
+ try:
73
+ conn = sqlite3.connect(path, timeout=timeout, **kwargs)
74
+ except TypeError:
75
+ # Unexpected kwargs from callers; retry with path only.
76
+ conn = sqlite3.connect(path, timeout=timeout)
77
+ conn.isolation_level = None # autocommit-like
78
+ return conn
79
+
80
+
81
+ class SQLiteConnection:
82
+ """DuckDB-like execute/fetchone surface over stdlib sqlite3."""
83
+
84
+ def __init__(self, raw: Any) -> None:
85
+ self._raw = raw
86
+
87
+ def execute(self, sql: str):
88
+ cursor_fn = getattr(self._raw, "cursor", None)
89
+ if callable(cursor_fn):
90
+ cur = cursor_fn()
91
+ cur.execute(sql)
92
+ return cur
93
+ execute = getattr(self._raw, "execute", None)
94
+ if callable(execute):
95
+ return execute(sql)
96
+ raise RuntimeError("SQLite connection does not support execute/cursor")
97
+
98
+ def close(self) -> None:
99
+ close = getattr(self._raw, "close", None)
100
+ if callable(close):
101
+ close()
102
+
103
+ def commit(self) -> None:
104
+ commit = getattr(self._raw, "commit", None)
105
+ if callable(commit):
106
+ commit()
107
+
108
+
109
+ def connect(dsn: str, *, read_only: bool = False) -> SQLiteConnection:
110
+ del read_only
111
+ return SQLiteConnection(_connect_raw(dsn))
112
+
113
+
114
+ def execute_user_sql(conn: Any, sql: str):
115
+ """Run already-gated SQL. Called only by write_gate.wrapper.WriteGate."""
116
+ return conn.execute(sql)
@@ -0,0 +1,205 @@
1
+ """JSONL approval queue. REQUIRE_APPROVAL SQL is recorded and not executed."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import uuid
7
+ from dataclasses import dataclass, field
8
+ from datetime import datetime, timezone
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from write_gate.decision import Decision
13
+ from write_gate.paths import APPROVALS_PATH, LOG_DIR
14
+
15
+ STATUS_PENDING = "pending"
16
+ STATUS_APPROVED = "approved"
17
+ STATUS_REJECTED = "rejected"
18
+
19
+ _ID_LEN = 12
20
+
21
+
22
+ class ApprovalError(Exception):
23
+ """Missing, not pending, or invalid approval record."""
24
+
25
+
26
+ @dataclass
27
+ class ApprovalRecord:
28
+ id: str
29
+ status: str
30
+ sql: str
31
+ database: str | None = None
32
+ db_path: str | None = None
33
+ policy_path: str | None = None
34
+ catalog_path: str | None = None
35
+ created_at: str = ""
36
+ decision: dict[str, Any] = field(default_factory=dict)
37
+ backend: str | None = None
38
+ agent: str | None = None
39
+
40
+ def to_dict(self) -> dict[str, Any]:
41
+ return {
42
+ "id": self.id,
43
+ "status": self.status,
44
+ "sql": self.sql,
45
+ "database": self.database,
46
+ "db_path": self.db_path,
47
+ "policy_path": self.policy_path,
48
+ "catalog_path": self.catalog_path,
49
+ "created_at": self.created_at,
50
+ "decision": self.decision,
51
+ "backend": self.backend,
52
+ "agent": self.agent,
53
+ }
54
+
55
+ @classmethod
56
+ def from_dict(cls, raw: dict[str, Any]) -> "ApprovalRecord":
57
+ decision = raw.get("decision")
58
+ if not isinstance(decision, dict):
59
+ decision = {}
60
+ return cls(
61
+ id=str(raw.get("id") or ""),
62
+ status=str(raw.get("status") or STATUS_PENDING),
63
+ sql=str(raw.get("sql") or ""),
64
+ database=raw.get("database"),
65
+ db_path=raw.get("db_path"),
66
+ policy_path=raw.get("policy_path"),
67
+ catalog_path=raw.get("catalog_path"),
68
+ created_at=str(raw.get("created_at") or ""),
69
+ decision=decision,
70
+ backend=raw.get("backend"),
71
+ agent=raw.get("agent"),
72
+ )
73
+
74
+
75
+ def _now() -> str:
76
+ return datetime.now(timezone.utc).isoformat()
77
+
78
+
79
+ def _new_id() -> str:
80
+ return uuid.uuid4().hex[:_ID_LEN]
81
+
82
+
83
+ def _path(path: Path | str | None = None) -> Path:
84
+ return Path(path) if path else APPROVALS_PATH
85
+
86
+
87
+ def _load(path: Path) -> dict[str, dict[str, Any]]:
88
+ if not path.exists():
89
+ return {}
90
+ records: dict[str, dict[str, Any]] = {}
91
+ try:
92
+ text = path.read_text(encoding="utf-8")
93
+ except OSError:
94
+ return {}
95
+ for line in text.splitlines():
96
+ line = line.strip()
97
+ if not line:
98
+ continue
99
+ try:
100
+ raw = json.loads(line)
101
+ except json.JSONDecodeError:
102
+ continue
103
+ if not isinstance(raw, dict):
104
+ continue
105
+ rec_id = str(raw.get("id") or "")
106
+ if rec_id:
107
+ records[rec_id] = raw
108
+ return records
109
+
110
+
111
+ def _save(path: Path, records: dict[str, dict[str, Any]]) -> None:
112
+ path.parent.mkdir(parents=True, exist_ok=True)
113
+ tmp = path.with_suffix(path.suffix + ".tmp")
114
+ body = "".join(json.dumps(rec, ensure_ascii=False) + "\n" for rec in records.values())
115
+ tmp.write_text(body, encoding="utf-8")
116
+ tmp.replace(path)
117
+
118
+
119
+ def get_approval(approval_id: str, path: Path | str | None = None) -> ApprovalRecord | None:
120
+ dest = _path(path)
121
+ raw = _load(dest).get(str(approval_id))
122
+ if not raw:
123
+ return None
124
+ return ApprovalRecord.from_dict(raw)
125
+
126
+
127
+ def list_pending(path: Path | str | None = None) -> list[ApprovalRecord]:
128
+ dest = _path(path)
129
+ pending: list[ApprovalRecord] = []
130
+ for raw in _load(dest).values():
131
+ rec = ApprovalRecord.from_dict(raw)
132
+ if rec.status == STATUS_PENDING and rec.id:
133
+ pending.append(rec)
134
+ pending.sort(key=lambda r: r.created_at)
135
+ return pending
136
+
137
+
138
+ def enqueue_approval(
139
+ *,
140
+ sql: str,
141
+ decision: Decision,
142
+ database: str | None = None,
143
+ db_path: str | None = None,
144
+ policy_path: str | None = None,
145
+ catalog_path: str | None = None,
146
+ path: Path | str | None = None,
147
+ backend: str | None = None,
148
+ agent: str | None = None,
149
+ ) -> ApprovalRecord:
150
+ dest = _path(path)
151
+ records = _load(dest)
152
+ approval_id = _new_id()
153
+ while approval_id in records:
154
+ approval_id = _new_id()
155
+ rec = ApprovalRecord(
156
+ id=approval_id,
157
+ status=STATUS_PENDING,
158
+ sql=sql,
159
+ database=database,
160
+ db_path=db_path,
161
+ policy_path=str(policy_path) if policy_path else None,
162
+ catalog_path=str(catalog_path) if catalog_path else None,
163
+ created_at=_now(),
164
+ decision=decision.to_dict(),
165
+ backend=backend,
166
+ agent=agent,
167
+ )
168
+ # Snapshot includes approval_id once attached on the live decision.
169
+ rec.decision = {**rec.decision, "approval_id": approval_id}
170
+ records[approval_id] = rec.to_dict()
171
+ _save(dest, records)
172
+ return rec
173
+
174
+
175
+ def set_status(
176
+ approval_id: str,
177
+ status: str,
178
+ path: Path | str | None = None,
179
+ ) -> ApprovalRecord:
180
+ if status not in {STATUS_PENDING, STATUS_APPROVED, STATUS_REJECTED}:
181
+ raise ApprovalError(f"invalid approval status: {status}")
182
+ dest = _path(path)
183
+ records = _load(dest)
184
+ raw = records.get(str(approval_id))
185
+ if not raw:
186
+ raise ApprovalError(f"approval not found: {approval_id}")
187
+ rec = ApprovalRecord.from_dict(raw)
188
+ if rec.status != STATUS_PENDING:
189
+ raise ApprovalError(f"approval not pending: {approval_id}")
190
+ rec.status = status
191
+ records[rec.id] = rec.to_dict()
192
+ _save(dest, records)
193
+ return rec
194
+
195
+
196
+ def mark_approved(approval_id: str, path: Path | str | None = None) -> ApprovalRecord:
197
+ return set_status(approval_id, STATUS_APPROVED, path=path)
198
+
199
+
200
+ def mark_rejected(approval_id: str, path: Path | str | None = None) -> ApprovalRecord:
201
+ return set_status(approval_id, STATUS_REJECTED, path=path)
202
+
203
+
204
+ def ensure_log_dir() -> None:
205
+ LOG_DIR.mkdir(parents=True, exist_ok=True)