sql-harness 0.1.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.
sql_harness/manager.py ADDED
@@ -0,0 +1,122 @@
1
+ """SqlHarness — single-process manager holding open workspaces.
2
+
3
+ A workspace = a named connection + its SQLAlchemy Engine. Engines are opened
4
+ lazily and kept alive until `close_workspace()` or process exit.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+
11
+ from sqlalchemy import Engine
12
+
13
+ from .config import ConnectionsConfig
14
+ from .drivers import Driver, get_driver
15
+ from .paths import workspace_dir
16
+
17
+
18
+ class Workspace:
19
+ """One connection = one workspace."""
20
+
21
+ def __init__(self, config: "WorkspaceConfig", engine: Engine, driver):
22
+ self.config = config
23
+ self.engine = engine
24
+ self.driver = driver
25
+
26
+ def __repr__(self) -> str:
27
+ return f"Workspace(name={self.config.connection.name!r}, driver={self.driver.name})"
28
+
29
+
30
+ class WorkspaceConfig:
31
+ """Lightweight wrapper used at runtime; created from ConnectionConfig."""
32
+
33
+ __slots__ = ("connection",)
34
+
35
+ def __init__(self, connection):
36
+ self.connection = connection
37
+
38
+
39
+ class SqlHarness:
40
+ """Owns the open workspace registry."""
41
+
42
+ def __init__(self, config: ConnectionsConfig):
43
+ self.config = config
44
+ self._workspaces: dict[str, Workspace] = {}
45
+
46
+ # --- workspace lifecycle -----------------------------------------------
47
+
48
+ def workspace(self, name: str) -> Workspace:
49
+ """Open (or return cached) workspace for `name`."""
50
+ if name in self._workspaces:
51
+ return self._workspaces[name]
52
+ conn = self.config.get(name)
53
+ driver = get_driver(conn.driver)
54
+ engine = driver.make_engine(conn.url, conn.pool)
55
+ ws = Workspace(WorkspaceConfig(conn), engine, driver)
56
+ self._workspaces[name] = ws
57
+ return ws
58
+
59
+ def close_workspace(self, name: str) -> None:
60
+ ws = self._workspaces.pop(name, None)
61
+ if ws is not None:
62
+ # SQLAlchemy engines use .dispose(); SSH handles use .close().
63
+ closer = getattr(ws.engine, "close", None)
64
+ if callable(closer):
65
+ closer()
66
+ else:
67
+ ws.engine.dispose()
68
+
69
+ def list_workspaces(self) -> list[str]:
70
+ return sorted(self._workspaces.keys())
71
+
72
+ def has_workspace(self, name: str) -> bool:
73
+ return name in self._workspaces
74
+
75
+ def get_workspace(self, name: str) -> Workspace | None:
76
+ return self._workspaces.get(name)
77
+
78
+ # --- default workspace (from connections.toml) -----------------------
79
+
80
+ @property
81
+ def default_workspace_name(self) -> str:
82
+ return self.config.default_workspace
83
+
84
+ def default_workspace(self) -> Workspace | None:
85
+ name = self.config.default_workspace
86
+ if not name:
87
+ return None
88
+ if name not in self.config.names():
89
+ return None
90
+ return self.workspace(name)
91
+
92
+ # --- skills ------------------------------------------------------------
93
+
94
+ def apply_skill(self, name: str) -> str:
95
+ """Read an agent-workspace skill markdown file."""
96
+ path = self._skill_path(name)
97
+ if not path.is_file():
98
+ raise FileNotFoundError(
99
+ f"no skill named {name!r} in {self._skills_dir()}"
100
+ )
101
+ return path.read_text(encoding="utf-8")
102
+
103
+ def list_skills(self) -> list[str]:
104
+ d = self._skills_dir()
105
+ if not d.is_dir():
106
+ return []
107
+ return sorted(p.stem for p in d.glob("*.md") if p.is_file())
108
+
109
+ def _skill_path(self, name: str) -> Path:
110
+ # Defensive: prevent path traversal.
111
+ if "/" in name or "\\" in name or name.startswith("."):
112
+ raise ValueError(f"invalid skill name: {name!r}")
113
+ return self._skills_dir() / f"{name}.md"
114
+
115
+ def _skills_dir(self) -> Path:
116
+ return workspace_dir() / "skills"
117
+
118
+ # --- teardown ----------------------------------------------------------
119
+
120
+ def close_all(self) -> None:
121
+ for name in list(self._workspaces.keys()):
122
+ self.close_workspace(name)
sql_harness/paths.py ADDED
@@ -0,0 +1,112 @@
1
+ """Path resolution for sql-harness state directory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ IS_WINDOWS = sys.platform == "win32"
10
+
11
+
12
+ def home_dir() -> Path:
13
+ """Return the sql-harness state root.
14
+
15
+ Resolved in order:
16
+ 1. $BH_SQL_HOME (matches browser-harness env-var style)
17
+ 2. $XDG_CONFIG_HOME/sql-harness
18
+ 3. ~/.config/sql-harness (POSIX / fallback)
19
+ """
20
+ override = os.environ.get("BH_SQL_HOME")
21
+ if override:
22
+ return Path(override).expanduser()
23
+ xdg = os.environ.get("XDG_CONFIG_HOME")
24
+ if xdg:
25
+ return Path(xdg).expanduser() / "sql-harness"
26
+ return Path.home() / ".config" / "sql-harness"
27
+
28
+
29
+ def config_dir() -> Path:
30
+ """Directory holding connections.toml and other static config."""
31
+ override = os.environ.get("BH_SQL_CONFIG_DIR")
32
+ return Path(override).expanduser() if override else home_dir()
33
+
34
+
35
+ def config_file() -> Path:
36
+ """Path to the plaintext connections.toml."""
37
+ override = os.environ.get("BH_SQL_CONFIG_FILE")
38
+ return Path(override).expanduser() if override else config_dir() / "connections.toml"
39
+
40
+
41
+ def workspace_dir() -> Path:
42
+ """Agent-editable workspace (mirrors browser-harness agent-workspace/).
43
+
44
+ Layout:
45
+ - agent_helpers.py shared base helpers (auto-imported every run)
46
+ - zones/<connection>/ per-DSN isolated zone (mirrors domain-skills/<host>/)
47
+ - helpers.py per-connection helpers (merged over the shared base)
48
+ - scripts/<name>.py saved heredocs for this connection
49
+ - skills/<name>.md per-connection skill knowledge
50
+ - scripts/ (legacy global scripts, pre-zone)
51
+ - skills/ (legacy global skills, pre-zone)
52
+ """
53
+ override = os.environ.get("BH_SQL_AGENT_WORKSPACE")
54
+ return Path(override).expanduser() if override else home_dir() / "agent-workspace"
55
+
56
+
57
+ def scripts_dir() -> Path:
58
+ """Legacy global scripts dir (pre-zone). Prefer zone_scripts_dir()."""
59
+ return workspace_dir() / "scripts"
60
+
61
+
62
+ def zone_dir(connection: str) -> Path:
63
+ """Per-connection isolated zone root: agent-workspace/zones/<connection>/.
64
+
65
+ Mirrors browser-harness's domain-skills/<host>/ — one isolated area per
66
+ DSN. `connection` is validated to be a simple identifier (no path parts).
67
+ """
68
+ _validate_zone_name(connection)
69
+ return workspace_dir() / "zones" / connection
70
+
71
+
72
+ def zone_scripts_dir(connection: str) -> Path:
73
+ return zone_dir(connection) / "scripts"
74
+
75
+
76
+ def zone_skills_dir(connection: str) -> Path:
77
+ return zone_dir(connection) / "skills"
78
+
79
+
80
+ def zone_helpers_file(connection: str) -> Path:
81
+ """Per-connection helpers.py, merged over the shared base on use_workspace."""
82
+ return zone_dir(connection) / "helpers.py"
83
+
84
+
85
+ def _validate_zone_name(name: str) -> None:
86
+ """A zone name is a connection name; reject path traversal / odd chars."""
87
+ if not name or "/" in name or "\\" in name or name.startswith("."):
88
+ raise ValueError(f"invalid zone/connection name: {name!r}")
89
+
90
+
91
+ def runtime_dir() -> Path:
92
+ """Runtime state: logs, lock files."""
93
+ override = os.environ.get("BH_SQL_RUNTIME_DIR")
94
+ return Path(override).expanduser() if override else home_dir() / "runtime"
95
+
96
+
97
+ def tmp_dir() -> Path:
98
+ """Per-session scratch files (query logs, dumps)."""
99
+ override = os.environ.get("BH_SQL_TMP_DIR")
100
+ return Path(override).expanduser() if override else home_dir() / "tmp"
101
+
102
+
103
+ def ensure_private_dir(p: Path) -> Path:
104
+ """Create directory (parents=True) and tighten perms on POSIX."""
105
+ p.mkdir(parents=True, exist_ok=True)
106
+ if not IS_WINDOWS:
107
+ # Best-effort: only tighten on creation
108
+ try:
109
+ p.chmod(0o700)
110
+ except (PermissionError, OSError):
111
+ pass
112
+ return p
sql_harness/run.py ADDED
@@ -0,0 +1,85 @@
1
+ """sql-harness CLI / heredoc entry point.
2
+
3
+ Mirrors browser-harness's run.py:
4
+
5
+ - Without args + non-TTY stdin: read heredoc body, exec in helpers' globals.
6
+ - Otherwise: dispatch to cli.main().
7
+
8
+ The console script `sql-harness` resolves to this module's `main()`.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import sys
14
+
15
+ from . import cli, helpers
16
+ from .agent_loader import load_agent_helpers
17
+ from .config import load as load_config
18
+ from .helpers import set_active
19
+ from .manager import SqlHarness
20
+ from .paths import config_file, ensure_private_dir, home_dir
21
+
22
+
23
+ # Merge every helper into this module's globals so `exec(code, globals())`
24
+ # in heredoc mode sees them as bare names. Mirrors browser-harness's
25
+ # `from .helpers import *` pattern.
26
+ for _name, _value in vars(helpers).items():
27
+ if _name.startswith("_"):
28
+ continue
29
+ globals()[_name] = _value
30
+
31
+ # Mirror the same names for `from .run import *` users.
32
+ __all__ = [n for n in globals().keys() if not n.startswith("_")]
33
+
34
+
35
+ def _build_harness() -> SqlHarness:
36
+ """Build a SqlHarness from the user's config file."""
37
+ cfg_path = config_file()
38
+ if not cfg_path.exists():
39
+ # First run: create a fresh state dir + empty config file.
40
+ ensure_private_dir(home_dir())
41
+ cfg = load_config(cfg_path)
42
+ else:
43
+ cfg = load_config(cfg_path)
44
+ return SqlHarness(cfg)
45
+
46
+
47
+ def main() -> int:
48
+ """Entry point: branch on TTY vs heredoc."""
49
+ harness = _build_harness()
50
+ set_active(harness)
51
+
52
+ # Merge agent_helpers.py into our globals so user-defined helpers
53
+ # are also visible in heredoc mode.
54
+ load_agent_helpers(globals())
55
+
56
+ # Heredoc mode: no args + piped stdin.
57
+ if len(sys.argv) == 1 and not sys.stdin.isatty():
58
+ # Read stdin as UTF-8 on Windows (default locale is GBK/CP936, which
59
+ # mangles non-ASCII bytes into surrogates that exec() rejects).
60
+ # Mirrors browser-harness's Windows stdin handling.
61
+ try:
62
+ sys.stdin.reconfigure(encoding="utf-8", errors="replace")
63
+ except (AttributeError, OSError):
64
+ pass
65
+ code = sys.stdin.read()
66
+ try:
67
+ exec(code, globals())
68
+ return 0
69
+ except SystemExit as e:
70
+ return int(e.code) if e.code is not None else 0
71
+ except Exception:
72
+ import traceback
73
+
74
+ traceback.print_exc()
75
+ return 1
76
+
77
+ # Otherwise: dispatch to the CLI.
78
+ try:
79
+ return cli.main(sys.argv[1:])
80
+ finally:
81
+ harness.close_all()
82
+
83
+
84
+ if __name__ == "__main__":
85
+ sys.exit(main())
@@ -0,0 +1,137 @@
1
+ Metadata-Version: 2.4
2
+ Name: sql-harness
3
+ Version: 0.1.0
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
+ Project-URL: Source, https://github.com/zhaoliuxue/much_bigpy/tree/master/lab/sql_harness
6
+ Project-URL: Issues, https://github.com/zhaoliuxue/much_bigpy/issues
7
+ Author: much-bigpy
8
+ License: MIT
9
+ Keywords: agent,cli,database,heredoc,mysql,postgres,sql,ssh
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Topic :: Database
16
+ Classifier: Topic :: System :: Shells
17
+ Requires-Python: >=3.12
18
+ Requires-Dist: paramiko<4,>=3.4
19
+ Requires-Dist: psycopg[binary]<4,>=3.2
20
+ Requires-Dist: pymysql<2,>=1.1
21
+ Requires-Dist: sqlalchemy<3,>=2.0
22
+ Description-Content-Type: text/markdown
23
+
24
+ # sql-harness
25
+
26
+ A thin, single-process SQL CLI for LLM agents. Mirrors [browser-harness](https://github.com/browser-use/browser-harness)'s structure but targets relational databases (Postgres, MySQL, Redis-soon).
27
+
28
+ ## Quickstart
29
+
30
+ ```bash
31
+ # 1. Install deps (one-time)
32
+ uv sync
33
+
34
+ # 2. Scaffold a starter connections.toml
35
+ uv run sql-harness init
36
+
37
+ # 3. Add a connection
38
+ uv run sql-harness add local_pg --driver postgres --url 'postgresql://postgres:postgres@localhost:5432/postgres'
39
+
40
+ # 4. Test it
41
+ uv run sql-harness test local_pg
42
+
43
+ # 5. Use it
44
+ uv run sql-harness <<'PY'
45
+ use_workspace("local_pg")
46
+ print(query("SELECT version()"))
47
+ print(list_tables())
48
+ print(describe("pg_class"))
49
+ PY
50
+ ```
51
+
52
+ ## Architecture (~1k lines across 8 core files)
53
+
54
+ - `install.md` — first-time install + first connection
55
+ - `SKILL.md` — day-to-day usage
56
+ - `lab/sql_harness/src/sql_harness/` — protected core package (src-layout, mirrors browser-harness)
57
+ - `${XDG_CONFIG_HOME:-~/.config}/sql-harness/connections.toml` — plaintext credentials in ONE place
58
+ - `${XDG_CONFIG_HOME:-~/.config}/sql-harness/agent-workspace/agent_helpers.py` — task-specific helpers
59
+ - `${XDG_CONFIG_HOME:-~/.config}/sql-harness/agent-workspace/skills/` — per-task/per-table skills
60
+
61
+ ## Why mirror browser-harness?
62
+
63
+ - Same operational ergonomics for agents: heredoc CLI with auto-imported helpers.
64
+ - Same agent-workspace pattern (helpers + skills stay editable per-project).
65
+ - Same path/state layout (XDG-style state directory).
66
+ - Same doc layering: `SKILL.md` body + `interaction-skills/*.md` for stuck points.
67
+
68
+ ## What this is NOT
69
+
70
+ - Not a database migration framework.
71
+ - Not an ORM.
72
+ - Not a connection-string parser (`pgcli`/`mycli` already exist for interactive REPL use; this is for *agents*).
73
+ - Not a long-running daemon. SQL connections don't need one.
74
+
75
+ ## Connection file format
76
+
77
+ Default: `${XDG_CONFIG_HOME:-~/.config}/sql-harness/connections.toml`.
78
+
79
+ ```toml
80
+ default_workspace = "local_pg"
81
+
82
+ [pool_defaults]
83
+ size = 5
84
+ recycle = 3600
85
+ pre_ping = true
86
+ echo = false
87
+
88
+ [[connections]]
89
+ name = "local_pg"
90
+ driver = "postgres"
91
+ url = "postgresql+psycopg://postgres:postgres@localhost:5432/postgres"
92
+ description = "Local PostgreSQL for dev"
93
+
94
+ [[connections]]
95
+ name = "prod_pg"
96
+ driver = "postgres"
97
+ url = "${env:PROD_PG_URL}" # env-var indirection
98
+ pool_size = 10
99
+ ```
100
+
101
+ Use `${env:VAR}` placeholders inside `url` for prod secrets — they're expanded at load time from the process environment.
102
+
103
+ ## Configuration env vars
104
+
105
+ | Var | Purpose |
106
+ |---|---|
107
+ | `BH_SQL_HOME` | Override state root (`~/.config/sql-harness` by default) |
108
+ | `BH_SQL_CONFIG_FILE` | Override connections.toml path |
109
+ | `BH_SQL_AGENT_WORKSPACE` | Override agent-workspace directory |
110
+ | `BH_SQL_RUNTIME_DIR` | Override runtime state dir |
111
+ | `BH_SQL_TMP_DIR` | Override temp dir |
112
+ | `BH_PG_URL` | Used by integration tests (skipped if unset) |
113
+ | `BH_MYSQL_URL` | Used by integration tests (skipped if unset) |
114
+
115
+ ## Drivers
116
+
117
+ | Driver | Backend | Package | Status |
118
+ |---|---|---|---|
119
+ | `postgres` | PostgreSQL | `psycopg[binary]>=3.2` | ✅ |
120
+ | `mysql` | MySQL | `pymysql>=1.1` | ✅ |
121
+ | `redis` | Redis | `redis>=5.0` (not yet added) | 🚧 stub |
122
+ | `sqlite` | SQLite | stdlib (`sqlite3`) | ✅ (test-only) |
123
+ | `ssh` / `ssh+password` / `ssh+key` | remote shell + SFTP (paramiko) | `paramiko>=3.4` | ✅ |
124
+
125
+ To enable Redis: `uv add "redis>=5.0,<6"` then implement `drivers/redis.py`.
126
+ To add another SSH host: append a `[[connections]]` block with `driver = "ssh"` and an `ssh://...` URL.
127
+
128
+ ## Contributing
129
+
130
+ PRs and improvements welcome. See `AGENTS.md` for code priorities.
131
+
132
+ - **Skills are written by the harness, not by you.** When you figure out a non-obvious SQL flow (a weird schema, a slow query, a JSON column trick), file a skill in `agent-workspace/skills/<name>.md`. Future sessions will read it before re-discovering it.
133
+ - Bug fixes, new drivers, helper additions all welcome.
134
+
135
+ ## License
136
+
137
+ MIT.
@@ -0,0 +1,19 @@
1
+ sql_harness/SKILL.md,sha256=O7xnojcL_Q_c8Hnf1TXwUEvbCpEkbD52rfwFDDxA_gM,3258
2
+ sql_harness/__init__.py,sha256=nT5ZKMOTeza9K1inWRHsR3h8ikvK1icekBbkOfX4U_U,669
3
+ sql_harness/agent_loader.py,sha256=CO0MahpXR1jzwQWSrrPGGo_AJ40ogunASP5gZFmjsu0,1821
4
+ sql_harness/cli.py,sha256=OBoB0icCi8dUrqo8RI4aE9bvxwxzwg7euGJWvEbNJSs,30859
5
+ sql_harness/config.py,sha256=CcOTASoyoqhRlTN-a8zY6D1QiK-XJlJ1dv6INWDiwfI,8141
6
+ sql_harness/helpers.py,sha256=bv3rUWeVBcIDdtrn6ZLcubKvkU9MtJjQtuboACkxrNQ,29484
7
+ sql_harness/manager.py,sha256=56iTMTUcqfi_Cktv8qbIU_7W70OP-6T5u_U66RntUaY,3929
8
+ sql_harness/paths.py,sha256=V05o-OPKkxU8kERgrWY3RuIre6xP-AM8CT3EPbY-3Ag,3798
9
+ sql_harness/run.py,sha256=xoAsPEXT-5l0eZwVXMlufCRxl1woD0sZSgBZgzWlzIc,2545
10
+ sql_harness/drivers/__init__.py,sha256=WdxI10lF_gkhFib6CPe2KA6to4jxr_Rmio3-jFsrW3g,1686
11
+ sql_harness/drivers/mysql.py,sha256=W2GH20JZE6mnPGwEIF5zykp31WaNoKx96c4e92LD5kM,1497
12
+ sql_harness/drivers/postgres.py,sha256=dEMBOTFprjYWYY2OreMzi0P8nosk6cFfuSKQhOqT2iY,2190
13
+ sql_harness/drivers/redis.py,sha256=Oywm-sVBFnrXEybMFxkFy4jKL9S2k3kITgGeI1T_RX8,1093
14
+ sql_harness/drivers/sqlite.py,sha256=AzlnccquIJ7XfyLBB6jTFtFMpij_rbHR4e_rr7oxhX8,1358
15
+ sql_harness/drivers/ssh.py,sha256=S5-lEJGihDvqu3BTqUGg6rUoDPOw0h23krtCgBMUlGE,10076
16
+ sql_harness-0.1.0.dist-info/METADATA,sha256=b3tN1nb6tTqNcQzO2macw8HTSztXht02mdTsPNQa3Qg,4895
17
+ sql_harness-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
18
+ sql_harness-0.1.0.dist-info/entry_points.txt,sha256=mY6NkQIlefGk1xDAIHcFdPc5jTY-Vi_iUuCBY_2NVTk,53
19
+ sql_harness-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ sql-harness = sql_harness.run:main