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/SKILL.md +61 -0
- sql_harness/__init__.py +27 -0
- sql_harness/agent_loader.py +55 -0
- sql_harness/cli.py +869 -0
- sql_harness/config.py +241 -0
- sql_harness/drivers/__init__.py +59 -0
- sql_harness/drivers/mysql.py +43 -0
- sql_harness/drivers/postgres.py +55 -0
- sql_harness/drivers/redis.py +33 -0
- sql_harness/drivers/sqlite.py +39 -0
- sql_harness/drivers/ssh.py +293 -0
- sql_harness/helpers.py +789 -0
- sql_harness/manager.py +122 -0
- sql_harness/paths.py +112 -0
- sql_harness/run.py +85 -0
- sql_harness-0.1.0.dist-info/METADATA +137 -0
- sql_harness-0.1.0.dist-info/RECORD +19 -0
- sql_harness-0.1.0.dist-info/WHEEL +4 -0
- sql_harness-0.1.0.dist-info/entry_points.txt +2 -0
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('"', '""') + '"'
|