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/config.py ADDED
@@ -0,0 +1,241 @@
1
+ """TOML configuration for sql-harness.
2
+
3
+ Schema (lives at ~/.config/sql-harness/connections.toml by default):
4
+
5
+ default_workspace = "local_pg" # optional, name of a connection
6
+ [pool_defaults]
7
+ size = 5
8
+ recycle = 3600
9
+ pre_ping = true
10
+ echo = false
11
+
12
+ [[connections]]
13
+ name = "local_pg"
14
+ driver = "postgres"
15
+ url = "postgresql+psycopg://user:pw@host:5432/db"
16
+ description = "..."
17
+ pool_size = 5
18
+ pool_recycle = 3600
19
+ pre_ping = true
20
+ echo = false
21
+ application_name = "sql-harness"
22
+
23
+ ${env:VAR} in `url` is expanded from the process environment.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import os
29
+ import re
30
+ import tomllib
31
+ from dataclasses import dataclass, field, replace
32
+ from pathlib import Path
33
+ from typing import Any
34
+
35
+ # Match ${env:NAME} placeholders inside URL strings.
36
+ _ENV_RE = re.compile(r"\$\{env:([A-Za-z_][A-Za-z0-9_]*)\}")
37
+
38
+
39
+ @dataclass
40
+ class PoolConfig:
41
+ """SQLAlchemy QueuePool knobs. Match URL is implied (`QueuePool`)."""
42
+
43
+ size: int = 5
44
+ recycle: int = 3600
45
+ pre_ping: bool = True
46
+ echo: bool = False
47
+
48
+ def sqlalchemy_kwargs(self) -> dict[str, Any]:
49
+ """Subset of kwargs accepted by sqlalchemy.create_engine()."""
50
+ return {
51
+ "pool_size": self.size,
52
+ "pool_recycle": self.recycle,
53
+ "pool_pre_ping": self.pre_ping,
54
+ "echo": self.echo,
55
+ }
56
+
57
+
58
+ @dataclass
59
+ class ConnectionConfig:
60
+ """A single named connection profile."""
61
+
62
+ name: str
63
+ driver: str # postgres | mysql | redis
64
+ url: str
65
+ description: str = ""
66
+ pool: PoolConfig = field(default_factory=PoolConfig)
67
+ application_name: str = "sql-harness"
68
+
69
+ def masked_url(self) -> str:
70
+ """Return the URL with password redacted."""
71
+ return _ENV_RE.sub(lambda m: os.environ.get(m.group(1), ""), _mask_password(self.url))
72
+
73
+
74
+ @dataclass
75
+ class ConnectionsConfig:
76
+ """Top-level config; one TOML file."""
77
+
78
+ default_workspace: str = ""
79
+ pool_defaults: PoolConfig = field(default_factory=PoolConfig)
80
+ connections: list[ConnectionConfig] = field(default_factory=list)
81
+
82
+ def get(self, name: str) -> ConnectionConfig:
83
+ for c in self.connections:
84
+ if c.name == name:
85
+ return c
86
+ raise KeyError(f"no connection named {name!r}")
87
+
88
+ def names(self) -> list[str]:
89
+ return [c.name for c in self.connections]
90
+
91
+ def apply_pool_defaults(self) -> None:
92
+ """For any connection that didn't override pool fields, use defaults."""
93
+ for c in self.connections:
94
+ if c.pool == PoolConfig(): # pure default
95
+ c.pool = replace(self.pool_defaults)
96
+
97
+
98
+ # --- TOML I/O ---------------------------------------------------------------
99
+
100
+
101
+ def _expand_env(value: str) -> str:
102
+ """Replace ${env:VAR} with the value of $VAR (or empty if unset)."""
103
+ return _ENV_RE.sub(lambda m: os.environ.get(m.group(1), ""), value)
104
+
105
+
106
+ def _mask_password(url: str) -> str:
107
+ """Replace the userinfo password with *** (best-effort)."""
108
+ # Format: scheme://user:password@host/...
109
+ if "@" not in url:
110
+ return url
111
+ head, tail = url.split("@", 1)
112
+ if "://" not in head:
113
+ return url
114
+ scheme, creds = head.split("://", 1)
115
+ if ":" in creds:
116
+ user, _ = creds.split(":", 1)
117
+ return f"{scheme}://{user}:***@{tail}"
118
+ return url
119
+
120
+
121
+ def load(path: Path) -> ConnectionsConfig:
122
+ """Load and parse connections.toml. Returns empty config if file missing."""
123
+ if not path.exists():
124
+ return ConnectionsConfig()
125
+ with path.open("rb") as f:
126
+ raw = tomllib.load(f)
127
+ return _from_dict(raw)
128
+
129
+
130
+ def save(cfg: ConnectionsConfig, path: Path) -> None:
131
+ """Serialize to TOML. Pure stdlib (manual writer — no tomllib writer in 3.11)."""
132
+ lines: list[str] = []
133
+ if cfg.default_workspace:
134
+ lines.append(f'default_workspace = "{_toml_str(cfg.default_workspace)}"')
135
+ lines.append("")
136
+
137
+ pd = cfg.pool_defaults
138
+ lines.append("[pool_defaults]")
139
+ lines.append(f"size = {pd.size}")
140
+ lines.append(f"recycle = {pd.recycle}")
141
+ lines.append(f"pre_ping = {'true' if pd.pre_ping else 'false'}")
142
+ lines.append(f"echo = {'true' if pd.echo else 'false'}")
143
+ lines.append("")
144
+
145
+ for c in cfg.connections:
146
+ lines.append("[[connections]]")
147
+ lines.append(f'name = "{_toml_str(c.name)}"')
148
+ lines.append(f'driver = "{_toml_str(c.driver)}"')
149
+ lines.append(f'url = "{_toml_str(c.url)}"')
150
+ if c.description:
151
+ lines.append(f'description = "{_toml_str(c.description)}"')
152
+ if c.pool.size != cfg.pool_defaults.size:
153
+ lines.append(f"pool_size = {c.pool.size}")
154
+ if c.pool.recycle != cfg.pool_defaults.recycle:
155
+ lines.append(f"pool_recycle = {c.pool.recycle}")
156
+ if c.pool.pre_ping != cfg.pool_defaults.pre_ping:
157
+ lines.append(f"pre_ping = {'true' if c.pool.pre_ping else 'false'}")
158
+ if c.pool.echo != cfg.pool_defaults.echo:
159
+ lines.append(f"echo = {'true' if c.pool.echo else 'false'}")
160
+ if c.application_name != "sql-harness":
161
+ lines.append(f'application_name = "{_toml_str(c.application_name)}"')
162
+ lines.append("")
163
+
164
+ path.parent.mkdir(parents=True, exist_ok=True)
165
+ path.write_text("\n".join(lines), encoding="utf-8")
166
+
167
+
168
+ def _toml_str(s: str) -> str:
169
+ """Escape a string for embedding in a TOML basic string."""
170
+ return s.replace("\\", "\\\\").replace('"', '\\"')
171
+
172
+
173
+ def _from_dict(raw: dict[str, Any]) -> ConnectionsConfig:
174
+ """Parse the loaded TOML dict into dataclasses + env-expand URLs."""
175
+ pd_raw = raw.get("pool_defaults") or {}
176
+ pool_defaults = PoolConfig(
177
+ size=int(pd_raw.get("size", 5)),
178
+ recycle=int(pd_raw.get("recycle", 3600)),
179
+ pre_ping=bool(pd_raw.get("pre_ping", True)),
180
+ echo=bool(pd_raw.get("echo", False)),
181
+ )
182
+
183
+ conns: list[ConnectionConfig] = []
184
+ for entry in raw.get("connections", []) or []:
185
+ p_raw = entry.get("pool") or {}
186
+ pool = PoolConfig(
187
+ size=int(p_raw.get("size", pool_defaults.size)),
188
+ recycle=int(p_raw.get("recycle", pool_defaults.recycle)),
189
+ pre_ping=bool(p_raw.get("pre_ping", pool_defaults.pre_ping)),
190
+ echo=bool(p_raw.get("echo", pool_defaults.echo)),
191
+ )
192
+ # Top-level pool_* keys (per the schema example) override the nested block.
193
+ if "pool_size" in entry:
194
+ pool.size = int(entry["pool_size"])
195
+ if "pool_recycle" in entry:
196
+ pool.recycle = int(entry["pool_recycle"])
197
+ if "pre_ping" in entry:
198
+ pool.pre_ping = bool(entry["pre_ping"])
199
+ if "echo" in entry:
200
+ pool.echo = bool(entry["echo"])
201
+
202
+ conns.append(
203
+ ConnectionConfig(
204
+ name=str(entry["name"]),
205
+ driver=str(entry["driver"]),
206
+ url=_expand_env(str(entry["url"])),
207
+ description=str(entry.get("description", "")),
208
+ pool=pool,
209
+ application_name=str(entry.get("application_name", "sql-harness")),
210
+ )
211
+ )
212
+
213
+ return ConnectionsConfig(
214
+ default_workspace=str(raw.get("default_workspace", "")),
215
+ pool_defaults=pool_defaults,
216
+ connections=conns,
217
+ )
218
+
219
+
220
+ def example_config() -> ConnectionsConfig:
221
+ """A safe-to-write starter config (no real secrets)."""
222
+ return ConnectionsConfig(
223
+ default_workspace="local_pg",
224
+ pool_defaults=PoolConfig(),
225
+ connections=[
226
+ ConnectionConfig(
227
+ name="local_pg",
228
+ driver="postgres",
229
+ url="postgresql+psycopg://postgres:postgres@localhost:5432/postgres",
230
+ description="Local PostgreSQL for dev",
231
+ pool=PoolConfig(size=5),
232
+ ),
233
+ ConnectionConfig(
234
+ name="local_mysql",
235
+ driver="mysql",
236
+ url="mysql+pymysql://root:root@localhost:3306/mysql?charset=utf8mb4",
237
+ description="Local MySQL for dev",
238
+ pool=PoolConfig(size=5),
239
+ ),
240
+ ],
241
+ )
@@ -0,0 +1,59 @@
1
+ """Driver abstraction for sql-harness.
2
+
3
+ A Driver wraps a SQLAlchemy Engine with dialect-specific helpers
4
+ (`list_tables`, `describe`, `quote_ident`). Adding a new backend
5
+ means adding one file here implementing the `Driver` protocol.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Protocol
11
+
12
+ from sqlalchemy import Engine
13
+
14
+
15
+ class Driver(Protocol):
16
+ """Protocol every backend implements."""
17
+
18
+ name: str
19
+
20
+ def make_engine(self, url: str, pool: "PoolConfig") -> Engine:
21
+ """Create a SQLAlchemy Engine with the given pool config."""
22
+
23
+ def list_tables(self, engine: Engine, schema: str | None) -> list[str]:
24
+ """Return table names (and views) visible in the given schema."""
25
+
26
+ def describe(
27
+ self, engine: Engine, table: str, schema: str | None
28
+ ) -> list[dict]:
29
+ """Return column metadata: [{name, type, nullable, default}, ...]."""
30
+
31
+ def quote_ident(self, ident: str) -> str:
32
+ """Safely quote an identifier for the dialect."""
33
+
34
+
35
+ def get_driver(name: str) -> Driver:
36
+ """Look up a driver by its short name."""
37
+ if name == "postgres":
38
+ from .postgres import PostgresDriver
39
+
40
+ return PostgresDriver()
41
+ if name == "mysql":
42
+ from .mysql import MysqlDriver
43
+
44
+ return MysqlDriver()
45
+ if name == "redis":
46
+ from .redis import RedisDriver
47
+
48
+ return RedisDriver()
49
+ if name == "sqlite":
50
+ from .sqlite import SqliteDriver
51
+
52
+ return SqliteDriver()
53
+ if name in ("ssh", "ssh+password", "ssh+key"):
54
+ from .ssh import SshDriver
55
+
56
+ return SshDriver()
57
+ raise ValueError(
58
+ f"unknown driver: {name!r} (known: postgres, mysql, redis, sqlite, ssh)"
59
+ )
@@ -0,0 +1,43 @@
1
+ """MySQL driver (PyMySQL via SQLAlchemy)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from sqlalchemy import Engine, create_engine, inspect, text
6
+
7
+
8
+ class MysqlDriver:
9
+ name = "mysql"
10
+
11
+ def make_engine(self, url: str, pool) -> Engine:
12
+ if not url.startswith("mysql+pymysql://"):
13
+ raise ValueError(
14
+ f"MySQL URL must start with mysql+pymysql:// (got {url[:30]!r})"
15
+ )
16
+ return create_engine(url, **pool.sqlalchemy_kwargs())
17
+
18
+ def list_tables(self, engine: Engine, schema: str | None) -> list[str]:
19
+ insp = inspect(engine)
20
+ return sorted(
21
+ insp.get_table_names(schema=schema) + insp.get_view_names(schema=schema)
22
+ )
23
+
24
+ def describe(self, engine: Engine, table: str, schema: str | None) -> list[dict]:
25
+ insp = inspect(engine)
26
+ cols = insp.get_columns(table, schema=schema)
27
+ return [
28
+ {
29
+ "name": c["name"],
30
+ "type": str(c["type"]),
31
+ "nullable": bool(c.get("nullable", True)),
32
+ "default": str(c.get("default")) if c.get("default") is not None else None,
33
+ }
34
+ for c in cols
35
+ ]
36
+
37
+ def quote_ident(self, ident: str) -> str:
38
+ # MySQL: backticks; escape embedded backticks.
39
+ return "`" + ident.replace("`", "``") + "`"
40
+
41
+ def server_version(self, engine: Engine) -> str:
42
+ with engine.connect() as conn:
43
+ return str(conn.execute(text("SELECT VERSION()")).scalar())
@@ -0,0 +1,55 @@
1
+ """PostgreSQL driver (psycopg 3 via SQLAlchemy)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from sqlalchemy import Engine, create_engine, inspect, text
6
+
7
+
8
+ class PostgresDriver:
9
+ name = "postgres"
10
+
11
+ def make_engine(self, url: str, pool) -> Engine:
12
+ # Accept all three SQLAlchemy-recognized PostgreSQL URL prefixes:
13
+ # postgresql+psycopg:// (explicit dialect — preferred)
14
+ # postgresql:// (SQLAlchemy maps to default psycopg driver)
15
+ # postgres:// (short alias SQLAlchemy also accepts)
16
+ # Normalize to the explicit dialect form for determinism.
17
+ if url.startswith("postgresql+psycopg://"):
18
+ pass
19
+ elif url.startswith("postgresql://"):
20
+ url = "postgresql+psycopg://" + url[len("postgresql://"):]
21
+ elif url.startswith("postgres://"):
22
+ url = "postgresql+psycopg://" + url[len("postgres://"):]
23
+ else:
24
+ raise ValueError(
25
+ f"PostgreSQL URL must start with postgres:// or postgresql:// "
26
+ f"(got {url[:30]!r})"
27
+ )
28
+ return create_engine(url, **pool.sqlalchemy_kwargs())
29
+
30
+ def list_tables(self, engine: Engine, schema: str | None) -> list[str]:
31
+ insp = inspect(engine)
32
+ return sorted(
33
+ insp.get_table_names(schema=schema) + insp.get_view_names(schema=schema)
34
+ )
35
+
36
+ def describe(self, engine: Engine, table: str, schema: str | None) -> list[dict]:
37
+ insp = inspect(engine)
38
+ cols = insp.get_columns(table, schema=schema)
39
+ return [
40
+ {
41
+ "name": c["name"],
42
+ "type": str(c["type"]),
43
+ "nullable": bool(c.get("nullable", True)),
44
+ "default": str(c.get("default")) if c.get("default") is not None else None,
45
+ }
46
+ for c in cols
47
+ ]
48
+
49
+ def quote_ident(self, ident: str) -> str:
50
+ # PostgreSQL standard: double-quote, escape embedded double-quotes.
51
+ return '"' + ident.replace('"', '""') + '"'
52
+
53
+ def server_version(self, engine: Engine) -> str:
54
+ with engine.connect() as conn:
55
+ return str(conn.execute(text("SELECT version()")).scalar())
@@ -0,0 +1,33 @@
1
+ """Redis driver (stub).
2
+
3
+ Placeholder for the future extension. To enable Redis:
4
+
5
+ 1. `uv add "redis>=5.0,<6"`
6
+ 2. Implement the Driver protocol below (sync Redis with ConnectionPool).
7
+ 3. Register it in `drivers/__init__.py:get_driver` (already wired).
8
+
9
+ Until then, `driver = "redis"` in connections.toml will raise NotImplementedError.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from sqlalchemy import Engine
15
+
16
+
17
+ class RedisDriver:
18
+ name = "redis"
19
+
20
+ def make_engine(self, url: str, pool) -> Engine:
21
+ raise NotImplementedError(
22
+ "Redis driver is not yet implemented. "
23
+ "Add redis-py to deps and fill in drivers/redis.py."
24
+ )
25
+
26
+ def list_tables(self, engine: Engine, schema: str | None) -> list[str]:
27
+ raise NotImplementedError("Redis driver is not yet implemented.")
28
+
29
+ def describe(self, engine: Engine, table: str, schema: str | None) -> list[dict]:
30
+ raise NotImplementedError("Redis driver is not yet implemented.")
31
+
32
+ def quote_ident(self, ident: str) -> str:
33
+ raise NotImplementedError("Redis driver is not yet implemented.")
@@ -0,0 +1,39 @@
1
+ """SQLite driver — useful for local dev + tests.
2
+
3
+ Added as a built-in 4th driver so unit tests can use a real SQL backend
4
+ without needing a Postgres or MySQL server.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from sqlalchemy import Engine, create_engine, inspect
10
+
11
+
12
+ class SqliteDriver:
13
+ name = "sqlite"
14
+
15
+ def make_engine(self, url: str, pool) -> Engine:
16
+ # SQLite ignores most pool settings; StaticPool is fine for in-memory.
17
+ if not url.startswith("sqlite:"):
18
+ raise ValueError(f"SQLite URL must start with sqlite: (got {url[:30]!r})")
19
+ return create_engine(url, future=True)
20
+
21
+ def list_tables(self, engine: Engine, schema: str | None) -> list[str]:
22
+ insp = inspect(engine)
23
+ return sorted(insp.get_table_names(schema=schema))
24
+
25
+ def describe(self, engine: Engine, table: str, schema: str | None) -> list[dict]:
26
+ insp = inspect(engine)
27
+ cols = insp.get_columns(table, schema=schema)
28
+ return [
29
+ {
30
+ "name": c["name"],
31
+ "type": str(c["type"]),
32
+ "nullable": bool(c.get("nullable", True)),
33
+ "default": str(c.get("default")) if c.get("default") is not None else None,
34
+ }
35
+ for c in cols
36
+ ]
37
+
38
+ def quote_ident(self, ident: str) -> str:
39
+ return '"' + ident.replace('"', '""') + '"'