sql-harness 0.2.3__tar.gz → 0.3.1__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.2.3 → sql_harness-0.3.1}/PKG-INFO +1 -1
- {sql_harness-0.2.3 → sql_harness-0.3.1}/SKILL.md +18 -5
- sql_harness-0.3.1/interaction-skills/mysql/json-columns.md +43 -0
- sql_harness-0.3.1/interaction-skills/postgres/json-columns.md +46 -0
- sql_harness-0.3.1/interaction-skills/postgres/migrations.md +105 -0
- sql_harness-0.3.1/interaction-skills/ssh/auth-and-tunnels.md +145 -0
- {sql_harness-0.2.3/interaction-skills → sql_harness-0.3.1/interaction-skills/ssh}/docker-via-ssh.md +41 -15
- {sql_harness-0.2.3 → sql_harness-0.3.1}/pyproject.toml +1 -5
- {sql_harness-0.2.3 → sql_harness-0.3.1}/src/sql_harness/cli.py +15 -5
- {sql_harness-0.2.3 → sql_harness-0.3.1}/tests/unit/test_cli.py +54 -3
- {sql_harness-0.2.3 → sql_harness-0.3.1}/uv.lock +1 -1
- sql_harness-0.2.3/docker-services/coturn/README.md +0 -69
- sql_harness-0.2.3/docker-services/coturn/docker-compose.yml +0 -29
- sql_harness-0.2.3/docker-services/coturn/turnserver.conf +0 -36
- sql_harness-0.2.3/docker-services/mysql/docker-compose.yml +0 -35
- sql_harness-0.2.3/docker-services/redis/docker-compose.yml +0 -24
- sql_harness-0.2.3/interaction-skills/json-columns.md +0 -30
- sql_harness-0.2.3/practice/pgexercises/README.md +0 -63
- sql_harness-0.2.3/skills/sql-harness/SKILL.md +0 -1
- sql_harness-0.2.3/src/sql_harness/SKILL.md +0 -1
- {sql_harness-0.2.3 → sql_harness-0.3.1}/.claude-plugin/marketplace.json +0 -0
- {sql_harness-0.2.3 → sql_harness-0.3.1}/.claude-plugin/plugin.json +0 -0
- {sql_harness-0.2.3 → sql_harness-0.3.1}/.env.example +0 -0
- {sql_harness-0.2.3 → sql_harness-0.3.1}/.github/ISSUE_TEMPLATE/bug-report.yml +0 -0
- {sql_harness-0.2.3 → sql_harness-0.3.1}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {sql_harness-0.2.3 → sql_harness-0.3.1}/.github/ISSUE_TEMPLATE/feature-request.yml +0 -0
- {sql_harness-0.2.3 → sql_harness-0.3.1}/.gitignore +0 -0
- {sql_harness-0.2.3 → sql_harness-0.3.1}/AGENTS.md +0 -0
- {sql_harness-0.2.3 → sql_harness-0.3.1}/README.md +0 -0
- {sql_harness-0.2.3 → sql_harness-0.3.1}/agent-workspace/README.md +0 -0
- {sql_harness-0.2.3 → sql_harness-0.3.1}/agent-workspace/agent_helpers.example.py +0 -0
- {sql_harness-0.2.3 → sql_harness-0.3.1}/agent-workspace/agent_helpers.py +0 -0
- {sql_harness-0.2.3 → sql_harness-0.3.1}/agent-workspace/zones/example_pg/scripts/sh_demo_crud.py +0 -0
- {sql_harness-0.2.3 → sql_harness-0.3.1}/agent-workspace/zones/example_pg/skills/sh_demo-schema.md +0 -0
- {sql_harness-0.2.3 → sql_harness-0.3.1}/agent-workspace/zones/meta/skills/pool.md +0 -0
- {sql_harness-0.2.3 → sql_harness-0.3.1}/agent-workspace/zones/meta/skills/workspace.md +0 -0
- {sql_harness-0.2.3 → sql_harness-0.3.1}/docs/connections-toml-example.toml +0 -0
- {sql_harness-0.2.3 → sql_harness-0.3.1}/docs/headless-notes.md +0 -0
- {sql_harness-0.2.3 → sql_harness-0.3.1}/docs/maintainer.md +0 -0
- {sql_harness-0.2.3 → sql_harness-0.3.1}/docs/standalone-install.md +0 -0
- {sql_harness-0.2.3 → sql_harness-0.3.1}/install.md +0 -0
- {sql_harness-0.2.3/interaction-skills/references → sql_harness-0.3.1/interaction-skills}/aggregations-advanced.md +0 -0
- {sql_harness-0.2.3 → sql_harness-0.3.1}/interaction-skills/aggregations.md +0 -0
- {sql_harness-0.2.3 → sql_harness-0.3.1}/interaction-skills/encoding-and-charset.md +0 -0
- {sql_harness-0.2.3/interaction-skills/references → sql_harness-0.3.1/interaction-skills}/joins-advanced.md +0 -0
- {sql_harness-0.2.3 → sql_harness-0.3.1}/interaction-skills/joins.md +0 -0
- {sql_harness-0.2.3 → sql_harness-0.3.1}/interaction-skills/large-result-sets.md +0 -0
- {sql_harness-0.2.3 → sql_harness-0.3.1}/interaction-skills/migrations.md +0 -0
- {sql_harness-0.2.3 → sql_harness-0.3.1}/interaction-skills/pooling.md +0 -0
- {sql_harness-0.2.3 → sql_harness-0.3.1}/interaction-skills/postgres/btree-indexes.md +0 -0
- {sql_harness-0.2.3/interaction-skills → sql_harness-0.3.1/interaction-skills/postgres}/indexes-and-explain.md +0 -0
- {sql_harness-0.2.3 → sql_harness-0.3.1}/interaction-skills/postgres/plan-reading.md +0 -0
- {sql_harness-0.2.3 → sql_harness-0.3.1}/interaction-skills/postgres/slow-queries-joins.md +0 -0
- {sql_harness-0.2.3 → sql_harness-0.3.1}/interaction-skills/postgres/specialized-indexes.md +0 -0
- {sql_harness-0.2.3 → sql_harness-0.3.1}/interaction-skills/postgres/table-optimization.md +0 -0
- /sql_harness-0.2.3/interaction-skills/references/recursive-ctes-patterns.md → /sql_harness-0.3.1/interaction-skills/recursive-ctes-advanced.md +0 -0
- {sql_harness-0.2.3 → sql_harness-0.3.1}/interaction-skills/recursive-ctes.md +0 -0
- {sql_harness-0.2.3 → sql_harness-0.3.1}/interaction-skills/save-run-cycle.md +0 -0
- {sql_harness-0.2.3 → sql_harness-0.3.1}/interaction-skills/schema-introspection.md +0 -0
- {sql_harness-0.2.3/interaction-skills → sql_harness-0.3.1/interaction-skills/ssh}/ssh.md +0 -0
- {sql_harness-0.2.3 → sql_harness-0.3.1}/interaction-skills/timeouts-and-cancellation.md +0 -0
- {sql_harness-0.2.3 → sql_harness-0.3.1}/interaction-skills/transactions.md +0 -0
- /sql_harness-0.2.3/interaction-skills/references/window-functions-catalog.md → /sql_harness-0.3.1/interaction-skills/window-functions-advanced.md +0 -0
- {sql_harness-0.2.3 → sql_harness-0.3.1}/interaction-skills/window-functions.md +0 -0
- {sql_harness-0.2.3 → sql_harness-0.3.1}/interaction-skills/zone-skill-auto-surface.md +0 -0
- {sql_harness-0.2.3 → sql_harness-0.3.1}/skills/sql-harness/README.txt +0 -0
- {sql_harness-0.2.3 → sql_harness-0.3.1}/skills/sql-harness/references/install.md +0 -0
- {sql_harness-0.2.3 → sql_harness-0.3.1}/sql-harness +0 -0
- {sql_harness-0.2.3 → sql_harness-0.3.1}/src/sql_harness/__init__.py +0 -0
- {sql_harness-0.2.3 → sql_harness-0.3.1}/src/sql_harness/agent_loader.py +0 -0
- {sql_harness-0.2.3 → sql_harness-0.3.1}/src/sql_harness/config.py +0 -0
- {sql_harness-0.2.3 → sql_harness-0.3.1}/src/sql_harness/drivers/__init__.py +0 -0
- {sql_harness-0.2.3 → sql_harness-0.3.1}/src/sql_harness/drivers/mysql.py +0 -0
- {sql_harness-0.2.3 → sql_harness-0.3.1}/src/sql_harness/drivers/postgres.py +0 -0
- {sql_harness-0.2.3 → sql_harness-0.3.1}/src/sql_harness/drivers/redis.py +0 -0
- {sql_harness-0.2.3 → sql_harness-0.3.1}/src/sql_harness/drivers/sqlite.py +0 -0
- {sql_harness-0.2.3 → sql_harness-0.3.1}/src/sql_harness/drivers/ssh.py +0 -0
- {sql_harness-0.2.3 → sql_harness-0.3.1}/src/sql_harness/helpers.py +0 -0
- {sql_harness-0.2.3 → sql_harness-0.3.1}/src/sql_harness/manager.py +0 -0
- {sql_harness-0.2.3 → sql_harness-0.3.1}/src/sql_harness/paths.py +0 -0
- {sql_harness-0.2.3 → sql_harness-0.3.1}/src/sql_harness/run.py +0 -0
- {sql_harness-0.2.3 → sql_harness-0.3.1}/tests/__init__.py +0 -0
- {sql_harness-0.2.3 → sql_harness-0.3.1}/tests/conftest.py +0 -0
- {sql_harness-0.2.3 → sql_harness-0.3.1}/tests/integration/__init__.py +0 -0
- {sql_harness-0.2.3 → sql_harness-0.3.1}/tests/integration/test_mysql.py +0 -0
- {sql_harness-0.2.3 → sql_harness-0.3.1}/tests/integration/test_pg_optimization.py +0 -0
- {sql_harness-0.2.3 → sql_harness-0.3.1}/tests/integration/test_pgexercises_practice.py +0 -0
- {sql_harness-0.2.3 → sql_harness-0.3.1}/tests/integration/test_postgres.py +0 -0
- {sql_harness-0.2.3 → sql_harness-0.3.1}/tests/integration/test_ssh.py +0 -0
- {sql_harness-0.2.3 → sql_harness-0.3.1}/tests/unit/__init__.py +0 -0
- {sql_harness-0.2.3 → sql_harness-0.3.1}/tests/unit/test_agent_loader.py +0 -0
- {sql_harness-0.2.3 → sql_harness-0.3.1}/tests/unit/test_config.py +0 -0
- {sql_harness-0.2.3 → sql_harness-0.3.1}/tests/unit/test_helpers.py +0 -0
- {sql_harness-0.2.3 → sql_harness-0.3.1}/tests/unit/test_manager.py +0 -0
- {sql_harness-0.2.3 → sql_harness-0.3.1}/tests/unit/test_paths.py +0 -0
- {sql_harness-0.2.3 → sql_harness-0.3.1}/tests/unit/test_ssh.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sql-harness
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.1
|
|
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
|
|
@@ -33,31 +33,44 @@ PY
|
|
|
33
33
|
Stuck-point mechanics — one file per topic, bare filenames only:
|
|
34
34
|
|
|
35
35
|
- `interaction-skills/aggregations.md`
|
|
36
|
-
- `interaction-skills/
|
|
36
|
+
- `interaction-skills/aggregations-advanced.md`
|
|
37
37
|
- `interaction-skills/encoding-and-charset.md`
|
|
38
|
-
- `interaction-skills/indexes-and-explain.md`
|
|
39
38
|
- `interaction-skills/joins.md`
|
|
40
|
-
- `interaction-skills/
|
|
39
|
+
- `interaction-skills/joins-advanced.md`
|
|
41
40
|
- `interaction-skills/large-result-sets.md`
|
|
42
41
|
- `interaction-skills/migrations.md`
|
|
43
42
|
- `interaction-skills/pooling.md`
|
|
44
43
|
- `interaction-skills/recursive-ctes.md`
|
|
44
|
+
- `interaction-skills/recursive-ctes-advanced.md`
|
|
45
45
|
- `interaction-skills/save-run-cycle.md`
|
|
46
46
|
- `interaction-skills/schema-introspection.md`
|
|
47
|
-
- `interaction-skills/ssh.md`
|
|
48
47
|
- `interaction-skills/timeouts-and-cancellation.md`
|
|
49
48
|
- `interaction-skills/transactions.md`
|
|
50
49
|
- `interaction-skills/window-functions.md`
|
|
50
|
+
- `interaction-skills/window-functions-advanced.md`
|
|
51
51
|
- `interaction-skills/zone-skill-auto-surface.md`
|
|
52
52
|
|
|
53
|
-
PG
|
|
53
|
+
PG-specific depth — `interaction-skills/postgres/`:
|
|
54
54
|
|
|
55
55
|
- `interaction-skills/postgres/btree-indexes.md`
|
|
56
|
+
- `interaction-skills/postgres/indexes-and-explain.md`
|
|
57
|
+
- `interaction-skills/postgres/json-columns.md`
|
|
58
|
+
- `interaction-skills/postgres/migrations.md`
|
|
56
59
|
- `interaction-skills/postgres/plan-reading.md`
|
|
57
60
|
- `interaction-skills/postgres/slow-queries-joins.md`
|
|
58
61
|
- `interaction-skills/postgres/specialized-indexes.md`
|
|
59
62
|
- `interaction-skills/postgres/table-optimization.md`
|
|
60
63
|
|
|
64
|
+
MySQL-specific — `interaction-skills/mysql/`:
|
|
65
|
+
|
|
66
|
+
- `interaction-skills/mysql/json-columns.md`
|
|
67
|
+
|
|
68
|
+
SSH-specific — `interaction-skills/ssh/`:
|
|
69
|
+
|
|
70
|
+
- `interaction-skills/ssh/auth-and-tunnels.md`
|
|
71
|
+
- `interaction-skills/ssh/docker-via-ssh.md`
|
|
72
|
+
- `interaction-skills/ssh/ssh.md`
|
|
73
|
+
|
|
61
74
|
## Cross-DSN strategy skills
|
|
62
75
|
|
|
63
76
|
Read these when working across multiple connections, tuning pool behavior, or operating remote hosts. Reached via `apply_skill("pool")` etc. from any active zone — `zones/meta/skills/` is the passive fallback layer (the legacy global `agent-workspace/skills/` was collapsed into a regular zone so `apply_skill()` has a single shape: active zone or meta).
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# JSON columns — MySQL (`JSON`)
|
|
2
|
+
|
|
3
|
+
MySQL 5.7+ has a native `JSON` type. PyMySQL serializes Python `dict`/`list` to `JSON`.
|
|
4
|
+
|
|
5
|
+
## Insert
|
|
6
|
+
|
|
7
|
+
SQLAlchemy handles the conversion when passing a dict as a parameter:
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
execute("INSERT INTO events (data) VALUES (:d)", {"d": {"event": "click"}})
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Query
|
|
14
|
+
|
|
15
|
+
Use `JSON_EXTRACT(col, '$.path')` or the `->` / `->>` shorthand (MySQL 8.0+):
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
rows = query("SELECT data->>'$.event' AS event FROM events WHERE JSON_EXTRACT(data, '$.event') = :e",
|
|
19
|
+
{"e": "click"})
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Note `->>` in MySQL returns `TEXT` (unquoted). In PostgreSQL `->>` returns `text` of a jsonb path. The `'$.event'` path syntax is MySQL-specific.
|
|
23
|
+
|
|
24
|
+
## Indexing
|
|
25
|
+
|
|
26
|
+
MySQL 8.0+ supports functional indexes on JSON expressions:
|
|
27
|
+
|
|
28
|
+
```sql
|
|
29
|
+
CREATE INDEX idx_events_event ON events ((CAST(data->>'$.event' AS CHAR(64))));
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Pre-8.0: index a generated column instead.
|
|
33
|
+
|
|
34
|
+
## Gotchas
|
|
35
|
+
|
|
36
|
+
- `JSON_EXTRACT` returns JSON; `->>` returns the unquoted scalar. For comparisons, match the operator.
|
|
37
|
+
- `->>` shorthand requires MySQL 8.0+. On 5.7 use `JSON_UNQUOTE(JSON_EXTRACT(...))`.
|
|
38
|
+
- MySQL `JSON` does NOT auto-compress (unlike PG `jsonb` TOAST). Large docs blow up row size.
|
|
39
|
+
- Whitespace handling: MySQL normalizes on input but may reformat on output (PG normalizes the same way).
|
|
40
|
+
|
|
41
|
+
## See also
|
|
42
|
+
|
|
43
|
+
- `interaction-skills/postgres/json-columns.md` — PostgreSQL `jsonb` syntax (`@>`, `?`, no `$.path`)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# JSON columns — PostgreSQL (`jsonb`)
|
|
2
|
+
|
|
3
|
+
Use PostgreSQL's binary `jsonb` type (not `json` — `jsonb` is binary-comparable and indexable).
|
|
4
|
+
|
|
5
|
+
## Insert
|
|
6
|
+
|
|
7
|
+
`psycopg3` auto-serializes Python `dict`/`list` to `jsonb`:
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
execute("INSERT INTO events (data) VALUES (:d)", {"d": {"event": "click", "ts": 1234}})
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Query
|
|
14
|
+
|
|
15
|
+
Use `->` (returns `jsonb`), `->>` (returns `text`), and the containment operator `@>`:
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
rows = query("SELECT data->>'event' AS event FROM events WHERE data @> :filter",
|
|
19
|
+
{"filter": {"event": "click"}})
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Indexing
|
|
23
|
+
|
|
24
|
+
`jsonb` supports GIN indexes on the whole document (fast for `@>` / `?` / `?&` / `?|`):
|
|
25
|
+
|
|
26
|
+
```sql
|
|
27
|
+
CREATE INDEX idx_events_data ON events USING GIN (data);
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
For paths you hit often, a btree expression index:
|
|
31
|
+
|
|
32
|
+
```sql
|
|
33
|
+
CREATE INDEX idx_events_event ON events ((data->>'event'));
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Gotchas
|
|
37
|
+
|
|
38
|
+
- Prefer `jsonb` over `json` — `json` is text-stored, no index support beyond expression indexes, whitespace-preserved.
|
|
39
|
+
- Comparing `data = '{"event":"click"}'::jsonb` works (SQLAlchemy normalizes JSON whitespace).
|
|
40
|
+
- `JSONB` aggregates (`jsonb_agg`, `jsonb_object_agg`) exist; for plain text JSON use `string_agg`.
|
|
41
|
+
- GIN on `jsonb` is large — measure before adding to high-write tables.
|
|
42
|
+
|
|
43
|
+
## See also
|
|
44
|
+
|
|
45
|
+
- `interaction-skills/postgres/specialized-indexes.md` — GIN/GiST/BRIN tradeoffs
|
|
46
|
+
- `interaction-skills/mysql/json-columns.md` — MySQL JSON syntax (different operators)
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# PG Data Movement
|
|
2
|
+
|
|
3
|
+
Move data in/out/around PostgreSQL: logical dump + restore, bulk COPY, and
|
|
4
|
+
logical replication. (Schema migrations -- ALTER / CREATE under transaction
|
|
5
|
+
-- live in `migrations.md`; this file is for moving data across databases or
|
|
6
|
+
in/out of tables.)
|
|
7
|
+
|
|
8
|
+
## Detection
|
|
9
|
+
|
|
10
|
+
- **Move a whole database** to a new server -> `pg_dump` + `pg_restore`.
|
|
11
|
+
- **Bulk-load** a big table (>1M rows) -> `COPY` (10-100x faster than `INSERT`).
|
|
12
|
+
- **Live replication** of changes from one PG to another (zero-downtime
|
|
13
|
+
migration, read replica, aggregation) -> logical replication (publication
|
|
14
|
+
+ subscription).
|
|
15
|
+
- **One-shot export** for analysis / downstream pipeline -> `COPY` /
|
|
16
|
+
`psql \copy`.
|
|
17
|
+
|
|
18
|
+
## Approach
|
|
19
|
+
|
|
20
|
+
### 1. Logical dump + restore (server-side CLI)
|
|
21
|
+
|
|
22
|
+
`pg_dump` and `pg_restore` live on the PG host, not in SQL. Run them via
|
|
23
|
+
the SSH workspace that hosts your PG:
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
use_workspace("2026aliyun") # SSH workspace on the PG host
|
|
27
|
+
|
|
28
|
+
ssh_exec("pg_dump -Fc -d sourcedb -f /tmp/sourcedb.dump") # custom format, compressed
|
|
29
|
+
ssh_exec("createdb targetdb") # empty target
|
|
30
|
+
ssh_exec("pg_restore -d targetdb --no-owner --jobs=4 /tmp/sourcedb.dump")
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Flags worth knowing:
|
|
34
|
+
|
|
35
|
+
- `-Fc` custom format (compressed, parallel-restoreable). `-Fp` plain SQL.
|
|
36
|
+
- `-s` schema-only / `-a` data-only. `-t public.orders` one table.
|
|
37
|
+
- `--no-owner` strips `OWNED BY` from objects (safer cross-cluster).
|
|
38
|
+
|
|
39
|
+
Cross-version note: `pg_dump` from the **target's** major is safest.
|
|
40
|
+
`pg_dump` 16 can read PG 15 / 14; restore with the target's own
|
|
41
|
+
`pg_restore`. Going major-down (PG 16 dump -> PG 14) is **not supported**.
|
|
42
|
+
|
|
43
|
+
### 2. Bulk COPY
|
|
44
|
+
|
|
45
|
+
`COPY` bypasses per-row parse/plan/execute -- it's the right tool for
|
|
46
|
+
anything >10k rows. `sql-harness`'s `execute` covers server-side file
|
|
47
|
+
reads; client-streaming needs psql's `\copy` via SSH.
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
use_workspace("2026aliyun")
|
|
51
|
+
|
|
52
|
+
# (a) COPY TO -- export a query to a server-side CSV. psql \copy wraps
|
|
53
|
+
# COPY and lets a client feed/collect the stream.
|
|
54
|
+
ssh_exec(r"""psql -U $PG_USER sourcedb -c "\copy (SELECT id,email FROM users WHERE created_at>'2025-01-01') TO '/tmp/users.csv' WITH CSV HEADER" """)
|
|
55
|
+
ssh_download("/tmp/users.csv", "./users.csv") # pull it local
|
|
56
|
+
|
|
57
|
+
# (b) COPY FROM file -- server reads directly. Needs superuser OR
|
|
58
|
+
# `GRANT pg_read_server_files TO <role>` (PG 14+).
|
|
59
|
+
execute("COPY users (id, email) FROM '/var/lib/pg/imports/users.csv' WITH CSV HEADER")
|
|
60
|
+
|
|
61
|
+
# (c) COPY FROM STDIN -- client streams. `execute` can't do streaming;
|
|
62
|
+
# psql \copy from a *local* file is the practical path:
|
|
63
|
+
ssh_exec(r"""psql -h $PG_HOST -U $PG_USER targetdb -c "\copy users (id,email) FROM '/local/users.csv' WITH CSV HEADER" """)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### 3. Logical replication (publication + subscription)
|
|
67
|
+
|
|
68
|
+
For live, ongoing PG-to-PG replication of row changes. SQL-only on both
|
|
69
|
+
sides; the only server config is `wal_level = logical` on the source.
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
use_workspace("source_pg")
|
|
73
|
+
# Source: declare what to publish. Default = all DML on the named tables.
|
|
74
|
+
execute("CREATE PUBLICATION pub_users FOR TABLE users")
|
|
75
|
+
# Add `WITH (publish = 'insert,update,delete,truncate')` to include TRUNCATEs.
|
|
76
|
+
|
|
77
|
+
use_workspace("target_pg")
|
|
78
|
+
# Target: subscribe. Initial copy runs, then changes stream.
|
|
79
|
+
execute("""CREATE SUBSCRIPTION sub_users
|
|
80
|
+
CONNECTION 'host=src.db port=5432 user=repl password=... dbname=source'
|
|
81
|
+
PUBLICATION pub_users""")
|
|
82
|
+
|
|
83
|
+
# Monitor catch-up from the TARGET:
|
|
84
|
+
for r in query("SELECT subname, status, received_lsn, latest_end_lsn FROM pg_stat_subscription"):
|
|
85
|
+
print(r) # status='streaming' + latest_end_lsn advancing = caught up
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Gotchas
|
|
89
|
+
|
|
90
|
+
- **Logical replication does NOT replicate**: schema (DDL), sequence state
|
|
91
|
+
(manually sync with `pg_dump --section=pre-data` on the source +
|
|
92
|
+
`pg_restore --section=post-data` on target), or `TRUNCATE` unless the
|
|
93
|
+
publication includes `publish='truncate'`.
|
|
94
|
+
- **Tables need a PK** (or explicit `REPLICA IDENTITY`) -- without it,
|
|
95
|
+
`UPDATE`/`DELETE` can't replicate.
|
|
96
|
+
- **`COPY FROM` file path** is server-side (`SHOW data_directory`).
|
|
97
|
+
Permissions: superuser OR `pg_read_server_files` (PG 14+).
|
|
98
|
+
- **`pg_dump` cross-major** only goes *up* (or equal). PG 14 dump -> PG 16
|
|
99
|
+
is fine; PG 16 dump -> PG 14 is rejected.
|
|
100
|
+
- **`pg_restore --jobs=N`** uses N cores but each job = one transaction
|
|
101
|
+
per table -- mid-restore failures leave the DB half-loaded. For
|
|
102
|
+
restartability, restore one table at a time.
|
|
103
|
+
- **Logical replication initial copy** puts write load on the source; for
|
|
104
|
+
very large DBs, stop writes briefly or use `ALTER SUBSCRIPTION ...
|
|
105
|
+
DISABLE` then re-enable after catching up.
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# SSH — auth 选型与端口转发
|
|
2
|
+
|
|
3
|
+
`ssh.md` 写了怎么用 helpers(`ssh_exec / upload / download`)和 CLI。本篇覆盖两块它没写的:(1) 选哪种 auth(key / password / ssh-agent / `ssh+key` / env fallback),(2) 用 SSH workspace 做端口转发把"躲在堡垒机后面的 DB"暴露成本地端口。
|
|
4
|
+
|
|
5
|
+
> 全文代码块已注入的 helper:`ssh_exec / ssh_upload / ssh_download / use_workspace / ssh_info`。
|
|
6
|
+
|
|
7
|
+
## Detection
|
|
8
|
+
|
|
9
|
+
- `ssh_exec` 弹 `RuntimeError("...no tables...")` 或 paramiko `Auth failed` / `No valid authenticators` → 跳 [§1](#1-auth-选型)
|
|
10
|
+
- PG/MySQL workspace 直连 `internal-db-1.corp:5432` 超时,但 SSH workspace 能登堡垒机 → 跳 [§2](#2-端口转发tunneling)
|
|
11
|
+
- 远程主机有多个 SSH key 想用 ssh-agent 托管 → §1.3
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## 1. Auth 选型
|
|
16
|
+
|
|
17
|
+
### 1.1 三种 scheme 对照
|
|
18
|
+
|
|
19
|
+
`drivers/ssh.py:_parse_url` 支持的三种 URL 形式:
|
|
20
|
+
|
|
21
|
+
| URL 形式 | 何时用 | 取 key 顺序 |
|
|
22
|
+
|---|---|---|
|
|
23
|
+
| `ssh://user@host:port` | 默认 key auth | `?key=...` → `$BH_SSH_KEY` → `~/.ssh/id_ed25519` → `id_rsa` → `id_ecdsa`(`KEY_FALLBACK`) |
|
|
24
|
+
| `ssh+key://user@host:port?key=/abs/path` | key 不在默认路径 | URL 里的 `?key=` 单独覆盖 |
|
|
25
|
+
| `ssh+password://user:pw@host:port` | 跳板 / 临时主机,没配 key | URL 嵌入密码;也可用 `$BH_SSH_PASSWORD` |
|
|
26
|
+
|
|
27
|
+
**注意**:`ssh+password` 要求密码嵌入 URL。不想明文落 TOML,把密码放 `$BH_SSH_PASSWORD` 并用 `ssh://user@host`,但驱动**不会**自动回退到 env 密码(仅 key 走 env fallback)。这是常见混淆点。
|
|
28
|
+
|
|
29
|
+
### 1.2 用哪个连接串
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
有 ed25519 key 在 ~/.ssh/ 且 authorized_keys 已加?
|
|
33
|
+
├─ 是 → ssh://user@host (默认 key path)
|
|
34
|
+
└─ 否:
|
|
35
|
+
有密码但没 key? → ssh+password://user:pw@host
|
|
36
|
+
key 在非默认路径? → ssh://user@host?key=/path/to/k
|
|
37
|
+
key 在 ssh-agent? → §1.3
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### 1.3 ssh-agent
|
|
41
|
+
|
|
42
|
+
paramiko 默认不会主动连 ssh-agent。两条路:
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
# 路径 A(最常用):导出私钥文件路径,给驱动
|
|
46
|
+
# connections.toml:
|
|
47
|
+
# url = "ssh://app@bastion.corp?key=/home/me/.ssh/bastion_ed25519"
|
|
48
|
+
# 这是 ssh.md 默认推荐的。
|
|
49
|
+
|
|
50
|
+
# 路径 B(agent 转发):用 ssh-agent 把 key 放进 socket,paramiko 通过
|
|
51
|
+
# SSHAgentKey 适配。当前 ssh-harness 没适配;如需要 agent 转发,得
|
|
52
|
+
# 自己 patch driver。阶段性建议 → 用路径 A。
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### 1.4 key 不被接受(`Auth failed` / `No valid authenticators`)
|
|
56
|
+
|
|
57
|
+
8 成是这几个原因,按频率排:
|
|
58
|
+
|
|
59
|
+
1. **key 文件权限太开放**(paramiko 严格遵循 OpenSSH 规则)
|
|
60
|
+
```
|
|
61
|
+
chmod 600 ~/.ssh/id_ed25519
|
|
62
|
+
```
|
|
63
|
+
2. **authorized_keys 里这 key 被加过 passphrase**——paramiko 不弹 UI。
|
|
64
|
+
解决:`ssh-keygen -p -f ~/.ssh/id_ed25519` 清掉 passphrase,或 `ssh-add ~/.ssh/id_ed25519`。
|
|
65
|
+
3. **`?key=` 路径拼错**:相对路径相对 pwd——绝对路径最稳。
|
|
66
|
+
4. **用户名错了**:`ssh://root@host` vs `ssh://ubuntu@host`,老 OpenSSH 默认禁 root。
|
|
67
|
+
|
|
68
|
+
排查:
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
use_workspace("bastion")
|
|
72
|
+
print(ssh_info()) # 看 user/host/port/key_path 解析对不对
|
|
73
|
+
ssh_exec("whoami") # 验证通了
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### 1.5 env fallback 默认 key 优先级
|
|
77
|
+
|
|
78
|
+
看 `drivers/ssh.py:KEY_FALLBACK = ("~/.ssh/id_ed25519", "~/.ssh/id_rsa", "~/.ssh/id_ecdsa")`:
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
r = ssh_exec("ls -l ~/.ssh/id_ed25519 ~/.ssh/id_rsa 2>&1")
|
|
82
|
+
print(r["stdout"])
|
|
83
|
+
# 第一个存在的就是 driver 选的
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
## 2. 端口转发(Tunneling)
|
|
89
|
+
|
|
90
|
+
业务 DB 不对公网开、只对堡垒机的内网暴露时。三条路:
|
|
91
|
+
|
|
92
|
+
### 2.1 远程 `ssh -L`(堡垒机侧启端口)
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
use_workspace("bastion")
|
|
96
|
+
ssh_exec(
|
|
97
|
+
"ssh -fN -L 0.0.0.0:15432:internal-db.corp:5432 jump@internal-db.corp",
|
|
98
|
+
timeout=5,
|
|
99
|
+
)
|
|
100
|
+
r = ssh_exec("ss -lntp | grep 15432", timeout=5)
|
|
101
|
+
assert "15432" in r["stdout"]
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
> 局限:转发启在堡垒机上,**你本机** psql 连不到堡垒机的 `15432`(除非堡垒机做了 reverse tunnel)。适合堡垒机里跑 docker-compose;不适合"我本地 psql 直连"。
|
|
105
|
+
|
|
106
|
+
### 2.2 paramiko `open_channel` 反向隧道
|
|
107
|
+
|
|
108
|
+
让堡垒机把 internal-db 的 5432 反向暴露到堡垒机的 15432,然后用堡垒机侧 `psql` / `mysql` 客户端工具调试:
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
use_workspace("bastion")
|
|
112
|
+
import paramiko
|
|
113
|
+
ws = current_workspace()
|
|
114
|
+
transport = ws.engine.client.get_transport()
|
|
115
|
+
reverse = transport.request_port_forward("0.0.0.0", 15432,
|
|
116
|
+
("internal-db.corp", 5432))
|
|
117
|
+
try:
|
|
118
|
+
r = ssh_exec("PGPASSWORD=... psql -h 127.0.0.1 -p 15432 -U app -c 'select 1'")
|
|
119
|
+
finally:
|
|
120
|
+
transport.cancel_port_forward("0.0.0.0", 15432)
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### 2.3 真要让你本机 SQL 客户端连
|
|
124
|
+
|
|
125
|
+
sql-harness 不内置 SSH SOCKS 代理;本机 `psql`/`TablePlus` 直连躲在堡垒机后的 DB,最干净是用 OpenSSH 客户端 `ssh -L 15432:internal-db.corp:5432 bastion`(sql-harness 之外的工具)。
|
|
126
|
+
|
|
127
|
+
如果坚持在 sql-harness 里搞,用 §2.2 + 堡垒机启端口 + 跑 `psql`。
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
## Gotchas
|
|
132
|
+
|
|
133
|
+
- **paramiko 不会自动用 ssh-agent**——见 §1.3。
|
|
134
|
+
- **`ssh+password://` 必须嵌入密码**——`BH_SSH_PASSWORD` env 不会回退(仅 key 走 env fallback)。
|
|
135
|
+
- **堡垒机转发时 `ssh -fN` 在某些堡垒机受限**——`AllowTcpForwarding no`。失败就 §2.2 走 paramiko `request_port_forward`。
|
|
136
|
+
- **`request_port_forward` 返回的对象必须在 finally 里 cancel**,否则下次 `use_workspace` 复用 transport 报端口占用。
|
|
137
|
+
- **workspace disconnect 不自动清 tunnel**——硬规矩:tunnel 用 try/finally 包起来。
|
|
138
|
+
- **Windows → Linux 堡垒机的 key 权限**:`scp` 把 600 丢没的话 OpenSSH 服务端会拒。`scp` 后 `chmod 600` 或用 `ssh-copy-id`(用 sftp,保留权限)。
|
|
139
|
+
- **堡垒机禁用 SFTP subsystem**:`ssh_upload/ssh_download` 报 `RuntimeError("SFTP subsystem not available")`。回退 `tar -czf - | ssh host tar -xzf -` 经 `ssh_exec` 流式传。
|
|
140
|
+
|
|
141
|
+
## See also
|
|
142
|
+
|
|
143
|
+
- `interaction-skills/ssh/ssh.md` — mechanic(helpers / CLI / 基础 gotchas)
|
|
144
|
+
- `interaction-skills/ssh/docker-via-ssh.md` — 通过 SSH workspace 把 docker-compose 部署到远程
|
|
145
|
+
- `docs/connections-toml-example.toml` — `ssh://` / `ssh+password://` / `ssh+key://` 三种真实示例
|
{sql_harness-0.2.3/interaction-skills → sql_harness-0.3.1/interaction-skills/ssh}/docker-via-ssh.md
RENAMED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
# Docker-via-SSH — deploy a docker-compose service to a REMOTE host
|
|
2
2
|
|
|
3
|
-
> **The root logic for all docker operations in sql-harness.**
|
|
4
|
-
>
|
|
5
|
-
> host and runs `docker compose`. Everything runs **on the
|
|
6
|
-
> Mirrors how you'd deploy by hand over SSH, but
|
|
3
|
+
> **The root logic for all docker operations in sql-harness.** You bring the
|
|
4
|
+
> `docker-compose.yml` (and config files) locally; `sql-harness ssh` uploads
|
|
5
|
+
> them to the remote host and runs `docker compose`. Everything runs **on the
|
|
6
|
+
> remote**, not locally. Mirrors how you'd deploy by hand over SSH, but
|
|
7
|
+
> scripted + repeatable.
|
|
7
8
|
|
|
8
9
|
## Detection
|
|
9
10
|
|
|
@@ -34,11 +35,12 @@ self-contained (compose + config + data + logs all relative to that dir):
|
|
|
34
35
|
|
|
35
36
|
## The 4-step deploy loop
|
|
36
37
|
|
|
37
|
-
### Step 1 — write the compose locally
|
|
38
|
+
### Step 1 — write the compose locally
|
|
38
39
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
service dir (`./data`, `./config`
|
|
40
|
+
This mechanic assumes you have a local `docker-compose.yml` + any config files
|
|
41
|
+
you want to ship. The shipped template dir is gone in this build — bring your
|
|
42
|
+
own. All mounts must be **relative** to the service dir (`./data`, `./config`
|
|
43
|
+
— NOT `./<service>/data`, which double-nests).
|
|
42
44
|
|
|
43
45
|
### Step 2 — upload to the remote
|
|
44
46
|
|
|
@@ -122,15 +124,39 @@ the save→run cycle applied to infrastructure.
|
|
|
122
124
|
yaml parse errors on the remote. Always strip CRLF before upload
|
|
123
125
|
(`sed -i 's/\r$//' file.yml` or set `git config core.autocrlf false`).
|
|
124
126
|
|
|
125
|
-
## Service-specific gotchas
|
|
127
|
+
## Service-specific gotchas — coturn
|
|
126
128
|
|
|
127
|
-
coturn
|
|
128
|
-
|
|
129
|
-
|
|
129
|
+
coturn (TURN/STUN relay) has war stories that bit production. All live here in the mechanic — not duplicated in the template dir.
|
|
130
|
+
|
|
131
|
+
**Key config knobs** (`turnserver.conf`):
|
|
132
|
+
- `realm` — your domain / public hostname (clients see this in their URL).
|
|
133
|
+
- `external-ip` — server's PUBLIC ip when behind NAT (Aliyun ECS is). Set as `AUTO` in shipped template; deploy script substitutes real public ip before `up -d`.
|
|
134
|
+
- `listening-ip=0.0.0.0` — bind interface (private is fine; only `external-ip` needs to be public).
|
|
135
|
+
- `listening-port=3478` (TURN/STUN), `tls-listening-port=5349` (TURNS over TLS).
|
|
136
|
+
- `min-port=49152` / `max-port=65535` — relay UDP range. Must match firewall.
|
|
137
|
+
- `user=<u>:<p>` + `lt-cred-mech` — long-term credential mechanism.
|
|
138
|
+
|
|
139
|
+
Test creds after deploy:
|
|
140
|
+
```bash
|
|
141
|
+
turnutils_uclient -u webrtc -w webrtc <server-ip> 3478
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
**Gotchas**:
|
|
145
|
+
|
|
146
|
+
- **`external-ip` behind NAT**: Aliyun ECS is behind NAT — coturn's `turnserver.conf` `external-ip=` must be the **public** ip, not the private one `listening-ip=0.0.0.0` binds. Templated as `AUTO`; substitute at deploy time (`sed -i "s/external-ip=AUTO/external-ip=$PUBIP/"`).
|
|
147
|
+
|
|
148
|
+
- **Firewall / security group**: docker publishes ports on the host, but Aliyun security group + host firewall (ufw/firewalld) must ALSO allow them. The coturn relay UDP range (49152-65535) is the easy one to forget. Open in security group, NOT via docker `ports:`.
|
|
149
|
+
|
|
150
|
+
- **Don't publish the relay port range via `ports:`** — publishing coturn's relay range `49152-65535` (~16k ports) makes Docker create ~16k iptables DNAT rules, which **OOM-kills the container start** (exit 137) on small hosts (Aliyun ECS). Use `network_mode: host` instead — coturn binds the range natively, zero iptables overhead. This is the official coturn/docker recommendation.
|
|
151
|
+
|
|
152
|
+
- **`network_mode: host` disables `ports:`** — the container binds host ports directly; don't also list `ports:` (compose rejects the combo). All port management moves to the host firewall + cloud security group.
|
|
153
|
+
|
|
154
|
+
- **Config-as-dir gotcha**: if `./turnserver.conf` doesn't exist locally when docker tries to mount it, Docker creates a **directory** at that path (not a file) → coturn fails to read config. Always upload the file BEFORE `up -d`, or pre-`touch` it on the remote.
|
|
155
|
+
|
|
156
|
+
**Why host networking (rationale)**: coturn's relay UDP range (49152-65535 = ~16k ports) must NOT be published via docker `-p` — Docker would create ~16k iptables DNAT rules, OOM-killing the daemon on small hosts. Host networking lets coturn bind the range natively, zero iptables overhead. Ports to open in host firewall / cloud security group: `3478/tcp+udp`, `5349/tcp+udp`, `49152-65535/udp` (relay range — open in security group, NOT in docker).
|
|
130
157
|
|
|
131
158
|
## See also
|
|
132
159
|
|
|
133
160
|
- This file's "Detection" / "Approach" sections (decision P: the legacy strategy stub in `agent-workspace/zones/meta/skills/docker-deploy.md` was collapsed into the mechanic; no separate doc).
|
|
134
|
-
- `interaction-skills/ssh.md` — the raw ssh_exec/upload/download helpers
|
|
135
|
-
- `
|
|
136
|
-
- `docker-services/<svc>/README.md` — service-specific gotchas (e.g. coturn)
|
|
161
|
+
- `interaction-skills/ssh/ssh.md` — the raw ssh_exec/upload/download helpers
|
|
162
|
+
- `interaction-skills/ssh/auth-and-tunnels.md` — SSH auth scheme 选型 + 端口转发
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "sql-harness"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.1"
|
|
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 = "README.md"
|
|
6
6
|
license = { text = "MIT" }
|
|
@@ -42,7 +42,6 @@ build-backend = "hatchling.build"
|
|
|
42
42
|
# Mirrors browser-harness's setuptools src-layout. The package source is
|
|
43
43
|
# under `src/sql_harness/`; the package name (top-level import) is `sql_harness`.
|
|
44
44
|
packages = ["src/sql_harness"]
|
|
45
|
-
artifacts = ["src/sql_harness/SKILL.md"]
|
|
46
45
|
|
|
47
46
|
[tool.hatch.build.targets.wheel.force-include]
|
|
48
47
|
# Bundle the full skill docs into the wheel so `sql-harness skill install`
|
|
@@ -53,9 +52,6 @@ artifacts = ["src/sql_harness/SKILL.md"]
|
|
|
53
52
|
"SKILL.md" = "sql_harness/_skills/SKILL.md"
|
|
54
53
|
"interaction-skills" = "sql_harness/_skills/interaction-skills"
|
|
55
54
|
"agent-workspace/zones/meta/skills" = "sql_harness/_skills/zones-meta-skills"
|
|
56
|
-
# Docker service templates (coturn/mysql/redis/...) — the docker-via-ssh mechanic
|
|
57
|
-
# uploads these to ~/sql-harness/<svc>/ on a remote host.
|
|
58
|
-
"docker-services" = "sql_harness/_skills/docker-services"
|
|
59
55
|
|
|
60
56
|
[tool.pytest.ini_options]
|
|
61
57
|
# Mirrors browser-harness's `pythonpath = ["src"]` — tests import `sql_harness`
|
|
@@ -343,17 +343,17 @@ def _packaged_skill_body() -> str | None:
|
|
|
343
343
|
|
|
344
344
|
Mirrors browser-harness's run.py:_print_skill, which reads the packaged
|
|
345
345
|
SKILL.md via importlib.resources so it works from both a source checkout
|
|
346
|
-
and a built wheel. Falls back to the repo-level
|
|
346
|
+
and a built wheel. Falls back to the repo-level SKILL.md (root).
|
|
347
347
|
"""
|
|
348
|
-
# 1. in-package:
|
|
348
|
+
# 1. in-package: sql_harness/_skills/SKILL.md (force-included in the wheel)
|
|
349
349
|
try:
|
|
350
|
-
ref = importlib_resources.files("sql_harness").joinpath("SKILL.md")
|
|
350
|
+
ref = importlib_resources.files("sql_harness").joinpath("_skills", "SKILL.md")
|
|
351
351
|
if ref.is_file():
|
|
352
352
|
return ref.read_text(encoding="utf-8")
|
|
353
353
|
except (ModuleNotFoundError, FileNotFoundError):
|
|
354
354
|
pass
|
|
355
|
-
# 2. repo-level fallback (source checkout)
|
|
356
|
-
repo = _package_root_dir() / "
|
|
355
|
+
# 2. repo-level fallback (source checkout): lab/sql_harness/SKILL.md
|
|
356
|
+
repo = _package_root_dir() / "SKILL.md"
|
|
357
357
|
if repo.is_file():
|
|
358
358
|
return repo.read_text(encoding="utf-8")
|
|
359
359
|
return None
|
|
@@ -558,6 +558,13 @@ def cmd_save(args, _harness: SqlHarness) -> int:
|
|
|
558
558
|
conn, code = _require_connection_or_fail(args)
|
|
559
559
|
if code:
|
|
560
560
|
return code
|
|
561
|
+
# Read stdin as UTF-8 on Windows (default locale is GBK/CP936, which
|
|
562
|
+
# mangles non-ASCII bytes into surrogates that write_text rejects).
|
|
563
|
+
# Mirrors run.py's heredoc-mode stdin handling.
|
|
564
|
+
try:
|
|
565
|
+
sys.stdin.reconfigure(encoding="utf-8", errors="replace")
|
|
566
|
+
except (AttributeError, OSError):
|
|
567
|
+
pass
|
|
561
568
|
body = sys.stdin.read()
|
|
562
569
|
if not body.strip():
|
|
563
570
|
print("error: stdin is empty; nothing to save", file=sys.stderr)
|
|
@@ -616,6 +623,9 @@ def cmd_run(args, harness: SqlHarness) -> int:
|
|
|
616
623
|
for n, v in vars(helpers).items():
|
|
617
624
|
if not n.startswith("_"):
|
|
618
625
|
namespace[n] = v
|
|
626
|
+
# Parity with heredoc mode (run.py globals): expose `sys` so saved
|
|
627
|
+
# scripts can use sys.stdout.write etc. without importing it.
|
|
628
|
+
namespace["sys"] = sys
|
|
619
629
|
code_text = path.read_text(encoding="utf-8")
|
|
620
630
|
try:
|
|
621
631
|
exec(compile(code_text, str(path), "exec"), namespace)
|
|
@@ -10,8 +10,14 @@ from pathlib import Path
|
|
|
10
10
|
import pytest
|
|
11
11
|
|
|
12
12
|
|
|
13
|
-
def _run_cli(
|
|
14
|
-
|
|
13
|
+
def _run_cli(
|
|
14
|
+
*args: str, env: dict | None = None, input: str | None = None
|
|
15
|
+
) -> subprocess.CompletedProcess:
|
|
16
|
+
"""Run sql-harness in a subprocess; capture UTF-8 output.
|
|
17
|
+
|
|
18
|
+
`input` (when given) is piped to the child's stdin as UTF-8 — used to
|
|
19
|
+
exercise `save`, which reads its heredoc body from stdin.
|
|
20
|
+
"""
|
|
15
21
|
import os
|
|
16
22
|
|
|
17
23
|
full_env = os.environ.copy()
|
|
@@ -24,6 +30,7 @@ def _run_cli(*args: str, env: dict | None = None) -> subprocess.CompletedProcess
|
|
|
24
30
|
encoding="utf-8",
|
|
25
31
|
errors="replace",
|
|
26
32
|
env=full_env,
|
|
33
|
+
input=input,
|
|
27
34
|
)
|
|
28
35
|
|
|
29
36
|
|
|
@@ -219,4 +226,48 @@ def test_open_scripts_requires_connection(tmp_path: Path) -> None:
|
|
|
219
226
|
"""`open scripts` without --connection errors with guidance."""
|
|
220
227
|
p = _run_cli("open", "scripts", env={"BH_SQL_HOME": str(tmp_path)})
|
|
221
228
|
assert p.returncode == 2
|
|
222
|
-
assert "zone-scoped" in p.stderr
|
|
229
|
+
assert "zone-scoped" in p.stderr
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _write_sqlite_cfg(tmp_path: Path) -> None:
|
|
233
|
+
"""Seed an isolated BH_SQL_HOME with a single sqlite connection."""
|
|
234
|
+
(tmp_path / "connections.toml").write_text(
|
|
235
|
+
'[[connections]]\n'
|
|
236
|
+
'name = "mem"\n'
|
|
237
|
+
'driver = "sqlite"\n'
|
|
238
|
+
'url = "sqlite:///:memory:"\n',
|
|
239
|
+
encoding="utf-8",
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def test_save_preserves_utf8_stdin(tmp_path: Path) -> None:
|
|
244
|
+
"""`save` reads stdin as UTF-8 even on Windows (cp936 default locale).
|
|
245
|
+
|
|
246
|
+
Regression: non-ASCII bytes in the heredoc body were decoded into
|
|
247
|
+
surrogates and write_text(utf-8) raised UnicodeEncodeError.
|
|
248
|
+
"""
|
|
249
|
+
_write_sqlite_cfg(tmp_path)
|
|
250
|
+
body = "# 服务器内存检查\nprint('ok')\n"
|
|
251
|
+
p = _run_cli(
|
|
252
|
+
"save", "utf8", "-c", "mem", input=body, env={"BH_SQL_HOME": str(tmp_path)}
|
|
253
|
+
)
|
|
254
|
+
assert p.returncode == 0, p.stderr
|
|
255
|
+
saved = Path(json.loads(p.stdout)["path"])
|
|
256
|
+
assert "服务器内存检查" in saved.read_text(encoding="utf-8")
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def test_run_namespace_exposes_sys(tmp_path: Path) -> None:
|
|
260
|
+
"""`run` exec namespace includes `sys`, matching heredoc mode.
|
|
261
|
+
|
|
262
|
+
Regression: saved scripts that used `sys.stdout.write` without
|
|
263
|
+
`import sys` (as ssh.md documents) failed with NameError.
|
|
264
|
+
"""
|
|
265
|
+
_write_sqlite_cfg(tmp_path)
|
|
266
|
+
body = "sys.stdout.write('ok-from-sys')\n" # relies on pre-imported sys
|
|
267
|
+
p = _run_cli(
|
|
268
|
+
"save", "usesys", "-c", "mem", input=body, env={"BH_SQL_HOME": str(tmp_path)}
|
|
269
|
+
)
|
|
270
|
+
assert p.returncode == 0, p.stderr
|
|
271
|
+
p2 = _run_cli("run", "usesys", "-c", "mem", env={"BH_SQL_HOME": str(tmp_path)})
|
|
272
|
+
assert p2.returncode == 0, p2.stderr
|
|
273
|
+
assert "ok-from-sys" in p2.stdout
|