sql-harness 0.3.3__tar.gz → 0.3.4__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.
- {sql_harness-0.3.3 → sql_harness-0.3.4}/PKG-INFO +2 -2
- {sql_harness-0.3.3 → sql_harness-0.3.4}/SKILL.md +31 -7
- {sql_harness-0.3.3 → sql_harness-0.3.4}/docs/AGENTS.md +2 -0
- {sql_harness-0.3.3 → sql_harness-0.3.4}/docs/README.md +1 -1
- {sql_harness-0.3.3/skills/sql-harness/references → sql_harness-0.3.4/docs}/install.md +5 -2
- {sql_harness-0.3.3 → sql_harness-0.3.4}/docs/maintainer.md +16 -4
- {sql_harness-0.3.3 → sql_harness-0.3.4}/interaction-skills/transactions.md +1 -0
- {sql_harness-0.3.3 → sql_harness-0.3.4}/pyproject.toml +2 -1
- {sql_harness-0.3.3 → sql_harness-0.3.4}/src/sql_harness/analytics.py +6 -2
- {sql_harness-0.3.3 → sql_harness-0.3.4}/src/sql_harness/cli.py +48 -26
- {sql_harness-0.3.3 → sql_harness-0.3.4}/src/sql_harness/config.py +4 -0
- {sql_harness-0.3.3 → sql_harness-0.3.4}/src/sql_harness/drivers/__init__.py +13 -1
- {sql_harness-0.3.3 → sql_harness-0.3.4}/src/sql_harness/drivers/mysql.py +29 -2
- {sql_harness-0.3.3 → sql_harness-0.3.4}/src/sql_harness/drivers/postgres.py +32 -2
- sql_harness-0.3.4/src/sql_harness/drivers/readonly.py +265 -0
- {sql_harness-0.3.3 → sql_harness-0.3.4}/src/sql_harness/drivers/redis.py +6 -1
- {sql_harness-0.3.3 → sql_harness-0.3.4}/src/sql_harness/drivers/sqlite.py +14 -2
- {sql_harness-0.3.3 → sql_harness-0.3.4}/src/sql_harness/drivers/ssh.py +6 -2
- {sql_harness-0.3.3 → sql_harness-0.3.4}/src/sql_harness/helpers.py +14 -0
- {sql_harness-0.3.3 → sql_harness-0.3.4}/src/sql_harness/manager.py +3 -1
- sql_harness-0.3.4/src/sql_harness/output.py +309 -0
- {sql_harness-0.3.3 → sql_harness-0.3.4}/src/sql_harness/run.py +14 -13
- {sql_harness-0.3.3 → sql_harness-0.3.4}/tests/unit/test_cli.py +127 -1
- sql_harness-0.3.4/tests/unit/test_output.py +161 -0
- sql_harness-0.3.4/tests/unit/test_readonly.py +185 -0
- {sql_harness-0.3.3 → sql_harness-0.3.4}/uv.lock +1 -1
- sql_harness-0.3.3/docs/install.md +0 -155
- sql_harness-0.3.3/skills/sql-harness/README.txt +0 -8
- {sql_harness-0.3.3 → sql_harness-0.3.4}/.env.example +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.4}/.github/ISSUE_TEMPLATE/bug-report.yml +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.4}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.4}/.github/ISSUE_TEMPLATE/feature-request.yml +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.4}/.gitignore +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.4}/AGENTS.md +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.4}/agent-workspace/README.md +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.4}/agent-workspace/agent_helpers.example.py +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.4}/agent-workspace/agent_helpers.py +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.4}/agent-workspace/zones/example_pg/scripts/sh_demo_crud.py +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.4}/agent-workspace/zones/example_pg/skills/sh_demo-schema.md +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.4}/agent-workspace/zones/meta/skills/pool.md +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.4}/agent-workspace/zones/meta/skills/workspace.md +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.4}/docs/connections-toml-example.toml +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.4}/docs/headless-notes.md +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.4}/docs/standalone-install.md +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.4}/interaction-skills/aggregations-advanced.md +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.4}/interaction-skills/aggregations.md +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.4}/interaction-skills/encoding-and-charset.md +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.4}/interaction-skills/joins-advanced.md +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.4}/interaction-skills/joins.md +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.4}/interaction-skills/large-result-sets.md +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.4}/interaction-skills/migrations.md +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.4}/interaction-skills/mysql/json-columns.md +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.4}/interaction-skills/pooling.md +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.4}/interaction-skills/postgres/btree-indexes.md +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.4}/interaction-skills/postgres/indexes-and-explain.md +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.4}/interaction-skills/postgres/json-columns.md +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.4}/interaction-skills/postgres/migrations.md +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.4}/interaction-skills/postgres/plan-reading.md +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.4}/interaction-skills/postgres/slow-queries-joins.md +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.4}/interaction-skills/postgres/specialized-indexes.md +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.4}/interaction-skills/postgres/table-optimization.md +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.4}/interaction-skills/recursive-ctes-advanced.md +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.4}/interaction-skills/recursive-ctes.md +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.4}/interaction-skills/save-run-cycle.md +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.4}/interaction-skills/schema-introspection.md +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.4}/interaction-skills/ssh/auth-and-tunnels.md +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.4}/interaction-skills/ssh/docker-via-ssh.md +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.4}/interaction-skills/ssh/ssh.md +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.4}/interaction-skills/timeouts-and-cancellation.md +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.4}/interaction-skills/window-functions-advanced.md +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.4}/interaction-skills/window-functions.md +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.4}/interaction-skills/zone-skill-auto-surface.md +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.4}/sql-harness +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.4}/src/sql_harness/__init__.py +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.4}/src/sql_harness/agent_loader.py +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.4}/src/sql_harness/paths.py +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.4}/tests/__init__.py +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.4}/tests/conftest.py +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.4}/tests/integration/__init__.py +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.4}/tests/integration/test_mysql.py +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.4}/tests/integration/test_pg_optimization.py +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.4}/tests/integration/test_pgexercises_practice.py +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.4}/tests/integration/test_postgres.py +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.4}/tests/integration/test_ssh.py +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.4}/tests/unit/__init__.py +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.4}/tests/unit/test_agent_loader.py +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.4}/tests/unit/test_analytics.py +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.4}/tests/unit/test_config.py +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.4}/tests/unit/test_helpers.py +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.4}/tests/unit/test_manager.py +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.4}/tests/unit/test_paths.py +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.4}/tests/unit/test_ssh.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: sql-harness
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.4
|
|
4
4
|
Summary: Single-process SQL + SSH CLI for LLM agents. PostgreSQL/MySQL/SSH via SQLAlchemy + paramiko. Plaintext credentials in one TOML file. Helpers auto-injected into the heredoc namespace.
|
|
5
5
|
Project-URL: Source, https://github.com/zhaoliuxue/much_bigpy/tree/master/lab/sql_harness
|
|
6
6
|
Project-URL: Issues, https://github.com/zhaoliuxue/much_bigpy/issues
|
|
@@ -37,7 +37,7 @@ uv sync
|
|
|
37
37
|
uv run sql-harness init
|
|
38
38
|
|
|
39
39
|
# 3. Add a connection
|
|
40
|
-
uv run sql-harness add local_pg --driver postgres --url 'postgresql://postgres:postgres@localhost:5432/postgres'
|
|
40
|
+
uv run sql-harness add --name local_pg --driver postgres --url 'postgresql://postgres:postgres@localhost:5432/postgres'
|
|
41
41
|
|
|
42
42
|
# 4. Test it
|
|
43
43
|
uv run sql-harness test local_pg
|
|
@@ -7,7 +7,7 @@ description: "Always use sql-harness for SQL, SSH, and database operations — q
|
|
|
7
7
|
|
|
8
8
|
A single-process SQL + SSH CLI for agents. 5 drivers (`postgres`, `mysql`, `sqlite`, `redis`, `ssh`) share one heredoc namespace. Connections live in plaintext in one TOML file.
|
|
9
9
|
|
|
10
|
-
For setup, read `install.md
|
|
10
|
+
For setup, read `install.md` (shipped next to this file; in a source checkout it's `docs/install.md`). For stuck-point mechanics, see `interaction-skills/` (cross-DB) and `interaction-skills/postgres/` (PG depth).
|
|
11
11
|
|
|
12
12
|
## Reach for sql-harness when...
|
|
13
13
|
|
|
@@ -26,9 +26,9 @@ For setup, read `install.md`. For stuck-point mechanics, see `interaction-skills
|
|
|
26
26
|
```bash
|
|
27
27
|
sql-harness --help
|
|
28
28
|
sql-harness list
|
|
29
|
-
sql-harness add <name> --driver postgres --url 'postgresql://user:pw@host/db'
|
|
30
|
-
sql-harness add <name> --driver mysql
|
|
31
|
-
sql-harness add <name> --driver ssh
|
|
29
|
+
sql-harness add --name <name> --driver postgres --url 'postgresql://user:pw@host/db'
|
|
30
|
+
sql-harness add --name <name> --driver mysql --url 'mysql+pymysql://user:pw@host/db'
|
|
31
|
+
sql-harness add --name <name> --driver ssh --url 'ssh://user@host:22?key=/path/id_ed25519'
|
|
32
32
|
sql-harness test <name>
|
|
33
33
|
|
|
34
34
|
sql-harness <<'PY'
|
|
@@ -71,7 +71,7 @@ Standard SQLAlchemy + PyMySQL. JSON syntax differs from PG — see `interaction-
|
|
|
71
71
|
|
|
72
72
|
### SQLite (`sqlite:///path.db`)
|
|
73
73
|
|
|
74
|
-
Local dev / tests. All standard helpers work. Use `sql-harness add --driver sqlite --url 'sqlite:///<absolute-path>'` (SQLite needs absolute path).
|
|
74
|
+
Local dev / tests. All standard helpers work. Use `sql-harness add --name <name> --driver sqlite --url 'sqlite:///<absolute-path>'` (SQLite needs absolute path).
|
|
75
75
|
|
|
76
76
|
### Redis (`redis://host:port`)
|
|
77
77
|
|
|
@@ -103,7 +103,7 @@ See `interaction-skills/ssh/` for: `ssh.md` (helpers + CLI + gotchas), `docker-v
|
|
|
103
103
|
| `list_tables(schema=None)` | `list[str]` | Schema recon — always start here |
|
|
104
104
|
| `describe(table, schema=None)` | `list[dict]` | Columns + types — before writing SQL |
|
|
105
105
|
| `table(name, schema=None, limit=None)` | `list[dict]` | Quick peek: `SELECT * FROM x LIMIT n` |
|
|
106
|
-
| `with_transaction()` | context manager yielding `Connection` | ≥ 2 statements / DDL
|
|
106
|
+
| `with_transaction()` | context manager yielding `Connection` | ≥ 2 DML statements / atomicity (DDL: PG+SQLite only) |
|
|
107
107
|
| `run_sql_file(path)` | `list[dict]` | Multi-statement `.sql`, one transaction |
|
|
108
108
|
| `server_version()` | `str` | Round-trip liveness check |
|
|
109
109
|
| `connection_info()` | `dict` | Current workspace metadata |
|
|
@@ -159,7 +159,8 @@ Reached via `apply_skill("pool")` etc. from any active zone — `zones/meta/skil
|
|
|
159
159
|
|
|
160
160
|
- **Start with `list_tables` + `describe`, not `query`.** Before writing SQL on an unknown DB, run `list_tables(schema=None)` then `describe("table", schema=None)` to learn the columns. Skips the trial-and-error SELECT cycle.
|
|
161
161
|
- **Set `BH_SQL_ZONE_SKILLS=1` once** so every `use_workspace()` surfaces that zone's per-DSN skill docs as a hint list (10 filenames max). Then `apply_skill(name)` reads the body when relevant.
|
|
162
|
-
- **Default to `with_transaction()`** for any write with ≥ 2 statements (insert + update,
|
|
162
|
+
- **Default to `with_transaction()`** for any write with ≥ 2 statements (insert + update, update + delete, …). Automatic rollback on exception — **for DML**. DDL is a different story: PostgreSQL and SQLite roll back `CREATE`/`DROP`/`ALTER` inside a transaction, **MySQL does not** (it implicitly commits), so a `drop + create` migration that fails halfway leaves you with neither table. Run DDL migrations with a backup or a reversible plan, not on the strength of the transaction. See `interaction-skills/migrations.md`.
|
|
163
|
+
- **Transactions are only real on a transactional engine.** A MySQL table created on a server whose `@@default_storage_engine` is not InnoDB (e.g. MyISAM) ignores `ROLLBACK` *silently* — the write sticks and nothing warns you. Check with `SELECT @@default_storage_engine`, and write `ENGINE=InnoDB` explicitly if you need rollback.
|
|
163
164
|
- **For PG performance tasks, reach for the 8 PG-only helpers first** (`explain_analyze`, `slow_queries`, `unused_indexes`, `seq_scan_heavy`, `table_stats`, `index_usage_stats`, `missing_indexes_hint`). Don't hand-parse `pg_stat_*` SQL.
|
|
164
165
|
- **For remote tasks, reach for SSH workspace first.** Don't reach for raw `subprocess` + ssh-key files in agent code — `ssh_exec("systemctl restart myapp")` is one line.
|
|
165
166
|
- **For "insert returning the new id"**: psycopg supports `INSERT … RETURNING id`; MySQL has no `RETURNING` — use `execute(...)` and read `result.lastrowid`.
|
|
@@ -189,6 +190,29 @@ Reached via `apply_skill("pool")` etc. from any active zone — `zones/meta/skil
|
|
|
189
190
|
- For tables > 10k rows, use `engine.connect().execution_options(stream_results=True)` and iterate manually — `query()` loads everything into memory.
|
|
190
191
|
- SSH SFTP subsystem may be disabled on the remote host. `ssh_upload/ssh_download` raise `RuntimeError("SFTP subsystem not available")`; fall back to `tar -czf - | ssh host tar -xzf -` via `ssh_exec`.
|
|
191
192
|
- Default `ssh_exec` timeout is 30 s. Long-running commands (e.g. `pg_dump`, `tar czf`) need explicit `timeout=`.
|
|
193
|
+
- **Long output is spilled to a file, not printed.** A failed run whose traceback is long, and any `ssh exec` / `ssh run-script` output past a few KB, are written to `$BH_SQL_HOME/tmp/output/` and replaced by a pointer line naming the path, line count and size. That is deliberate — read the file when you actually need it. `BH_SQL_TRACEBACK=1` forces the raw traceback to stderr instead. The full traceback always also lands in `$BH_SQL_HOME/sql-harness.log` (NDJSON), whatever the terminal shows.
|
|
194
|
+
|
|
195
|
+
## Read-only connections (`read_only = true`)
|
|
196
|
+
|
|
197
|
+
For a sensitive database, mark the connection read-only. Two rails are installed at the driver layer, both attached to the **engine** rather than to `query()`/`execute()` — so `conn.execute()`, `run_sql_file()` and any path that skips the helpers are covered too.
|
|
198
|
+
|
|
199
|
+
```toml
|
|
200
|
+
[[connections]]
|
|
201
|
+
name = "prod_ro"
|
|
202
|
+
driver = "mysql"
|
|
203
|
+
url = "mysql+pymysql://ro_user@host:3306/proddb"
|
|
204
|
+
read_only = true
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
```bash
|
|
208
|
+
sql-harness add --name prod_ro --driver mysql --url '...' --read-only
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
What it refuses: anything whose verb is not `SELECT`/`SHOW`/`DESCRIBE`/`EXPLAIN`/`TABLE`/`VALUES`/`PRAGMA`, plus transaction control. **Unrecognised statements are refused, not permitted** — that is why it is an allow-list: a deny-list misses `CALL`, `DO`, `LOAD DATA`, `HANDLER` and every future statement type. It also handles the dodges a naive keyword match falls for: `/*!50000 DROP */` (MySQL executes those), `SELECT 1; DROP TABLE t`, `WITH x AS (...) DELETE`, `EXPLAIN ANALYZE DELETE` (PostgreSQL executes it), and `#` — which is a comment in MySQL but the XOR operator in PostgreSQL, so treating it as a comment there would hide a write. On MySQL it additionally refuses `INTO OUTFILE`/`INTO DUMPFILE`/`FOR UPDATE`/`LOCK IN SHARE MODE`; on PostgreSQL, `SELECT ... INTO` (creates a table) and the `FOR ...` lock variants.
|
|
212
|
+
|
|
213
|
+
Where the backend has a session setting, it is applied on **every new pooled connection**: `SET SESSION TRANSACTION READ ONLY` (MySQL) / `SET SESSION CHARACTERISTICS AS TRANSACTION READ ONLY` (PostgreSQL). That rail is enforced by the server, so it catches anything that slips past the statement guard (e.g. a stored procedure).
|
|
214
|
+
|
|
215
|
+
**This is a guard rail, not a security boundary.** Both rails are escapable from the same session — a caller can run `SET SESSION TRANSACTION READ WRITE` and carry on, and any client-side check is pattern matching rather than a parser. The only hard boundary is the database account: use `GRANT SELECT ON db.* TO ...` for a database you genuinely must not write to, and treat everything above as defence in depth on top of it. SQLite has no session rail at all; for a file database the real boundary is the URL (`sqlite:///file:path?mode=ro&uri=true`). SSH workspaces ignore the flag — a remote shell is not a statement stream.
|
|
192
216
|
|
|
193
217
|
## Domain / table skills
|
|
194
218
|
|
|
@@ -12,10 +12,12 @@ Core code lives in `lab/sql_harness/src/sql_harness/`:
|
|
|
12
12
|
- `config.py` — TOML schema + ConnectionConfig / ConnectionsConfig dataclasses
|
|
13
13
|
- `paths.py` — XDG-style state directory resolution
|
|
14
14
|
- `drivers/` — backend abstractions (postgres, mysql, redis stub, sqlite for tests)
|
|
15
|
+
- `drivers/readonly.py` — read-only statement guard shared by the SQL drivers: comment/literal-stripping, allow-list verb check, and `install_guard()` which attaches both rails to an engine. Read its module docstring before relying on it — it is a guard rail, not a security boundary
|
|
15
16
|
- `manager.py` — SqlHarness class: holds engines, workspaces, skills registry
|
|
16
17
|
- `helpers.py` — heredoc helpers (auto-imported)
|
|
17
18
|
- `agent_loader.py` — loads `$BH_SQL_AGENT_WORKSPACE/agent_helpers.py`
|
|
18
19
|
- `analytics.py` — event bus (observer) + JSON stats + NDJSON execution log: CLI call counts, doc-read tracking, command/error history; `BH_SQL_ANALYTICS` (default ON), view with `sql-harness stats`
|
|
20
|
+
- `output.py` — terminal output policy: UTF-8 stream setup, long-output spill to `$BH_SQL_HOME/tmp/output/`, concise error rendering; `BH_SQL_TRACEBACK=1` forces raw tracebacks
|
|
19
21
|
- `run.py` — heredoc entry; delegates to CLI otherwise
|
|
20
22
|
- `cli.py` — argparse subparser CLI (18 subcommands)
|
|
21
23
|
|
|
@@ -14,7 +14,7 @@ uv sync
|
|
|
14
14
|
uv run sql-harness init
|
|
15
15
|
|
|
16
16
|
# 3. Add a connection
|
|
17
|
-
uv run sql-harness add local_pg --driver postgres --url 'postgresql://postgres:postgres@localhost:5432/postgres'
|
|
17
|
+
uv run sql-harness add --name local_pg --driver postgres --url 'postgresql://postgres:postgres@localhost:5432/postgres'
|
|
18
18
|
|
|
19
19
|
# 4. Test it
|
|
20
20
|
uv run sql-harness test local_pg
|
|
@@ -27,7 +27,8 @@ Writes `~/.config/sql-harness/connections.toml` (or `$BH_SQL_HOME/connections.to
|
|
|
27
27
|
## 3. Add your first connection
|
|
28
28
|
|
|
29
29
|
```bash
|
|
30
|
-
uv run sql-harness add
|
|
30
|
+
uv run sql-harness add \
|
|
31
|
+
--name my_pg \
|
|
31
32
|
--driver postgres \
|
|
32
33
|
--url 'postgresql://user:pw@localhost:5432/db' \
|
|
33
34
|
--description "My dev DB"
|
|
@@ -89,7 +90,9 @@ password = "${env:IOT_SSH_PASSWORD}" # standalone secret — keeps the URL clea
|
|
|
89
90
|
|
|
90
91
|
`${env:VAR}` placeholders inside `url` and `password` are expanded at load time from the process environment — keep prod secrets out of the file.
|
|
91
92
|
|
|
92
|
-
Optional per-connection keys: `description`, `password`, `pool_size`, `pool_recycle`, `pre_ping`, `echo`, `application_name`.
|
|
93
|
+
Optional per-connection keys: `description`, `password`, `read_only`, `pool_size`, `pool_recycle`, `pre_ping`, `echo`, `application_name`.
|
|
94
|
+
|
|
95
|
+
`read_only = true` refuses writes on that connection — a statement allow-list on every statement the engine sends, plus the server-side session read-only setting where the backend has one. Use it for sensitive databases, but note it is a **guard rail, not a security boundary**: the session setting is escapable from the same session, and any client-side check is pattern matching. Pair it with a `SELECT`-only database account, which is the actual boundary. See `SKILL.md` → "Read-only connections".
|
|
93
96
|
|
|
94
97
|
SSH password precedence (highest first): password embedded in `url` > the `password` key > `$BH_SSH_PASSWORD`. A connection that sets a password uses password auth exclusively — local `~/.ssh` keys are not consulted (see `interaction-skills/ssh/auth-and-tunnels.md`).
|
|
95
98
|
|
|
@@ -17,14 +17,26 @@ hand-synced copies. `pyproject.toml` `[tool.hatch.build.targets.wheel.force-incl
|
|
|
17
17
|
maps the repo docs into the wheel:
|
|
18
18
|
|
|
19
19
|
- `SKILL.md` → `sql_harness/_skills/SKILL.md`
|
|
20
|
+
- `docs/install.md` → `sql_harness/_skills/install.md`
|
|
20
21
|
- `interaction-skills` → `sql_harness/_skills/interaction-skills`
|
|
21
22
|
- `agent-workspace/zones/meta/skills` → `sql_harness/_skills/zones-meta-skills`
|
|
22
23
|
|
|
23
24
|
The root `SKILL.md` is the **single source of truth** (v0.3.0 consolidation —
|
|
24
25
|
the old `skills/sql-harness/SKILL.md` and `src/sql_harness/SKILL.md` copies
|
|
25
|
-
were removed
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
were removed; v0.3.4 removed the last hand-synced copy,
|
|
27
|
+
`skills/sql-harness/references/install.md`). After editing it, just
|
|
28
|
+
`uv build` again. `sql-harness skill` reads `sql_harness/_skills/SKILL.md`
|
|
29
|
+
from the installed package and falls back to the repo-root `SKILL.md` in a
|
|
30
|
+
source checkout.
|
|
31
|
+
|
|
32
|
+
`SKILL.md` references `install.md` as a sibling file — that only resolves
|
|
33
|
+
because `docs/install.md` is force-included into the bundle as
|
|
34
|
+
`_skills/install.md`. Dropping it from the force-include list breaks the
|
|
35
|
+
reference for every `skill install` user.
|
|
36
|
+
|
|
37
|
+
`skill install` needs a real (non-editable) wheel install to work: it reads
|
|
38
|
+
`sql_harness/_skills/`, which only exists after `uv build`. In a bare source
|
|
39
|
+
checkout `_bundled_skills_root()` returns `None` and the command exits 2.
|
|
28
40
|
|
|
29
41
|
## Build outputs
|
|
30
42
|
|
|
@@ -44,7 +56,7 @@ The wheel contains the package modules + the `_skills/` doc bundle
|
|
|
44
56
|
```bash
|
|
45
57
|
# standalone venv
|
|
46
58
|
uv sync
|
|
47
|
-
uv run pytest tests/unit -q #
|
|
59
|
+
uv run pytest tests/unit -q # 133 tests must pass
|
|
48
60
|
uv run sql-harness --help # CLI works
|
|
49
61
|
uv run sql-harness skill # SKILL.md emit works
|
|
50
62
|
```
|
|
@@ -55,3 +55,4 @@ for attempt in range(3):
|
|
|
55
55
|
- `text("SELECT ...").execution_options(...)` is per-statement; transaction-level options belong on the `SET` line.
|
|
56
56
|
- Nested `with_transaction()` will create a SAVEPOINT (PostgreSQL) or fail (MySQL). Don't nest.
|
|
57
57
|
- A failed statement inside a transaction does NOT auto-rollback the whole transaction in some dialects — call `conn.rollback()` explicitly if you've swallowed the exception.
|
|
58
|
+
- **A non-transactional table ignores `ROLLBACK` silently.** On MySQL, check `SELECT @@default_storage_engine` before trusting a rollback: if it is not InnoDB, any table created without an explicit `ENGINE=InnoDB` accepts `BEGIN`/`ROLLBACK` and discards them — the row is still there afterwards and nothing errors. Reach for `with_transaction()` on such tables and you get the *appearance* of atomicity with none of the substance.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "sql-harness"
|
|
3
|
-
version = "0.3.
|
|
3
|
+
version = "0.3.4"
|
|
4
4
|
description = "Single-process SQL + SSH CLI for LLM agents. PostgreSQL/MySQL/SSH via SQLAlchemy + paramiko. Plaintext credentials in one TOML file. Helpers auto-injected into the heredoc namespace."
|
|
5
5
|
readme = "docs/README.md"
|
|
6
6
|
license = { text = "MIT" }
|
|
@@ -50,6 +50,7 @@ packages = ["src/sql_harness"]
|
|
|
50
50
|
# shipping its SKILL.md in the wheel, but extended to the whole doc bundle.
|
|
51
51
|
# Paths are relative to this pyproject.toml (lab/sql_harness/).
|
|
52
52
|
"SKILL.md" = "sql_harness/_skills/SKILL.md"
|
|
53
|
+
"docs/install.md" = "sql_harness/_skills/install.md"
|
|
53
54
|
"interaction-skills" = "sql_harness/_skills/interaction-skills"
|
|
54
55
|
"agent-workspace/zones/meta/skills" = "sql_harness/_skills/zones-meta-skills"
|
|
55
56
|
|
|
@@ -48,8 +48,12 @@ CLI_INVOKED = "cli.invoked" # payload: {"subcommand": str}
|
|
|
48
48
|
DOC_READ = "doc.read" # payload: {"doc": str, "via": str}
|
|
49
49
|
COMMAND_EXECUTED = "command.executed"
|
|
50
50
|
# payload: {"argv": list[str], "code": str|None, "ok": bool,
|
|
51
|
-
# "exit_code": int, "error": str|None}
|
|
52
|
-
#
|
|
51
|
+
# "exit_code": int, "error": str|None}
|
|
52
|
+
# argv = CLI args including the subcommand; [] in heredoc mode
|
|
53
|
+
# code = the heredoc body; None in CLI mode
|
|
54
|
+
# error = the FULL traceback when the run died on an uncaught exception,
|
|
55
|
+
# else None. stderr shows a compressed form (see output.py), so this
|
|
56
|
+
# log is the only complete channel — it keeps everything.
|
|
53
57
|
|
|
54
58
|
_OFF_VALUES = {"0", "off", "false", "no"}
|
|
55
59
|
DOC_READS_CAP = 500
|
|
@@ -32,6 +32,7 @@ import shutil
|
|
|
32
32
|
import subprocess
|
|
33
33
|
import sys
|
|
34
34
|
import time
|
|
35
|
+
import traceback
|
|
35
36
|
from importlib import resources as importlib_resources
|
|
36
37
|
from pathlib import Path
|
|
37
38
|
from typing import Callable
|
|
@@ -47,6 +48,7 @@ from .config import (
|
|
|
47
48
|
save as save_config,
|
|
48
49
|
)
|
|
49
50
|
from .manager import SqlHarness
|
|
51
|
+
from .output import render_exception, set_utf8_streams, spill
|
|
50
52
|
from .paths import (
|
|
51
53
|
config_file,
|
|
52
54
|
ensure_private_dir,
|
|
@@ -84,6 +86,7 @@ def cmd_list(_args, harness: SqlHarness) -> int:
|
|
|
84
86
|
"url": c.masked_url(),
|
|
85
87
|
"description": c.description,
|
|
86
88
|
"password_set": bool(c.password),
|
|
89
|
+
"read_only": c.read_only,
|
|
87
90
|
"pool_size": c.pool.size,
|
|
88
91
|
})
|
|
89
92
|
_emit({"default_workspace": cfg.default_workspace, "connections": rows})
|
|
@@ -113,6 +116,7 @@ def cmd_add(args, harness: SqlHarness) -> int:
|
|
|
113
116
|
|
|
114
117
|
description = args.description or ""
|
|
115
118
|
password = getattr(args, "password", None) or ""
|
|
119
|
+
read_only = bool(getattr(args, "read_only", False))
|
|
116
120
|
pool = PoolConfig(size=args.pool_size or cfg.pool_defaults.size)
|
|
117
121
|
|
|
118
122
|
cfg.connections.append(
|
|
@@ -122,11 +126,18 @@ def cmd_add(args, harness: SqlHarness) -> int:
|
|
|
122
126
|
url=url,
|
|
123
127
|
description=description,
|
|
124
128
|
password=password,
|
|
129
|
+
read_only=read_only,
|
|
125
130
|
pool=pool,
|
|
126
131
|
)
|
|
127
132
|
)
|
|
128
133
|
save_config(cfg, config_file())
|
|
129
|
-
_emit({
|
|
134
|
+
_emit({
|
|
135
|
+
"added": name,
|
|
136
|
+
"driver": driver,
|
|
137
|
+
"url": _mask(url),
|
|
138
|
+
"password_set": bool(password),
|
|
139
|
+
"read_only": read_only,
|
|
140
|
+
})
|
|
130
141
|
return 0
|
|
131
142
|
|
|
132
143
|
|
|
@@ -176,6 +187,7 @@ def cmd_show(args, harness: SqlHarness) -> int:
|
|
|
176
187
|
"description": c.description,
|
|
177
188
|
"password_set": bool(c.password),
|
|
178
189
|
"password": c.masked_password(),
|
|
190
|
+
"read_only": c.read_only,
|
|
179
191
|
"pool": {
|
|
180
192
|
"size": c.pool.size,
|
|
181
193
|
"recycle": c.pool.recycle,
|
|
@@ -296,17 +308,15 @@ def cmd_skill(args, _harness: SqlHarness) -> int:
|
|
|
296
308
|
if body is None:
|
|
297
309
|
print(
|
|
298
310
|
"error: packaged SKILL.md not found (expected at "
|
|
299
|
-
"
|
|
311
|
+
"sql_harness/_skills/SKILL.md, or the repo-root SKILL.md "
|
|
312
|
+
"in a source checkout)",
|
|
300
313
|
file=sys.stderr,
|
|
301
314
|
)
|
|
302
315
|
return 2
|
|
303
|
-
#
|
|
304
|
-
#
|
|
316
|
+
# stdout is already UTF-8 (set_utf8_streams() runs at the top of main()),
|
|
317
|
+
# which is what lets non-ASCII skill bodies emit cleanly on Windows —
|
|
318
|
+
# the default GBK/CP936 codepage can't encode symbols like ⇄.
|
|
305
319
|
out = sys.stdout
|
|
306
|
-
try:
|
|
307
|
-
out.reconfigure(encoding="utf-8", errors="replace")
|
|
308
|
-
except (AttributeError, OSError):
|
|
309
|
-
pass
|
|
310
320
|
out.write(body)
|
|
311
321
|
if not body.endswith("\n"):
|
|
312
322
|
out.write("\n")
|
|
@@ -594,13 +604,9 @@ def cmd_save(args, _harness: SqlHarness) -> int:
|
|
|
594
604
|
conn, code = _require_connection_or_fail(args)
|
|
595
605
|
if code:
|
|
596
606
|
return code
|
|
597
|
-
#
|
|
598
|
-
# mangles non-ASCII bytes into
|
|
599
|
-
# Mirrors run.py's heredoc
|
|
600
|
-
try:
|
|
601
|
-
sys.stdin.reconfigure(encoding="utf-8", errors="replace")
|
|
602
|
-
except (AttributeError, OSError):
|
|
603
|
-
pass
|
|
607
|
+
# stdin is already UTF-8 (set_utf8_streams() runs at the top of main()):
|
|
608
|
+
# on Windows the default GBK/CP936 locale mangles non-ASCII bytes into
|
|
609
|
+
# surrogates that write_text rejects. Mirrors run.py's heredoc mode.
|
|
604
610
|
body = sys.stdin.read()
|
|
605
611
|
if not body.strip():
|
|
606
612
|
print("error: stdin is empty; nothing to save", file=sys.stderr)
|
|
@@ -668,11 +674,11 @@ def cmd_run(args, harness: SqlHarness) -> int:
|
|
|
668
674
|
return 0
|
|
669
675
|
except SystemExit as e:
|
|
670
676
|
return int(e.code) if e.code is not None else 0
|
|
671
|
-
|
|
672
|
-
|
|
677
|
+
# Real failures propagate to main(), which stores the full traceback in the
|
|
678
|
+
# analytics log and renders the short form to stderr. Catching here would
|
|
679
|
+
# mean a saved-script crash left no traceback anywhere — the frames already
|
|
680
|
+
# carry the script's real path, so the report names the failing line.
|
|
673
681
|
|
|
674
|
-
traceback.print_exc()
|
|
675
|
-
return 1
|
|
676
682
|
|
|
677
683
|
|
|
678
684
|
def cmd_scripts(args, _harness: SqlHarness) -> int:
|
|
@@ -853,9 +859,11 @@ def cmd_ssh(args, harness: SqlHarness) -> int:
|
|
|
853
859
|
|
|
854
860
|
if sub == "exec":
|
|
855
861
|
result = _h.ssh_exec(args.command, timeout=args.timeout)
|
|
856
|
-
|
|
862
|
+
# Remote output length is unbounded (cat of a big file, a chatty log);
|
|
863
|
+
# spill it so a long command doesn't flood the caller's context.
|
|
864
|
+
sys.stdout.write(spill(result["stdout"], "ssh-stdout"))
|
|
857
865
|
if result["stderr"]:
|
|
858
|
-
sys.stderr.write(result["stderr"])
|
|
866
|
+
sys.stderr.write(spill(result["stderr"], "ssh-stderr"))
|
|
859
867
|
return 0 if result["ok"] else result["exit_code"]
|
|
860
868
|
|
|
861
869
|
if sub == "upload":
|
|
@@ -870,9 +878,9 @@ def cmd_ssh(args, harness: SqlHarness) -> int:
|
|
|
870
878
|
|
|
871
879
|
if sub == "run-script":
|
|
872
880
|
result = _h.ssh_run_script(args.local, args.remote_dir, args.interpreter)
|
|
873
|
-
sys.stdout.write(result["stdout"])
|
|
881
|
+
sys.stdout.write(spill(result["stdout"], "ssh-stdout"))
|
|
874
882
|
if result["stderr"]:
|
|
875
|
-
sys.stderr.write(result["stderr"])
|
|
883
|
+
sys.stderr.write(spill(result["stderr"], "ssh-stderr"))
|
|
876
884
|
return 0 if result["ok"] else result["exit_code"]
|
|
877
885
|
|
|
878
886
|
if sub == "info":
|
|
@@ -906,6 +914,11 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
906
914
|
"--password",
|
|
907
915
|
help="standalone secret (SSH password); keeps the URL credential-free",
|
|
908
916
|
)
|
|
917
|
+
p_add.add_argument(
|
|
918
|
+
"--read-only",
|
|
919
|
+
action="store_true",
|
|
920
|
+
help="refuse writes on this connection (statement guard + session read-only)",
|
|
921
|
+
)
|
|
909
922
|
p_add.add_argument("--pool-size", type=int)
|
|
910
923
|
|
|
911
924
|
p_edit = sub.add_parser("edit", help="edit connections.toml in $EDITOR")
|
|
@@ -1006,6 +1019,12 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
1006
1019
|
|
|
1007
1020
|
|
|
1008
1021
|
def main(argv: list[str]) -> int:
|
|
1022
|
+
# Before anything can print: force UTF-8 on the standard streams so data
|
|
1023
|
+
# that legitimately contains non-GBK characters (Chinese descriptions in
|
|
1024
|
+
# connections.toml, CJK paths, remote host output) reports instead of
|
|
1025
|
+
# crashing the process that was trying to report it.
|
|
1026
|
+
set_utf8_streams()
|
|
1027
|
+
|
|
1009
1028
|
parser = _build_parser()
|
|
1010
1029
|
args = parser.parse_args(argv)
|
|
1011
1030
|
if not args.cmd:
|
|
@@ -1044,12 +1063,15 @@ def main(argv: list[str]) -> int:
|
|
|
1044
1063
|
{"argv": argv, "code": None, "ok": rc == 0, "exit_code": rc, "error": None},
|
|
1045
1064
|
)
|
|
1046
1065
|
return rc
|
|
1047
|
-
except Exception as
|
|
1066
|
+
except Exception as exc:
|
|
1067
|
+
# The analytics log keeps the full traceback (machine-readable record);
|
|
1068
|
+
# the terminal gets the short form. Same split as heredoc mode.
|
|
1048
1069
|
emit(
|
|
1049
1070
|
COMMAND_EXECUTED,
|
|
1050
1071
|
{"argv": argv, "code": None, "ok": False, "exit_code": 1,
|
|
1051
|
-
"error":
|
|
1072
|
+
"error": "".join(traceback.format_exception(exc))},
|
|
1052
1073
|
)
|
|
1053
|
-
|
|
1074
|
+
sys.stderr.write(render_exception(exc, label=f"cli-{args.cmd}"))
|
|
1075
|
+
return 1
|
|
1054
1076
|
finally:
|
|
1055
1077
|
harness.close_all()
|
|
@@ -69,6 +69,7 @@ class ConnectionConfig:
|
|
|
69
69
|
url: str
|
|
70
70
|
description: str = ""
|
|
71
71
|
password: str = "" # standalone secret (SSH password)
|
|
72
|
+
read_only: bool = False # refuse writes: see drivers/readonly.py
|
|
72
73
|
pool: PoolConfig = field(default_factory=PoolConfig)
|
|
73
74
|
application_name: str = "sql-harness"
|
|
74
75
|
|
|
@@ -161,6 +162,8 @@ def save(cfg: ConnectionsConfig, path: Path) -> None:
|
|
|
161
162
|
lines.append(f'description = "{_toml_str(c.description)}"')
|
|
162
163
|
if c.password:
|
|
163
164
|
lines.append(f'password = "{_toml_str(c.password)}"')
|
|
165
|
+
if c.read_only:
|
|
166
|
+
lines.append("read_only = true")
|
|
164
167
|
if c.pool.size != cfg.pool_defaults.size:
|
|
165
168
|
lines.append(f"pool_size = {c.pool.size}")
|
|
166
169
|
if c.pool.recycle != cfg.pool_defaults.recycle:
|
|
@@ -218,6 +221,7 @@ def _from_dict(raw: dict[str, Any]) -> ConnectionsConfig:
|
|
|
218
221
|
url=_expand_env(str(entry["url"])),
|
|
219
222
|
description=str(entry.get("description", "")),
|
|
220
223
|
password=_expand_env(str(entry.get("password", ""))),
|
|
224
|
+
read_only=bool(entry.get("read_only", False)),
|
|
221
225
|
pool=pool,
|
|
222
226
|
application_name=str(entry.get("application_name", "sql-harness")),
|
|
223
227
|
)
|
|
@@ -17,12 +17,24 @@ class Driver(Protocol):
|
|
|
17
17
|
|
|
18
18
|
name: str
|
|
19
19
|
|
|
20
|
-
def make_engine(
|
|
20
|
+
def make_engine(
|
|
21
|
+
self,
|
|
22
|
+
url: str,
|
|
23
|
+
pool: "PoolConfig",
|
|
24
|
+
password: str | None = None,
|
|
25
|
+
read_only: bool = False,
|
|
26
|
+
) -> Engine:
|
|
21
27
|
"""Create a SQLAlchemy Engine with the given pool config.
|
|
22
28
|
|
|
23
29
|
`password` is the connection's standalone secret (ConnectionConfig.password).
|
|
24
30
|
Drivers whose URL already carries credentials (SQL backends) ignore it;
|
|
25
31
|
SSH uses it when no password is embedded in the URL.
|
|
32
|
+
|
|
33
|
+
`read_only` installs that dialect's read-only rails (see
|
|
34
|
+
`drivers/readonly.py`): a statement allow-list on every
|
|
35
|
+
`before_cursor_execute`, plus the server-side session setting where the
|
|
36
|
+
backend has one. Drivers with no notion of statements (SSH, Redis)
|
|
37
|
+
accept it for API parity.
|
|
26
38
|
"""
|
|
27
39
|
|
|
28
40
|
def list_tables(self, engine: Engine, schema: str | None) -> list[str]:
|
|
@@ -4,17 +4,44 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
from sqlalchemy import Engine, create_engine, inspect, text
|
|
6
6
|
|
|
7
|
+
from .readonly import install_guard
|
|
8
|
+
|
|
9
|
+
# Dangerous even inside an otherwise read-only statement: file writes and row
|
|
10
|
+
# locks. MySQL's `#` is a comment and `\'` escapes inside strings.
|
|
11
|
+
_MYSQL_FORBIDDEN = frozenset(
|
|
12
|
+
{
|
|
13
|
+
"INTO OUTFILE",
|
|
14
|
+
"INTO DUMPFILE",
|
|
15
|
+
"FOR UPDATE",
|
|
16
|
+
"FOR SHARE",
|
|
17
|
+
"LOCK IN SHARE MODE",
|
|
18
|
+
}
|
|
19
|
+
)
|
|
20
|
+
|
|
7
21
|
|
|
8
22
|
class MysqlDriver:
|
|
9
23
|
name = "mysql"
|
|
10
24
|
|
|
11
|
-
def make_engine(
|
|
25
|
+
def make_engine(
|
|
26
|
+
self, url: str, pool, password: str | None = None, read_only: bool = False
|
|
27
|
+
) -> Engine:
|
|
12
28
|
# `password` is ignored: credentials live in the URL for SQL backends.
|
|
13
29
|
if not url.startswith("mysql+pymysql://"):
|
|
14
30
|
raise ValueError(
|
|
15
31
|
f"MySQL URL must start with mysql+pymysql:// (got {url[:30]!r})"
|
|
16
32
|
)
|
|
17
|
-
|
|
33
|
+
engine = create_engine(url, **pool.sqlalchemy_kwargs())
|
|
34
|
+
if read_only:
|
|
35
|
+
install_guard(
|
|
36
|
+
engine,
|
|
37
|
+
hash_comments=True,
|
|
38
|
+
backslash_escapes=True,
|
|
39
|
+
extra_forbidden=_MYSQL_FORBIDDEN,
|
|
40
|
+
# Server-enforced rail: MySQL then rejects DML *and* DDL with
|
|
41
|
+
# 1792 "Cannot execute statement in a READ ONLY transaction".
|
|
42
|
+
session_sql="SET SESSION TRANSACTION READ ONLY",
|
|
43
|
+
)
|
|
44
|
+
return engine
|
|
18
45
|
|
|
19
46
|
def list_tables(self, engine: Engine, schema: str | None) -> list[str]:
|
|
20
47
|
insp = inspect(engine)
|
|
@@ -4,11 +4,28 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
from sqlalchemy import Engine, create_engine, inspect, text
|
|
6
6
|
|
|
7
|
+
from .readonly import install_guard
|
|
8
|
+
|
|
9
|
+
# `SELECT ... INTO t` creates a table in PostgreSQL (unlike MySQL, where INTO
|
|
10
|
+
# means OUTFILE). Spaced so `FROM into_log` is not a false positive. The FOR
|
|
11
|
+
# variants take row locks.
|
|
12
|
+
_PG_FORBIDDEN = frozenset(
|
|
13
|
+
{
|
|
14
|
+
" INTO ",
|
|
15
|
+
"FOR UPDATE",
|
|
16
|
+
"FOR NO KEY UPDATE",
|
|
17
|
+
"FOR SHARE",
|
|
18
|
+
"FOR KEY SHARE",
|
|
19
|
+
}
|
|
20
|
+
)
|
|
21
|
+
|
|
7
22
|
|
|
8
23
|
class PostgresDriver:
|
|
9
24
|
name = "postgres"
|
|
10
25
|
|
|
11
|
-
def make_engine(
|
|
26
|
+
def make_engine(
|
|
27
|
+
self, url: str, pool, password: str | None = None, read_only: bool = False
|
|
28
|
+
) -> Engine:
|
|
12
29
|
# `password` is ignored: credentials live in the URL for SQL backends.
|
|
13
30
|
# Accept all three SQLAlchemy-recognized PostgreSQL URL prefixes:
|
|
14
31
|
# postgresql+psycopg:// (explicit dialect — preferred)
|
|
@@ -26,7 +43,20 @@ class PostgresDriver:
|
|
|
26
43
|
f"PostgreSQL URL must start with postgres:// or postgresql:// "
|
|
27
44
|
f"(got {url[:30]!r})"
|
|
28
45
|
)
|
|
29
|
-
|
|
46
|
+
engine = create_engine(url, **pool.sqlalchemy_kwargs())
|
|
47
|
+
if read_only:
|
|
48
|
+
install_guard(
|
|
49
|
+
engine,
|
|
50
|
+
# `#` is the bitwise-XOR operator in PostgreSQL, not a comment —
|
|
51
|
+
# treating it as one would swallow real code and hide a write.
|
|
52
|
+
hash_comments=False,
|
|
53
|
+
backslash_escapes=False,
|
|
54
|
+
extra_forbidden=_PG_FORBIDDEN,
|
|
55
|
+
# Server-enforced rail: writes then fail with
|
|
56
|
+
# "cannot execute ... in a read-only transaction".
|
|
57
|
+
session_sql="SET SESSION CHARACTERISTICS AS TRANSACTION READ ONLY",
|
|
58
|
+
)
|
|
59
|
+
return engine
|
|
30
60
|
|
|
31
61
|
def list_tables(self, engine: Engine, schema: str | None) -> list[str]:
|
|
32
62
|
insp = inspect(engine)
|