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.
Files changed (57) hide show
  1. sqlrooms/cli.py +576 -0
  2. sqlrooms/web/__init__.py +0 -0
  3. sqlrooms/web/db_bridge/__init__.py +28 -0
  4. sqlrooms/web/db_bridge/connectors/__init__.py +9 -0
  5. sqlrooms/web/db_bridge/connectors/base.py +59 -0
  6. sqlrooms/web/db_bridge/connectors/postgres.py +118 -0
  7. sqlrooms/web/db_bridge/connectors/snowflake.py +161 -0
  8. sqlrooms/web/db_bridge/factory.py +91 -0
  9. sqlrooms/web/db_bridge/registry.py +113 -0
  10. sqlrooms/web/db_bridge/types.py +29 -0
  11. sqlrooms/web/db_bridge/utils.py +45 -0
  12. sqlrooms/web/launcher.py +1215 -0
  13. sqlrooms/web/static/assets/AiSlice-x2gVCmwI.js +137 -0
  14. sqlrooms/web/static/assets/CommandSlice-DPSuuiIV.js +23 -0
  15. sqlrooms/web/static/assets/DockLayout-DhgcIQET.js +1 -0
  16. sqlrooms/web/static/assets/GridLayout-CBVgs-6H.css +1 -0
  17. sqlrooms/web/static/assets/GridLayout-fXJZYHbE.js +253 -0
  18. sqlrooms/web/static/assets/LayoutRendererContext-BKO2wB-W.js +1 -0
  19. sqlrooms/web/static/assets/LeafLayout-DPFHUP6B.js +1 -0
  20. sqlrooms/web/static/assets/LeafLayout-ekhNDEEg.js +1 -0
  21. sqlrooms/web/static/assets/RenderNodeContext-BdrX8FaE.js +1 -0
  22. sqlrooms/web/static/assets/RendererSwitcher-DnVbhqg4.js +1 -0
  23. sqlrooms/web/static/assets/SplitLayout-fPLAPJN-.js +1 -0
  24. sqlrooms/web/static/assets/TabsLayout-C0N-7wmx.js +1 -0
  25. sqlrooms/web/static/assets/TabsLayout-T3iApyr5.js +41 -0
  26. sqlrooms/web/static/assets/chunk-jRWAZmH_.js +1 -0
  27. sqlrooms/web/static/assets/codicon-ngg6Pgfi.ttf +0 -0
  28. sqlrooms/web/static/assets/core.esm-DdCldPzV.js +5 -0
  29. sqlrooms/web/static/assets/css.worker-Wv5dxAWO.js +89 -0
  30. sqlrooms/web/static/assets/devtools-BNUn8Jb2.js +2 -0
  31. sqlrooms/web/static/assets/dist-dwKeDPoe.js +1 -0
  32. sqlrooms/web/static/assets/html.worker-CQP8QQsS.js +502 -0
  33. sqlrooms/web/static/assets/index-D9UP9D4f.js +316286 -0
  34. sqlrooms/web/static/assets/index-DioDnqnf.css +1 -0
  35. sqlrooms/web/static/assets/json.worker-DzV-CpCQ.js +58 -0
  36. sqlrooms/web/static/assets/loro_wasm_bg-DP4dC0x3.wasm +0 -0
  37. sqlrooms/web/static/assets/loro_wasm_bg-VQ4j4Qa9.js +9 -0
  38. sqlrooms/web/static/assets/loro_wasm_bg-oL0xMWtE.js +3630 -0
  39. sqlrooms/web/static/assets/maplibre-gl-C-a91wbz.js +748 -0
  40. sqlrooms/web/static/assets/node-sql-parser-ChfKIXD7.js +68 -0
  41. sqlrooms/web/static/assets/prop-types-DybOnnvg.js +1 -0
  42. sqlrooms/web/static/assets/react-dom-liMHu8hH.js +1 -0
  43. sqlrooms/web/static/assets/resizable-DYr7VLR3.js +1 -0
  44. sqlrooms/web/static/assets/scroll-area-ZmzNHGEm.js +1 -0
  45. sqlrooms/web/static/assets/tooltip-mgpsA9tW.js +1 -0
  46. sqlrooms/web/static/assets/ts.worker-Dth06zuC.js +67734 -0
  47. sqlrooms/web/static/assets/utils-yJ4l7ARz.js +1 -0
  48. sqlrooms/web/static/assets/webgl-device-CgQl7NRd.js +1 -0
  49. sqlrooms/web/static/assets/webgl-device-CtgDFnYR.js +13 -0
  50. sqlrooms/web/static/index.html +32 -0
  51. sqlrooms/web/static/logo.png +0 -0
  52. sqlrooms/web/ui.py +37 -0
  53. sqlrooms-0.1.0.dist-info/METADATA +274 -0
  54. sqlrooms-0.1.0.dist-info/RECORD +57 -0
  55. sqlrooms-0.1.0.dist-info/WHEEL +4 -0
  56. sqlrooms-0.1.0.dist-info/entry_points.txt +2 -0
  57. 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")
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