sqlrooms 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.
- sqlrooms/cli.py +576 -0
- sqlrooms/web/__init__.py +0 -0
- sqlrooms/web/db_bridge/__init__.py +28 -0
- sqlrooms/web/db_bridge/connectors/__init__.py +9 -0
- sqlrooms/web/db_bridge/connectors/base.py +59 -0
- sqlrooms/web/db_bridge/connectors/postgres.py +118 -0
- sqlrooms/web/db_bridge/connectors/snowflake.py +161 -0
- sqlrooms/web/db_bridge/factory.py +91 -0
- sqlrooms/web/db_bridge/registry.py +113 -0
- sqlrooms/web/db_bridge/types.py +29 -0
- sqlrooms/web/db_bridge/utils.py +45 -0
- sqlrooms/web/launcher.py +1215 -0
- sqlrooms/web/static/assets/AiSlice-x2gVCmwI.js +137 -0
- sqlrooms/web/static/assets/CommandSlice-DPSuuiIV.js +23 -0
- sqlrooms/web/static/assets/DockLayout-DhgcIQET.js +1 -0
- sqlrooms/web/static/assets/GridLayout-CBVgs-6H.css +1 -0
- sqlrooms/web/static/assets/GridLayout-fXJZYHbE.js +253 -0
- sqlrooms/web/static/assets/LayoutRendererContext-BKO2wB-W.js +1 -0
- sqlrooms/web/static/assets/LeafLayout-DPFHUP6B.js +1 -0
- sqlrooms/web/static/assets/LeafLayout-ekhNDEEg.js +1 -0
- sqlrooms/web/static/assets/RenderNodeContext-BdrX8FaE.js +1 -0
- sqlrooms/web/static/assets/RendererSwitcher-DnVbhqg4.js +1 -0
- sqlrooms/web/static/assets/SplitLayout-fPLAPJN-.js +1 -0
- sqlrooms/web/static/assets/TabsLayout-C0N-7wmx.js +1 -0
- sqlrooms/web/static/assets/TabsLayout-T3iApyr5.js +41 -0
- sqlrooms/web/static/assets/chunk-jRWAZmH_.js +1 -0
- sqlrooms/web/static/assets/codicon-ngg6Pgfi.ttf +0 -0
- sqlrooms/web/static/assets/core.esm-DdCldPzV.js +5 -0
- sqlrooms/web/static/assets/css.worker-Wv5dxAWO.js +89 -0
- sqlrooms/web/static/assets/devtools-BNUn8Jb2.js +2 -0
- sqlrooms/web/static/assets/dist-dwKeDPoe.js +1 -0
- sqlrooms/web/static/assets/html.worker-CQP8QQsS.js +502 -0
- sqlrooms/web/static/assets/index-D9UP9D4f.js +316286 -0
- sqlrooms/web/static/assets/index-DioDnqnf.css +1 -0
- sqlrooms/web/static/assets/json.worker-DzV-CpCQ.js +58 -0
- sqlrooms/web/static/assets/loro_wasm_bg-DP4dC0x3.wasm +0 -0
- sqlrooms/web/static/assets/loro_wasm_bg-VQ4j4Qa9.js +9 -0
- sqlrooms/web/static/assets/loro_wasm_bg-oL0xMWtE.js +3630 -0
- sqlrooms/web/static/assets/maplibre-gl-C-a91wbz.js +748 -0
- sqlrooms/web/static/assets/node-sql-parser-ChfKIXD7.js +68 -0
- sqlrooms/web/static/assets/prop-types-DybOnnvg.js +1 -0
- sqlrooms/web/static/assets/react-dom-liMHu8hH.js +1 -0
- sqlrooms/web/static/assets/resizable-DYr7VLR3.js +1 -0
- sqlrooms/web/static/assets/scroll-area-ZmzNHGEm.js +1 -0
- sqlrooms/web/static/assets/tooltip-mgpsA9tW.js +1 -0
- sqlrooms/web/static/assets/ts.worker-Dth06zuC.js +67734 -0
- sqlrooms/web/static/assets/utils-yJ4l7ARz.js +1 -0
- sqlrooms/web/static/assets/webgl-device-CgQl7NRd.js +1 -0
- sqlrooms/web/static/assets/webgl-device-CtgDFnYR.js +13 -0
- sqlrooms/web/static/index.html +32 -0
- sqlrooms/web/static/logo.png +0 -0
- sqlrooms/web/ui.py +37 -0
- sqlrooms-0.1.0.dist-info/METADATA +274 -0
- sqlrooms-0.1.0.dist-info/RECORD +57 -0
- sqlrooms-0.1.0.dist-info/WHEEL +4 -0
- sqlrooms-0.1.0.dist-info/entry_points.txt +2 -0
- sqlrooms-0.1.0.dist-info/licenses/LICENSE +9 -0
sqlrooms/cli.py
ADDED
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import hashlib
|
|
5
|
+
import logging
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import sys
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import duckdb
|
|
14
|
+
import typer
|
|
15
|
+
|
|
16
|
+
from .web.db_bridge import (
|
|
17
|
+
SUPPORTED_ENGINES,
|
|
18
|
+
PostgresConnectorSettings,
|
|
19
|
+
SnowflakeConnectorSettings,
|
|
20
|
+
)
|
|
21
|
+
from .web.launcher import SqlroomsHttpServer, _pick_free_port
|
|
22
|
+
|
|
23
|
+
logging.basicConfig(
|
|
24
|
+
level=logging.INFO,
|
|
25
|
+
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
26
|
+
)
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
app = typer.Typer(
|
|
30
|
+
add_completion=False,
|
|
31
|
+
pretty_exceptions_enable=False,
|
|
32
|
+
invoke_without_command=True,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
if sys.platform.startswith("win"):
|
|
36
|
+
_config_base = Path(os.environ.get("APPDATA", "")) / "sqlrooms"
|
|
37
|
+
else:
|
|
38
|
+
_config_base = Path.home() / ".config" / "sqlrooms"
|
|
39
|
+
DEFAULT_CONFIG_PATH = _config_base / "config.toml"
|
|
40
|
+
DEFAULT_HTTP_PORT = 4173
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _resolve_http_port(host: str, port: int | None, ws_port: int | None = None) -> int:
|
|
44
|
+
if port is not None:
|
|
45
|
+
return port
|
|
46
|
+
reserved_ports = {ws_port} if ws_port is not None else None
|
|
47
|
+
selected_port = _pick_free_port(
|
|
48
|
+
host,
|
|
49
|
+
DEFAULT_HTTP_PORT,
|
|
50
|
+
reserved_ports=reserved_ports,
|
|
51
|
+
)
|
|
52
|
+
if selected_port != DEFAULT_HTTP_PORT:
|
|
53
|
+
logger.info(
|
|
54
|
+
"Port %s is in use, using HTTP port %s instead",
|
|
55
|
+
DEFAULT_HTTP_PORT,
|
|
56
|
+
selected_port,
|
|
57
|
+
)
|
|
58
|
+
return selected_port
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _normalize_config_string(value: Any) -> str | None:
|
|
62
|
+
if isinstance(value, (int, float)):
|
|
63
|
+
return str(value)
|
|
64
|
+
if not isinstance(value, str):
|
|
65
|
+
return None
|
|
66
|
+
normalized = value.strip()
|
|
67
|
+
return normalized or None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _resolve_config_path(explicit_path: str | None, no_config: bool) -> Path | None:
|
|
71
|
+
if no_config:
|
|
72
|
+
return None
|
|
73
|
+
if explicit_path:
|
|
74
|
+
candidate = Path(explicit_path).expanduser()
|
|
75
|
+
if candidate.exists():
|
|
76
|
+
return candidate
|
|
77
|
+
raise RuntimeError(f"SQLRooms config file not found: {candidate}")
|
|
78
|
+
if DEFAULT_CONFIG_PATH.exists():
|
|
79
|
+
return DEFAULT_CONFIG_PATH
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _read_toml(path: Path) -> dict[str, Any]:
|
|
84
|
+
try:
|
|
85
|
+
import tomllib # type: ignore[attr-defined]
|
|
86
|
+
except ModuleNotFoundError:
|
|
87
|
+
import tomli as tomllib # type: ignore[no-redef]
|
|
88
|
+
with path.open("rb") as fh:
|
|
89
|
+
data = tomllib.load(fh)
|
|
90
|
+
if not isinstance(data, dict):
|
|
91
|
+
raise RuntimeError(f"SQLRooms config must be a TOML object: {path}")
|
|
92
|
+
return data
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _require_config_string(
|
|
96
|
+
payload: dict[str, Any], key: str, *, connector_id: str, engine: str
|
|
97
|
+
) -> str:
|
|
98
|
+
value = _normalize_config_string(payload.get(key))
|
|
99
|
+
if value:
|
|
100
|
+
return value
|
|
101
|
+
raise RuntimeError(
|
|
102
|
+
f"Connector '{connector_id}' ({engine}) requires non-empty '{key}' in config."
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _load_connector_config(
|
|
107
|
+
path: Path | None,
|
|
108
|
+
) -> list[PostgresConnectorSettings | SnowflakeConnectorSettings]:
|
|
109
|
+
if path is None:
|
|
110
|
+
return []
|
|
111
|
+
raw = _read_toml(path)
|
|
112
|
+
db = raw.get("db")
|
|
113
|
+
if not isinstance(db, dict):
|
|
114
|
+
db = {}
|
|
115
|
+
connectors = db.get("connectors") or []
|
|
116
|
+
if not isinstance(connectors, list):
|
|
117
|
+
raise RuntimeError("'db.connectors' must be an array in SQLRooms config.")
|
|
118
|
+
|
|
119
|
+
out: list[PostgresConnectorSettings | SnowflakeConnectorSettings] = []
|
|
120
|
+
seen_ids: set[str] = set()
|
|
121
|
+
for idx, item in enumerate(connectors):
|
|
122
|
+
if not isinstance(item, dict):
|
|
123
|
+
raise RuntimeError(f"Connector entry at index {idx} must be an object.")
|
|
124
|
+
engine = _normalize_config_string(item.get("engine"))
|
|
125
|
+
if engine not in SUPPORTED_ENGINES:
|
|
126
|
+
raise RuntimeError(
|
|
127
|
+
f"Connector entry at index {idx} has unsupported engine: {engine!r}"
|
|
128
|
+
)
|
|
129
|
+
connection_id = _require_config_string(
|
|
130
|
+
item, "id", connector_id=f"#{idx}", engine=engine
|
|
131
|
+
)
|
|
132
|
+
if connection_id in seen_ids:
|
|
133
|
+
raise RuntimeError(f"Duplicate connector id in config: {connection_id}")
|
|
134
|
+
seen_ids.add(connection_id)
|
|
135
|
+
|
|
136
|
+
title = _normalize_config_string(item.get("title")) or connection_id
|
|
137
|
+
if engine == "postgres":
|
|
138
|
+
out.append(
|
|
139
|
+
PostgresConnectorSettings(
|
|
140
|
+
host=_normalize_config_string(item.get("host")) or "localhost",
|
|
141
|
+
port=_normalize_config_string(item.get("port")) or "5432",
|
|
142
|
+
database=_normalize_config_string(item.get("database")) or "",
|
|
143
|
+
user=_normalize_config_string(item.get("user")) or "",
|
|
144
|
+
password=_normalize_config_string(item.get("password")),
|
|
145
|
+
connection_id=connection_id,
|
|
146
|
+
title=title,
|
|
147
|
+
)
|
|
148
|
+
)
|
|
149
|
+
continue
|
|
150
|
+
|
|
151
|
+
out.append(
|
|
152
|
+
SnowflakeConnectorSettings(
|
|
153
|
+
account=_normalize_config_string(item.get("account")),
|
|
154
|
+
user=_normalize_config_string(item.get("user")),
|
|
155
|
+
password=_normalize_config_string(item.get("password")),
|
|
156
|
+
warehouse=_normalize_config_string(item.get("warehouse")),
|
|
157
|
+
database=_normalize_config_string(item.get("database")),
|
|
158
|
+
schema=_normalize_config_string(item.get("schema")),
|
|
159
|
+
role=_normalize_config_string(item.get("role")),
|
|
160
|
+
authenticator=_normalize_config_string(item.get("authenticator")),
|
|
161
|
+
connection_id=connection_id,
|
|
162
|
+
title=title,
|
|
163
|
+
)
|
|
164
|
+
)
|
|
165
|
+
logger.info("Loaded SQLRooms connector config from %s", path)
|
|
166
|
+
return out
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _load_ai_runtime_config(
|
|
170
|
+
path: Path | None,
|
|
171
|
+
) -> tuple[
|
|
172
|
+
str | None,
|
|
173
|
+
str | None,
|
|
174
|
+
dict[str, dict[str, Any]],
|
|
175
|
+
list[dict[str, Any]],
|
|
176
|
+
dict[str, Any],
|
|
177
|
+
]:
|
|
178
|
+
if path is None:
|
|
179
|
+
return (None, None, {}, [], {})
|
|
180
|
+
raw = _read_toml(path)
|
|
181
|
+
ai = raw.get("ai")
|
|
182
|
+
if not isinstance(ai, dict):
|
|
183
|
+
return (None, None, {}, [], {})
|
|
184
|
+
|
|
185
|
+
default_provider = _normalize_config_string(ai.get("default_provider"))
|
|
186
|
+
default_model = _normalize_config_string(ai.get("default_model"))
|
|
187
|
+
providers_raw = ai.get("providers") or []
|
|
188
|
+
if not isinstance(providers_raw, list):
|
|
189
|
+
raise RuntimeError("'ai.providers' must be an array in SQLRooms config.")
|
|
190
|
+
|
|
191
|
+
providers: dict[str, dict[str, Any]] = {}
|
|
192
|
+
for idx, item in enumerate(providers_raw):
|
|
193
|
+
if not isinstance(item, dict):
|
|
194
|
+
raise RuntimeError(f"AI provider entry at index {idx} must be an object.")
|
|
195
|
+
provider_id = _require_config_string(
|
|
196
|
+
item, "id", connector_id=f"ai#{idx}", engine="ai"
|
|
197
|
+
)
|
|
198
|
+
if provider_id in providers:
|
|
199
|
+
raise RuntimeError(f"Duplicate AI provider id in config: {provider_id}")
|
|
200
|
+
base_url = _normalize_config_string(item.get("base_url")) or ""
|
|
201
|
+
api_key = _normalize_config_string(item.get("api_key")) or ""
|
|
202
|
+
api_key_env = _normalize_config_string(item.get("api_key_env"))
|
|
203
|
+
if api_key_env and not api_key:
|
|
204
|
+
api_key = os.environ.get(api_key_env, "")
|
|
205
|
+
models_raw = item.get("models") or []
|
|
206
|
+
if not isinstance(models_raw, list):
|
|
207
|
+
raise RuntimeError(
|
|
208
|
+
f"AI provider '{provider_id}' has invalid 'models' (must be an array)."
|
|
209
|
+
)
|
|
210
|
+
models = []
|
|
211
|
+
for model in models_raw:
|
|
212
|
+
model_name = _normalize_config_string(model)
|
|
213
|
+
if model_name:
|
|
214
|
+
models.append({"modelName": model_name})
|
|
215
|
+
providers[provider_id] = {
|
|
216
|
+
"baseUrl": base_url,
|
|
217
|
+
"apiKey": api_key,
|
|
218
|
+
"models": models,
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
custom_models_raw = ai.get("custom_models") or []
|
|
222
|
+
if not isinstance(custom_models_raw, list):
|
|
223
|
+
raise RuntimeError("'ai.custom_models' must be an array in SQLRooms config.")
|
|
224
|
+
custom_models: list[dict[str, Any]] = []
|
|
225
|
+
for idx, item in enumerate(custom_models_raw):
|
|
226
|
+
if not isinstance(item, dict):
|
|
227
|
+
raise RuntimeError(
|
|
228
|
+
f"AI custom model entry at index {idx} must be an object."
|
|
229
|
+
)
|
|
230
|
+
model_name = _normalize_config_string(
|
|
231
|
+
item.get("model_name") or item.get("modelName")
|
|
232
|
+
)
|
|
233
|
+
base_url = _normalize_config_string(item.get("base_url") or item.get("baseUrl"))
|
|
234
|
+
if not model_name or not base_url:
|
|
235
|
+
raise RuntimeError(
|
|
236
|
+
f"AI custom model entry at index {idx} requires 'model_name' and 'base_url'."
|
|
237
|
+
)
|
|
238
|
+
custom_models.append(
|
|
239
|
+
{
|
|
240
|
+
"modelName": model_name,
|
|
241
|
+
"baseUrl": base_url,
|
|
242
|
+
"apiKey": _normalize_config_string(item.get("api_key")) or "",
|
|
243
|
+
}
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
model_parameters_raw = ai.get("model_parameters") or {}
|
|
247
|
+
if not isinstance(model_parameters_raw, dict):
|
|
248
|
+
raise RuntimeError(
|
|
249
|
+
"'ai.model_parameters' must be an object in SQLRooms config."
|
|
250
|
+
)
|
|
251
|
+
model_parameters: dict[str, Any] = {}
|
|
252
|
+
if "max_steps" in model_parameters_raw:
|
|
253
|
+
max_steps = model_parameters_raw.get("max_steps")
|
|
254
|
+
if not isinstance(max_steps, int):
|
|
255
|
+
raise RuntimeError("'ai.model_parameters.max_steps' must be an integer.")
|
|
256
|
+
model_parameters["maxSteps"] = max_steps
|
|
257
|
+
additional_instruction = model_parameters_raw.get(
|
|
258
|
+
"additional_instruction",
|
|
259
|
+
model_parameters_raw.get("additionalInstruction"),
|
|
260
|
+
)
|
|
261
|
+
if isinstance(additional_instruction, str):
|
|
262
|
+
model_parameters["additionalInstruction"] = additional_instruction
|
|
263
|
+
|
|
264
|
+
if (
|
|
265
|
+
default_provider
|
|
266
|
+
and default_provider not in providers
|
|
267
|
+
and default_provider != "custom"
|
|
268
|
+
):
|
|
269
|
+
raise RuntimeError(
|
|
270
|
+
f"AI default_provider '{default_provider}' is not defined under ai.providers."
|
|
271
|
+
)
|
|
272
|
+
if not default_provider and providers:
|
|
273
|
+
default_provider = next(iter(providers.keys()))
|
|
274
|
+
|
|
275
|
+
if not default_model and default_provider:
|
|
276
|
+
provider = providers.get(default_provider, {})
|
|
277
|
+
models = provider.get("models") or []
|
|
278
|
+
if models:
|
|
279
|
+
default_model = models[0].get("modelName")
|
|
280
|
+
if default_provider == "custom" and not default_model and custom_models:
|
|
281
|
+
default_model = custom_models[0].get("modelName")
|
|
282
|
+
logger.info("Loaded SQLRooms AI config from %s", path)
|
|
283
|
+
return (default_provider, default_model, providers, custom_models, model_parameters)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
@app.command("export")
|
|
287
|
+
def export_project(
|
|
288
|
+
db_path: str = typer.Argument(
|
|
289
|
+
...,
|
|
290
|
+
help="DuckDB project file to export from.",
|
|
291
|
+
),
|
|
292
|
+
out_dir: str = typer.Option(
|
|
293
|
+
"./out",
|
|
294
|
+
"--dir",
|
|
295
|
+
help="Output directory for exported artifacts.",
|
|
296
|
+
),
|
|
297
|
+
meta_namespace: str = typer.Option(
|
|
298
|
+
"__sqlrooms",
|
|
299
|
+
"--meta-namespace",
|
|
300
|
+
help="Namespace for SQLRooms meta tables.",
|
|
301
|
+
),
|
|
302
|
+
):
|
|
303
|
+
"""
|
|
304
|
+
Export app-builder files stored in persisted UI state to a directory.
|
|
305
|
+
"""
|
|
306
|
+
out = Path(out_dir).expanduser().resolve()
|
|
307
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
308
|
+
con = duckdb.connect(db_path)
|
|
309
|
+
try:
|
|
310
|
+
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", meta_namespace):
|
|
311
|
+
typer.echo(
|
|
312
|
+
f"Invalid --meta-namespace value: {meta_namespace!r}",
|
|
313
|
+
err=True,
|
|
314
|
+
)
|
|
315
|
+
raise typer.Exit(code=1)
|
|
316
|
+
ui_ref = f'"{meta_namespace}"."ui_state"'
|
|
317
|
+
row = con.execute(
|
|
318
|
+
f"SELECT payload_json FROM {ui_ref} WHERE key = 'default' LIMIT 1"
|
|
319
|
+
).fetchone()
|
|
320
|
+
if not row:
|
|
321
|
+
typer.echo("No persisted ui_state found; nothing to export.")
|
|
322
|
+
return
|
|
323
|
+
payload = row[0]
|
|
324
|
+
if isinstance(payload, str):
|
|
325
|
+
try:
|
|
326
|
+
payload = json.loads(payload)
|
|
327
|
+
except json.JSONDecodeError as exc:
|
|
328
|
+
typer.echo(
|
|
329
|
+
f"Failed to parse payload_json from {ui_ref}: {exc}",
|
|
330
|
+
err=True,
|
|
331
|
+
)
|
|
332
|
+
raise typer.Exit(code=1) from exc
|
|
333
|
+
if not isinstance(payload, dict):
|
|
334
|
+
typer.echo(
|
|
335
|
+
"Invalid payload_json format: expected a JSON object.",
|
|
336
|
+
err=True,
|
|
337
|
+
)
|
|
338
|
+
raise typer.Exit(code=1)
|
|
339
|
+
|
|
340
|
+
app_project = (payload or {}).get("appProject") or {}
|
|
341
|
+
config = app_project.get("config") or {}
|
|
342
|
+
apps_by_sheet = config.get("appsBySheetId") or {}
|
|
343
|
+
exported = 0
|
|
344
|
+
for sheet_id, sheet_app in apps_by_sheet.items():
|
|
345
|
+
files = (sheet_app or {}).get("files") or {}
|
|
346
|
+
if not isinstance(files, dict) or len(files) == 0:
|
|
347
|
+
continue
|
|
348
|
+
safe_sheet_id = "".join(
|
|
349
|
+
ch for ch in str(sheet_id) if ch.isalnum() or ch in ("-", "_")
|
|
350
|
+
)
|
|
351
|
+
if not safe_sheet_id:
|
|
352
|
+
safe_sheet_id = hashlib.sha1(str(sheet_id).encode("utf-8")).hexdigest()[
|
|
353
|
+
:8
|
|
354
|
+
]
|
|
355
|
+
name = str((sheet_app or {}).get("name") or sheet_id)
|
|
356
|
+
safe_name = "".join(
|
|
357
|
+
ch for ch in name if ch.isalnum() or ch in ("-", "_", " ")
|
|
358
|
+
).strip()
|
|
359
|
+
safe_name = safe_name.replace(" ", "-") or safe_sheet_id
|
|
360
|
+
root = out / f"{safe_name}-{safe_sheet_id[:8]}"
|
|
361
|
+
root_resolved = root.resolve()
|
|
362
|
+
try:
|
|
363
|
+
root_resolved.relative_to(out)
|
|
364
|
+
except ValueError:
|
|
365
|
+
typer.echo(
|
|
366
|
+
f"Unsafe export root path resolved outside output dir: {root_resolved}",
|
|
367
|
+
err=True,
|
|
368
|
+
)
|
|
369
|
+
raise typer.Exit(code=1)
|
|
370
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
371
|
+
meta = {
|
|
372
|
+
"sheetId": sheet_id,
|
|
373
|
+
"name": name,
|
|
374
|
+
"prompt": (sheet_app or {}).get("prompt", ""),
|
|
375
|
+
"template": (sheet_app or {}).get("template", ""),
|
|
376
|
+
"updatedAt": (sheet_app or {}).get("updatedAt"),
|
|
377
|
+
}
|
|
378
|
+
(root / "app.json").write_text(json.dumps(meta, indent=2), encoding="utf-8")
|
|
379
|
+
for path, content in files.items():
|
|
380
|
+
rel = Path(str(path).lstrip("/"))
|
|
381
|
+
target = (root / rel).resolve()
|
|
382
|
+
try:
|
|
383
|
+
target.relative_to(root_resolved)
|
|
384
|
+
except ValueError:
|
|
385
|
+
typer.echo(
|
|
386
|
+
f"Skipping unsafe export path outside target directory: {path!r}",
|
|
387
|
+
err=True,
|
|
388
|
+
)
|
|
389
|
+
continue
|
|
390
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
391
|
+
target.write_text(str(content), encoding="utf-8")
|
|
392
|
+
exported += 1
|
|
393
|
+
if exported == 0:
|
|
394
|
+
typer.echo("No app-builder files found in ui_state.")
|
|
395
|
+
return
|
|
396
|
+
typer.echo(f"Exported artifacts to {out}")
|
|
397
|
+
finally:
|
|
398
|
+
con.close()
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
@app.callback(invoke_without_command=True)
|
|
402
|
+
def main(
|
|
403
|
+
db_path: str | None = typer.Argument(
|
|
404
|
+
None,
|
|
405
|
+
help="DuckDB database to use (positional). Pass a filepath to persist, or ':memory:' for an in-memory DB (no file).",
|
|
406
|
+
),
|
|
407
|
+
db_path_option: str | None = typer.Option(
|
|
408
|
+
None,
|
|
409
|
+
"--db-path",
|
|
410
|
+
"-d",
|
|
411
|
+
help="DuckDB database to use (flag). Pass a filepath to persist, or ':memory:' for an in-memory DB (no file).",
|
|
412
|
+
show_default=False,
|
|
413
|
+
),
|
|
414
|
+
host: str = typer.Option("127.0.0.1", "--host", help="HTTP host for the UI."),
|
|
415
|
+
port: int | None = typer.Option(
|
|
416
|
+
None,
|
|
417
|
+
"--port",
|
|
418
|
+
help="HTTP port for the UI. If omitted, 4173 or the next free port is chosen automatically.",
|
|
419
|
+
),
|
|
420
|
+
ws_port: int | None = typer.Option(
|
|
421
|
+
None,
|
|
422
|
+
"--ws-port",
|
|
423
|
+
help="WebSocket port for DuckDB queries. If omitted, a free port is chosen automatically.",
|
|
424
|
+
),
|
|
425
|
+
config: str | None = typer.Option(
|
|
426
|
+
None,
|
|
427
|
+
"--config",
|
|
428
|
+
envvar="SQLROOMS_CONFIG",
|
|
429
|
+
help="Path to a SQLRooms TOML config file. Defaults to ~/.config/sqlrooms/config.toml (%%APPDATA%%\\sqlrooms\\config.toml on Windows).",
|
|
430
|
+
),
|
|
431
|
+
no_config: bool = typer.Option(
|
|
432
|
+
False,
|
|
433
|
+
"--no-config",
|
|
434
|
+
help="Disable loading settings from config file.",
|
|
435
|
+
),
|
|
436
|
+
no_open_browser: bool = typer.Option(
|
|
437
|
+
False, "--no-open-browser", help="Skip automatically opening the browser."
|
|
438
|
+
),
|
|
439
|
+
ui: str = typer.Option(
|
|
440
|
+
None,
|
|
441
|
+
"--ui",
|
|
442
|
+
help="Optional path to a custom UI bundle directory (Vite dist). If omitted, uses the bundled default UI.",
|
|
443
|
+
),
|
|
444
|
+
no_ui: bool = typer.Option(
|
|
445
|
+
False,
|
|
446
|
+
"--no-ui",
|
|
447
|
+
help="Start only the HTTP API server and DuckDB websocket backend; do not serve the bundled/static UI.",
|
|
448
|
+
),
|
|
449
|
+
experimental: bool = typer.Option(
|
|
450
|
+
False,
|
|
451
|
+
"--experimental",
|
|
452
|
+
help="Enable experimental artifacts, blocks, commands, and agent tools.",
|
|
453
|
+
),
|
|
454
|
+
experimental_sync: bool = typer.Option(
|
|
455
|
+
False,
|
|
456
|
+
"--experimental-sync",
|
|
457
|
+
help="Enable experimental sync (CRDT) over WebSocket (Loro). Requires --experimental.",
|
|
458
|
+
),
|
|
459
|
+
legacy_sync: bool = typer.Option(
|
|
460
|
+
False,
|
|
461
|
+
"--sync",
|
|
462
|
+
hidden=True,
|
|
463
|
+
),
|
|
464
|
+
ai_devtools: bool = typer.Option(
|
|
465
|
+
False,
|
|
466
|
+
"--ai-devtools",
|
|
467
|
+
envvar="SQLROOMS_AI_DEVTOOLS",
|
|
468
|
+
help="Enable the AI session devtools button in the UI, including production-built UI bundles.",
|
|
469
|
+
),
|
|
470
|
+
meta_db: str | None = typer.Option(
|
|
471
|
+
None,
|
|
472
|
+
"--meta-db",
|
|
473
|
+
help="Optional path to a dedicated DuckDB file for SQLRooms meta state (UI state + CRDT snapshots). If omitted, stores meta tables in the main DB.",
|
|
474
|
+
),
|
|
475
|
+
meta_namespace: str = typer.Option(
|
|
476
|
+
"__sqlrooms",
|
|
477
|
+
"--meta-namespace",
|
|
478
|
+
help="Namespace used for SQLRooms meta tables. If --meta-db is provided, this is the ATTACH alias; otherwise it's a schema in the main DB.",
|
|
479
|
+
),
|
|
480
|
+
external_url: str | None = typer.Option(
|
|
481
|
+
None,
|
|
482
|
+
"--external-url",
|
|
483
|
+
envvar="SQLROOMS_EXTERNAL_URL",
|
|
484
|
+
help="Public HTTP base URL to expose in runtime config, e.g. https://my-sprite.sprites.dev.",
|
|
485
|
+
),
|
|
486
|
+
external_ws_url: str | None = typer.Option(
|
|
487
|
+
None,
|
|
488
|
+
"--external-ws-url",
|
|
489
|
+
envvar="SQLROOMS_EXTERNAL_WS_URL",
|
|
490
|
+
help="Public DuckDB websocket URL to expose in runtime config, e.g. wss://my-sprite.sprites.dev:4000.",
|
|
491
|
+
),
|
|
492
|
+
):
|
|
493
|
+
"""
|
|
494
|
+
Launch a local SQLRooms project for adding data and building worksheets with Mosaic charts and dashboards.
|
|
495
|
+
|
|
496
|
+
Example: sqlrooms ./my-project.duckdb
|
|
497
|
+
|
|
498
|
+
- Boots a DuckDB websocket server (sqlrooms-server).
|
|
499
|
+
- Serves the worksheet UI with persisted state stored in DuckDB.
|
|
500
|
+
"""
|
|
501
|
+
if legacy_sync:
|
|
502
|
+
typer.echo(
|
|
503
|
+
"--sync has been renamed to --experimental-sync and is not part of the public launch surface.",
|
|
504
|
+
err=True,
|
|
505
|
+
)
|
|
506
|
+
raise typer.Exit(code=1)
|
|
507
|
+
if experimental_sync and not experimental:
|
|
508
|
+
typer.echo("--experimental-sync requires --experimental.", err=True)
|
|
509
|
+
raise typer.Exit(code=1)
|
|
510
|
+
|
|
511
|
+
resolved_db_path = db_path if db_path is not None else db_path_option
|
|
512
|
+
if resolved_db_path is None:
|
|
513
|
+
typer.echo(
|
|
514
|
+
"Please provide a DuckDB project file, e.g. `sqlrooms ./my-project.duckdb`, "
|
|
515
|
+
"or pass `--db-path :memory:` for a temporary in-memory session.",
|
|
516
|
+
err=True,
|
|
517
|
+
)
|
|
518
|
+
raise typer.Exit(code=1)
|
|
519
|
+
|
|
520
|
+
try:
|
|
521
|
+
config_path = _resolve_config_path(config, no_config=no_config)
|
|
522
|
+
connector_settings = _load_connector_config(config_path)
|
|
523
|
+
(
|
|
524
|
+
llm_provider,
|
|
525
|
+
llm_model,
|
|
526
|
+
ai_providers,
|
|
527
|
+
ai_custom_models,
|
|
528
|
+
ai_model_parameters,
|
|
529
|
+
) = _load_ai_runtime_config(config_path)
|
|
530
|
+
except Exception as exc:
|
|
531
|
+
typer.echo(str(exc), err=True)
|
|
532
|
+
raise typer.Exit(code=1) from exc
|
|
533
|
+
|
|
534
|
+
# config_path may be None when the file doesn't exist yet; for saving we
|
|
535
|
+
# still want a writable target unless the user explicitly opted out.
|
|
536
|
+
save_config_path = (
|
|
537
|
+
config_path if config_path else (None if no_config else DEFAULT_CONFIG_PATH)
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
selected_port = _resolve_http_port(host, port, ws_port)
|
|
541
|
+
selected_api_key = (
|
|
542
|
+
str(ai_providers.get(llm_provider or "", {}).get("apiKey") or "")
|
|
543
|
+
if llm_provider
|
|
544
|
+
else ""
|
|
545
|
+
)
|
|
546
|
+
server = SqlroomsHttpServer(
|
|
547
|
+
db_path=resolved_db_path,
|
|
548
|
+
host=host,
|
|
549
|
+
port=selected_port,
|
|
550
|
+
ws_port=ws_port,
|
|
551
|
+
sync_enabled=experimental_sync,
|
|
552
|
+
experimental_enabled=experimental,
|
|
553
|
+
meta_db=meta_db,
|
|
554
|
+
meta_namespace=meta_namespace,
|
|
555
|
+
llm_provider=llm_provider,
|
|
556
|
+
llm_model=llm_model,
|
|
557
|
+
api_key=selected_api_key,
|
|
558
|
+
ai_providers=ai_providers,
|
|
559
|
+
ai_custom_models=ai_custom_models,
|
|
560
|
+
ai_model_parameters=ai_model_parameters,
|
|
561
|
+
connector_settings=connector_settings,
|
|
562
|
+
open_browser=not no_open_browser,
|
|
563
|
+
ui_dir=ui,
|
|
564
|
+
serve_ui=not no_ui,
|
|
565
|
+
config_path=save_config_path,
|
|
566
|
+
external_url=external_url,
|
|
567
|
+
external_ws_url=external_ws_url,
|
|
568
|
+
ai_devtools=ai_devtools,
|
|
569
|
+
)
|
|
570
|
+
try:
|
|
571
|
+
asyncio.run(server.start())
|
|
572
|
+
except RuntimeError as exc:
|
|
573
|
+
typer.echo(str(exc), err=True)
|
|
574
|
+
raise typer.Exit(code=1) from exc
|
|
575
|
+
except KeyboardInterrupt:
|
|
576
|
+
sys.stderr.write("\nShutting down...\n")
|
sqlrooms/web/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from .connectors import (
|
|
2
|
+
PostgresBridgeConnector,
|
|
3
|
+
PostgresConnectorSettings,
|
|
4
|
+
SnowflakeBridgeConnector,
|
|
5
|
+
SnowflakeConnectorSettings,
|
|
6
|
+
)
|
|
7
|
+
from .factory import (
|
|
8
|
+
ENGINE_CONFIG_FIELDS,
|
|
9
|
+
SUPPORTED_ENGINES,
|
|
10
|
+
build_cli_db_bridge_registry,
|
|
11
|
+
build_ephemeral_connector,
|
|
12
|
+
)
|
|
13
|
+
from .registry import DbBridgeRegistry, UnknownBridgeConnectionError
|
|
14
|
+
from .types import DbBridgeConnector
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"DbBridgeConnector",
|
|
18
|
+
"DbBridgeRegistry",
|
|
19
|
+
"ENGINE_CONFIG_FIELDS",
|
|
20
|
+
"SUPPORTED_ENGINES",
|
|
21
|
+
"UnknownBridgeConnectionError",
|
|
22
|
+
"PostgresBridgeConnector",
|
|
23
|
+
"PostgresConnectorSettings",
|
|
24
|
+
"SnowflakeBridgeConnector",
|
|
25
|
+
"SnowflakeConnectorSettings",
|
|
26
|
+
"build_cli_db_bridge_registry",
|
|
27
|
+
"build_ephemeral_connector",
|
|
28
|
+
]
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
from .postgres import PostgresBridgeConnector, PostgresConnectorSettings
|
|
2
|
+
from .snowflake import SnowflakeBridgeConnector, SnowflakeConnectorSettings
|
|
3
|
+
|
|
4
|
+
__all__ = [
|
|
5
|
+
"PostgresBridgeConnector",
|
|
6
|
+
"PostgresConnectorSettings",
|
|
7
|
+
"SnowflakeBridgeConnector",
|
|
8
|
+
"SnowflakeConnectorSettings",
|
|
9
|
+
]
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Iterable
|
|
4
|
+
|
|
5
|
+
from ..utils import cursor_columns, rows_to_arrow_bytes, rows_to_json_rows
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class BaseSqlBridgeConnector:
|
|
9
|
+
"""
|
|
10
|
+
Shared SQL execution helpers for bridge connectors.
|
|
11
|
+
|
|
12
|
+
Subclasses provide `_connect()` and any engine-specific catalog/diagnostics logic.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def _connect(self):
|
|
16
|
+
raise NotImplementedError
|
|
17
|
+
|
|
18
|
+
def test_connection(self) -> bool:
|
|
19
|
+
with self._connect() as conn:
|
|
20
|
+
with conn.cursor() as cur:
|
|
21
|
+
cur.execute("SELECT 1")
|
|
22
|
+
cur.fetchone()
|
|
23
|
+
return True
|
|
24
|
+
|
|
25
|
+
def execute_query(self, sql: str, query_type: str) -> dict[str, Any]:
|
|
26
|
+
with self._connect() as conn:
|
|
27
|
+
with conn.cursor() as cur:
|
|
28
|
+
cur.execute(sql)
|
|
29
|
+
if query_type == "exec":
|
|
30
|
+
return {"ok": True}
|
|
31
|
+
rows = cur.fetchall()
|
|
32
|
+
columns = cursor_columns(cur)
|
|
33
|
+
return {"jsonData": rows_to_json_rows(rows, columns)}
|
|
34
|
+
|
|
35
|
+
def fetch_arrow_bytes(self, sql: str) -> bytes:
|
|
36
|
+
with self._connect() as conn:
|
|
37
|
+
with conn.cursor() as cur:
|
|
38
|
+
cur.execute(sql)
|
|
39
|
+
rows = cur.fetchall()
|
|
40
|
+
columns = cursor_columns(cur)
|
|
41
|
+
return rows_to_arrow_bytes(rows, columns)
|
|
42
|
+
|
|
43
|
+
def stream_arrow_batches(
|
|
44
|
+
self, sql: str, chunk_rows: int = 5000, query_id: str | None = None
|
|
45
|
+
) -> Iterable[bytes]:
|
|
46
|
+
_ = query_id
|
|
47
|
+
with self._connect() as conn:
|
|
48
|
+
with conn.cursor() as cur:
|
|
49
|
+
cur.execute(sql)
|
|
50
|
+
columns = cursor_columns(cur)
|
|
51
|
+
while True:
|
|
52
|
+
rows = cur.fetchmany(max(1, int(chunk_rows)))
|
|
53
|
+
if not rows:
|
|
54
|
+
break
|
|
55
|
+
yield rows_to_arrow_bytes(rows, columns)
|
|
56
|
+
|
|
57
|
+
def cancel_query(self, query_id: str) -> bool:
|
|
58
|
+
_ = query_id
|
|
59
|
+
return False
|