provsql-studio 1.0.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 (55) hide show
  1. provsql_studio/__init__.py +2 -0
  2. provsql_studio/__main__.py +4 -0
  3. provsql_studio/app.py +667 -0
  4. provsql_studio/circuit.py +346 -0
  5. provsql_studio/cli.py +232 -0
  6. provsql_studio/db.py +1657 -0
  7. provsql_studio/static/app.css +2008 -0
  8. provsql_studio/static/app.js +2068 -0
  9. provsql_studio/static/circuit.js +1741 -0
  10. provsql_studio/static/colors_and_type.css +174 -0
  11. provsql_studio/static/fonts/EBGaramond-400-Italic-greek-ext.woff2 +0 -0
  12. provsql_studio/static/fonts/EBGaramond-400-Italic-greek.woff2 +0 -0
  13. provsql_studio/static/fonts/EBGaramond-400-Italic-latin-ext.woff2 +0 -0
  14. provsql_studio/static/fonts/EBGaramond-400-Italic-latin.woff2 +0 -0
  15. provsql_studio/static/fonts/EBGaramond-400-greek-ext.woff2 +0 -0
  16. provsql_studio/static/fonts/EBGaramond-400-greek.woff2 +0 -0
  17. provsql_studio/static/fonts/EBGaramond-400-latin-ext.woff2 +0 -0
  18. provsql_studio/static/fonts/EBGaramond-400-latin.woff2 +0 -0
  19. provsql_studio/static/fonts/EBGaramond-600-Italic-greek-ext.woff2 +0 -0
  20. provsql_studio/static/fonts/EBGaramond-600-Italic-greek.woff2 +0 -0
  21. provsql_studio/static/fonts/EBGaramond-600-Italic-latin-ext.woff2 +0 -0
  22. provsql_studio/static/fonts/EBGaramond-600-Italic-latin.woff2 +0 -0
  23. provsql_studio/static/fonts/EBGaramond-600-greek-ext.woff2 +0 -0
  24. provsql_studio/static/fonts/EBGaramond-600-greek.woff2 +0 -0
  25. provsql_studio/static/fonts/EBGaramond-600-latin-ext.woff2 +0 -0
  26. provsql_studio/static/fonts/EBGaramond-600-latin.woff2 +0 -0
  27. provsql_studio/static/fonts/FiraCode-400-greek-ext.woff2 +0 -0
  28. provsql_studio/static/fonts/FiraCode-400-greek.woff2 +0 -0
  29. provsql_studio/static/fonts/FiraCode-400-latin-ext.woff2 +0 -0
  30. provsql_studio/static/fonts/FiraCode-400-latin.woff2 +0 -0
  31. provsql_studio/static/fonts/FiraCode-400-symbols2.woff2 +0 -0
  32. provsql_studio/static/fonts/FiraCode-500-greek-ext.woff2 +0 -0
  33. provsql_studio/static/fonts/FiraCode-500-greek.woff2 +0 -0
  34. provsql_studio/static/fonts/FiraCode-500-latin-ext.woff2 +0 -0
  35. provsql_studio/static/fonts/FiraCode-500-latin.woff2 +0 -0
  36. provsql_studio/static/fonts/FiraCode-500-symbols2.woff2 +0 -0
  37. provsql_studio/static/fonts/Jost-400-latin-ext.woff2 +0 -0
  38. provsql_studio/static/fonts/Jost-400-latin.woff2 +0 -0
  39. provsql_studio/static/fonts/Jost-500-latin-ext.woff2 +0 -0
  40. provsql_studio/static/fonts/Jost-500-latin.woff2 +0 -0
  41. provsql_studio/static/fonts/Jost-600-latin-ext.woff2 +0 -0
  42. provsql_studio/static/fonts/Jost-600-latin.woff2 +0 -0
  43. provsql_studio/static/fonts/OFL-EBGaramond.txt +94 -0
  44. provsql_studio/static/fonts/OFL-FiraCode.txt +93 -0
  45. provsql_studio/static/fonts/OFL-Jost.txt +83 -0
  46. provsql_studio/static/fonts-face.css +198 -0
  47. provsql_studio/static/img/favicon.ico +0 -0
  48. provsql_studio/static/img/logo.png +0 -0
  49. provsql_studio/static/index.html +303 -0
  50. provsql_studio-1.0.0.dist-info/METADATA +143 -0
  51. provsql_studio-1.0.0.dist-info/RECORD +55 -0
  52. provsql_studio-1.0.0.dist-info/WHEEL +5 -0
  53. provsql_studio-1.0.0.dist-info/entry_points.txt +2 -0
  54. provsql_studio-1.0.0.dist-info/licenses/LICENSE +21 -0
  55. provsql_studio-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,2 @@
1
+ """ProvSQL Studio: web UI for the ProvSQL PostgreSQL extension."""
2
+ __version__ = "1.0.0"
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
provsql_studio/app.py ADDED
@@ -0,0 +1,667 @@
1
+ """Flask app factory + routes for ProvSQL Studio.
2
+
3
+ Routes:
4
+ GET /, /where, /circuit – serve the shared shell with a body class.
5
+ GET /api/conn – current_user / current_database / host.
6
+ POST /api/conn – swap the active database (rebuilds the pool).
7
+ GET /api/databases – list databases the current user can connect to.
8
+ GET /api/relations – list provenance-tagged relations + content.
9
+ GET /api/schema – list all SELECT-able tables/views with their columns.
10
+ POST /api/exec – run a SQL batch; only the last statement's result is shown.
11
+ POST /api/cancel/<id> – pg_cancel_backend the batch in flight under that request id.
12
+ GET /api/circuit/<uuid> – BFS subgraph + dot-layout for a circuit root.
13
+ POST /api/circuit/<uuid>/expand – fetch a sub-DAG rooted at a frontier node.
14
+ GET /api/leaf/<uuid> – resolve an input gate back to its source row.
15
+ GET /api/config – read the four whitelisted GUCs.
16
+ POST /api/config – write one whitelisted GUC.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import re
21
+ import threading
22
+ import uuid as uuid_mod
23
+ from pathlib import Path
24
+
25
+ import psycopg
26
+ import psycopg.conninfo
27
+ import sqlparse
28
+ from flask import Flask, jsonify, redirect, request, send_from_directory
29
+
30
+ from . import circuit as circuit_mod
31
+ from . import db
32
+
33
+
34
+ _STATIC_DIR = Path(__file__).resolve().parent / "static"
35
+
36
+ # Statements that are wrappable for where-mode (i.e. their last statement is
37
+ # a SELECT we can plug into `SELECT *, provenance(), where_provenance(...) FROM (<last>) t`).
38
+ _WRAPPABLE_RE = re.compile(r"^\s*(WITH|SELECT)\b", re.IGNORECASE)
39
+
40
+
41
+ def create_app(
42
+ *,
43
+ dsn: str | None = None,
44
+ statement_timeout: str = "30s",
45
+ max_circuit_depth: int = 8,
46
+ max_circuit_nodes: int = 500,
47
+ max_sidebar_rows: int = 100,
48
+ max_result_rows: int = 1000,
49
+ search_path: str = "",
50
+ tool_search_path: str = "",
51
+ db_is_auto: bool = False,
52
+ ) -> Flask:
53
+ app = Flask(__name__, static_folder=None) # we serve /static/ ourselves
54
+ app.config.update(
55
+ STATEMENT_TIMEOUT=statement_timeout,
56
+ MAX_CIRCUIT_DEPTH=max_circuit_depth,
57
+ MAX_CIRCUIT_NODES=max_circuit_nodes,
58
+ MAX_SIDEBAR_ROWS=max_sidebar_rows,
59
+ MAX_RESULT_ROWS=max_result_rows,
60
+ SEARCH_PATH=search_path,
61
+ TOOL_SEARCH_PATH=tool_search_path,
62
+ )
63
+
64
+ app.config["DSN"] = dsn or ""
65
+ # True when the CLI couldn't infer a DB from --dsn or PG* env vars
66
+ # and fell back to the postgres maintenance DB. The /api/conn route
67
+ # surfaces this so the UI can prompt the user to pick a real DB
68
+ # via the top-nav switcher. Cleared once the user switches.
69
+ app.config["DB_IS_AUTO"] = bool(db_is_auto)
70
+ # Runtime overrides for the panel-managed GUCs (provsql.active and
71
+ # provsql.verbose_level). Applied as SET LOCAL on every batch so changes
72
+ # survive across pool checkouts. Toggle-managed GUCs go in their own
73
+ # request fields, not here.
74
+ #
75
+ # Loaded from ~/.config/provsql-studio/config.json so a Studio restart
76
+ # doesn't drop the user's chosen kill-switch / verbosity settings.
77
+ app.config["RUNTIME_GUCS"] = dict(db.load_persisted_gucs())
78
+ # Studio-level option overrides (Config-panel managed): persisted-on-disk
79
+ # values for max_circuit_depth and statement_timeout. They override the
80
+ # CLI defaults set above so the panel-set values survive restarts.
81
+ persisted_opts = db.load_persisted_options()
82
+ if "max_circuit_depth" in persisted_opts:
83
+ app.config["MAX_CIRCUIT_DEPTH"] = persisted_opts["max_circuit_depth"]
84
+ if "max_circuit_nodes" in persisted_opts:
85
+ app.config["MAX_CIRCUIT_NODES"] = persisted_opts["max_circuit_nodes"]
86
+ if "max_sidebar_rows" in persisted_opts:
87
+ app.config["MAX_SIDEBAR_ROWS"] = persisted_opts["max_sidebar_rows"]
88
+ if "max_result_rows" in persisted_opts:
89
+ app.config["MAX_RESULT_ROWS"] = persisted_opts["max_result_rows"]
90
+ if "statement_timeout_seconds" in persisted_opts:
91
+ app.config["STATEMENT_TIMEOUT"] = f"{persisted_opts['statement_timeout_seconds']}s"
92
+ if "search_path" in persisted_opts:
93
+ app.config["SEARCH_PATH"] = persisted_opts["search_path"]
94
+ if "tool_search_path" in persisted_opts:
95
+ app.config["TOOL_SEARCH_PATH"] = persisted_opts["tool_search_path"]
96
+ app.extensions["provsql_pool"] = db.make_pool(dsn)
97
+ # Registry of in-flight POST /api/exec batches, keyed by the
98
+ # client-generated request id. Lets POST /api/cancel/<id> resolve a
99
+ # request id to a backend pid and fire pg_cancel_backend on a
100
+ # separate connection while the original /api/exec is still
101
+ # blocked. Threaded Flask (cli.py) is required for that to work.
102
+ app.extensions["provsql_inflight"] = {
103
+ "lock": threading.Lock(),
104
+ "by_id": {}, # request_id -> pg_backend_pid
105
+ }
106
+ layout_cache = circuit_mod.LayoutCache()
107
+
108
+ # Routes read the live pool through this getter so swapping the pool
109
+ # (when the user switches database) is picked up without re-binding
110
+ # closures.
111
+ def get_pool():
112
+ return app.extensions["provsql_pool"]
113
+
114
+ # ──────── static + shell routes ────────
115
+
116
+ @app.get("/")
117
+ def root():
118
+ return redirect("/circuit", code=302)
119
+
120
+ @app.get("/where")
121
+ def where_shell():
122
+ return _serve_shell("where")
123
+
124
+ @app.get("/circuit")
125
+ def circuit_shell():
126
+ return _serve_shell("circuit")
127
+
128
+ @app.get("/static/<path:filename>")
129
+ def static_file(filename: str):
130
+ return send_from_directory(_STATIC_DIR, filename)
131
+
132
+ def _serve_shell(mode: str):
133
+ html = (_STATIC_DIR / "index.html").read_text()
134
+ # Replace the body class so app.js sees the correct mode and adjust
135
+ # asset URLs so /static/ resolves under Flask's mount.
136
+ html = html.replace('<body class="mode-where">', f'<body class="mode-{mode}">')
137
+ for asset in (
138
+ "fonts-face.css",
139
+ "colors_and_type.css",
140
+ "app.css",
141
+ "app.js",
142
+ "img/favicon.ico",
143
+ "img/logo.png",
144
+ ):
145
+ html = html.replace(f'href="{asset}"', f'href="/static/{asset}"')
146
+ html = html.replace(f'src="{asset}"', f'src="/static/{asset}"')
147
+ return html, 200, {"Content-Type": "text/html; charset=utf-8"}
148
+
149
+ # ──────── API routes ────────
150
+
151
+ @app.get("/api/conn")
152
+ def api_conn():
153
+ # Catch the OperationalError that the pool raises when PG is down
154
+ # (server stopped, network blip, auth revoked) so /api/conn
155
+ # responds with a structured 503 + human-readable reason instead
156
+ # of a bare Flask 500. The connectivity-poll on the front-end
157
+ # surfaces this string in the dot's tooltip.
158
+ try:
159
+ info = db.conn_info(get_pool())
160
+ except psycopg.OperationalError as e:
161
+ return jsonify({
162
+ "error": "database unreachable",
163
+ "reason": str(e).strip() or "cannot connect to PostgreSQL",
164
+ }), 503
165
+ # Display the path that user queries effectively see: the Studio
166
+ # override (Config panel) when set, else the session value, with
167
+ # provsql always pinned at the end. The front-end renders this
168
+ # as `<path> [lock]` to indicate provsql is enforced.
169
+ info["search_path"] = db.compose_search_path(
170
+ app.config.get("SEARCH_PATH", ""),
171
+ info["search_path"],
172
+ )
173
+ info["db_is_auto"] = app.config.get("DB_IS_AUTO", False)
174
+ # Send back a password-stripped DSN so the connection editor
175
+ # can prefill its input without leaking secrets to the page.
176
+ # The user re-types the password if they need to switch host
177
+ # or role.
178
+ try:
179
+ params = psycopg.conninfo.conninfo_to_dict(app.config.get("DSN", ""))
180
+ params.pop("password", None)
181
+ info["dsn"] = psycopg.conninfo.make_conninfo(**params)
182
+ except Exception:
183
+ info["dsn"] = ""
184
+ return jsonify(info)
185
+
186
+ @app.post("/api/conn")
187
+ def api_conn_switch():
188
+ payload = request.get_json(silent=True) or {}
189
+ new_dsn = payload.get("dsn")
190
+ target = payload.get("database")
191
+ dsn_no_db = False
192
+ if new_dsn and isinstance(new_dsn, str) and new_dsn.strip():
193
+ # Free-form DSN path: the user pasted a full conninfo string
194
+ # (host, port, user, password, options, ...). We open a fresh
195
+ # pool and probe it with SELECT 1 before swapping; if anything
196
+ # is wrong (auth, host unreachable, bad syntax) the old pool
197
+ # stays in service and the error reaches the front-end.
198
+ new_dsn = new_dsn.strip()
199
+ # If the user didn't specify dbname, default to the postgres
200
+ # maintenance DB and re-raise the auto-fallback banner so
201
+ # they can pick a real database from the switcher. Mirrors
202
+ # the CLI launch behaviour.
203
+ try:
204
+ params = psycopg.conninfo.conninfo_to_dict(new_dsn)
205
+ except Exception:
206
+ params = None
207
+ if params is not None and "dbname" not in params:
208
+ params["dbname"] = "postgres"
209
+ new_dsn = psycopg.conninfo.make_conninfo(**params)
210
+ dsn_no_db = True
211
+ try:
212
+ probe_pool = db.make_pool(new_dsn)
213
+ with probe_pool.connection() as c, c.cursor() as cur:
214
+ cur.execute("SELECT 1")
215
+ cur.fetchone()
216
+ except Exception as e:
217
+ try:
218
+ probe_pool.close()
219
+ except Exception:
220
+ pass
221
+ return jsonify({
222
+ "error": "cannot connect with the supplied DSN",
223
+ "reason": str(e).strip() or repr(e),
224
+ }), 400
225
+ new_pool = probe_pool
226
+ elif target and isinstance(target, str):
227
+ # Convenience path: swap dbname only, preserving the rest of
228
+ # the connection parameters. Used by the top-nav switcher.
229
+ accessible = db.list_databases(get_pool())
230
+ if target not in accessible:
231
+ return jsonify({"error": f"database {target!r} not accessible"}), 403
232
+ params = psycopg.conninfo.conninfo_to_dict(app.config["DSN"])
233
+ params["dbname"] = target
234
+ new_dsn = psycopg.conninfo.make_conninfo(**params)
235
+ new_pool = db.make_pool(new_dsn)
236
+ else:
237
+ return jsonify({"error": "missing 'dsn' or 'database'"}), 400
238
+
239
+ old_pool = app.extensions["provsql_pool"]
240
+ app.extensions["provsql_pool"] = new_pool
241
+ app.config["DSN"] = new_dsn
242
+ # The "no DB picked" hint reappears whenever we land on the
243
+ # postgres maintenance DB by default (here when the user
244
+ # supplied a DSN without a dbname). For an explicit dbname or a
245
+ # plain database-switch, drop it.
246
+ app.config["DB_IS_AUTO"] = dsn_no_db
247
+ layout_cache._store.clear()
248
+ try:
249
+ old_pool.close()
250
+ except Exception:
251
+ pass
252
+ return jsonify(db.conn_info(new_pool))
253
+
254
+ @app.get("/api/databases")
255
+ def api_databases():
256
+ return jsonify(db.list_databases(get_pool()))
257
+
258
+ @app.get("/api/relations")
259
+ def api_relations():
260
+ return jsonify(db.list_relations(
261
+ get_pool(),
262
+ max_rows=int(app.config["MAX_SIDEBAR_ROWS"]),
263
+ ))
264
+
265
+ @app.get("/api/schema")
266
+ def api_schema():
267
+ return jsonify(db.list_schema(get_pool()))
268
+
269
+ @app.post("/api/exec")
270
+ def api_exec():
271
+ payload = request.get_json(silent=True) or {}
272
+ sql_text = payload.get("sql", "")
273
+ mode = payload.get("mode", "where")
274
+ request_id = str(payload.get("request_id") or "").strip()
275
+
276
+ statements = _split_statements(sql_text)
277
+ if not statements:
278
+ return jsonify({"blocks": []})
279
+
280
+ last = statements[-1]
281
+ wrap_last = mode == "where" and bool(_WRAPPABLE_RE.match(last))
282
+
283
+ # Toggles. In where mode `where_provenance` is forced on because the
284
+ # wrap calls `provsql.where_provenance(provsql.provenance())` and
285
+ # would otherwise return zero matches. In circuit mode both are
286
+ # user-controlled; defaults match the previous fixed behaviour.
287
+ where_prov = bool(payload.get("where_provenance", mode == "where"))
288
+ if mode == "where":
289
+ where_prov = True
290
+ update_prov = bool(payload.get("update_provenance", False))
291
+
292
+ inflight = app.extensions["provsql_inflight"]
293
+ registered = False
294
+
295
+ def register_pid(pid: int) -> None:
296
+ nonlocal registered
297
+ if not request_id:
298
+ return
299
+ with inflight["lock"]:
300
+ inflight["by_id"][request_id] = pid
301
+ registered = True
302
+
303
+ try:
304
+ intermediate, final, meta = db.exec_batch(
305
+ get_pool(),
306
+ statements,
307
+ statement_timeout=app.config["STATEMENT_TIMEOUT"],
308
+ where_provenance=where_prov,
309
+ update_provenance=update_prov,
310
+ wrap_last=wrap_last,
311
+ extra_gucs=app.config["RUNTIME_GUCS"],
312
+ on_pid=register_pid,
313
+ search_path=app.config.get("SEARCH_PATH", ""),
314
+ tool_search_path=app.config.get("TOOL_SEARCH_PATH", ""),
315
+ max_result_rows=int(app.config["MAX_RESULT_ROWS"]),
316
+ )
317
+ finally:
318
+ if registered:
319
+ with inflight["lock"]:
320
+ inflight["by_id"].pop(request_id, None)
321
+
322
+ blocks = [r.to_dict() for r in intermediate]
323
+ if final is not None:
324
+ blocks.append(final.to_dict())
325
+ return jsonify({
326
+ "blocks": blocks,
327
+ "wrapped": meta["wrapped"],
328
+ "notices": meta.get("notices", []),
329
+ })
330
+
331
+ @app.post("/api/cancel/<request_id>")
332
+ def api_cancel(request_id: str):
333
+ # Fires pg_cancel_backend(pid) on a *fresh* connection (not from
334
+ # the pool) so we never wait for a slot that may itself be held
335
+ # by the very query we're trying to cancel. The cancel arrives
336
+ # at the running backend as a SIGINT, which the patched
337
+ # provsql_sigint_handler turns into the standard
338
+ # InterruptPending / QueryCancelPending pair, and PG ereports
339
+ # 57014 that exec_batch then surfaces as a normal error block.
340
+ inflight = app.extensions["provsql_inflight"]
341
+ with inflight["lock"]:
342
+ pid = inflight["by_id"].get(request_id)
343
+ if pid is None:
344
+ return jsonify({
345
+ "ok": False,
346
+ "reason": "no in-flight query for this id",
347
+ }), 404
348
+ try:
349
+ with psycopg.connect(app.config["DSN"]) as conn:
350
+ with conn.cursor() as cur:
351
+ cur.execute("SELECT pg_cancel_backend(%s)", (pid,))
352
+ ok = bool(cur.fetchone()[0])
353
+ return jsonify({"ok": ok})
354
+ except psycopg.Error as e:
355
+ return jsonify({
356
+ "ok": False,
357
+ "reason": str(e).strip(),
358
+ }), 500
359
+
360
+ @app.get("/api/circuit/<token>")
361
+ def api_circuit(token: str):
362
+ try:
363
+ root_uuid = _coerce_to_uuid(token)
364
+ except ValueError:
365
+ return jsonify({"error": "not a valid UUID or agg_token"}), 400
366
+ depth = _clamp_depth(request.args.get("depth"), app.config["MAX_CIRCUIT_DEPTH"])
367
+ return _layout_response(root_uuid, depth)
368
+
369
+ @app.post("/api/circuit/<token>/expand")
370
+ def api_circuit_expand(token: str):
371
+ # Path token is the original root (kept for client-side correlation),
372
+ # body carries the frontier we actually re-root the next BFS at.
373
+ del token # unused on the server; the frontier in the body is canonical
374
+ payload = request.get_json(silent=True) or {}
375
+ try:
376
+ frontier = _coerce_to_uuid(payload.get("frontier_node_uuid", ""))
377
+ except ValueError:
378
+ return jsonify({"error": "frontier_node_uuid is not a valid UUID"}), 400
379
+ depth = _clamp_depth(payload.get("additional_depth"), app.config["MAX_CIRCUIT_DEPTH"])
380
+ return _layout_response(frontier, depth)
381
+
382
+ def _layout_response(root: str, depth: int):
383
+ import psycopg
384
+ cached = layout_cache.get(root, depth)
385
+ if cached is not None:
386
+ return jsonify(cached)
387
+ try:
388
+ data = circuit_mod.get_circuit(
389
+ get_pool(), root=root, depth=depth, max_nodes=app.config["MAX_CIRCUIT_NODES"]
390
+ )
391
+ except circuit_mod.CircuitTooLarge as e:
392
+ return jsonify({
393
+ "error": "circuit too large",
394
+ "node_count": e.node_count,
395
+ "cap": e.cap,
396
+ "depth": e.depth,
397
+ "depth_1_size": e.depth_1_size,
398
+ "hint": "reduce depth or click into a specific node",
399
+ }), 413
400
+ except psycopg.errors.UndefinedFunction:
401
+ # The current database carries an older provsql that predates
402
+ # circuit_subgraph / resolve_input. Tell the user instead of
403
+ # leaking the raw "function ... does not exist" stack trace.
404
+ return jsonify({
405
+ "error": "circuit introspection unavailable on this database",
406
+ "hint": (
407
+ "The connected database has an older provsql installation "
408
+ "without provsql.circuit_subgraph. Upgrade the extension "
409
+ "(ALTER EXTENSION provsql UPDATE) or switch to a database "
410
+ "that has the current version."
411
+ ),
412
+ }), 501
413
+ layout_cache.put(root, depth, data)
414
+ return jsonify(data)
415
+
416
+ @app.get("/api/leaf/<token>")
417
+ def api_leaf(token: str):
418
+ import psycopg
419
+ try:
420
+ uuid_str = _coerce_to_uuid(token)
421
+ except ValueError:
422
+ return jsonify({"error": "not a valid UUID"}), 400
423
+ try:
424
+ rows = circuit_mod.resolve_input(get_pool(), uuid_str)
425
+ except psycopg.errors.UndefinedFunction:
426
+ return jsonify({
427
+ "error": "leaf resolution unavailable on this database",
428
+ "hint": (
429
+ "The connected database has an older provsql installation "
430
+ "without provsql.resolve_input. Upgrade the extension or "
431
+ "switch to a database that has the current version."
432
+ ),
433
+ }), 501
434
+ if not rows:
435
+ return jsonify({"error": "no row maps to this input gate"}), 404
436
+ # Best-effort probability: when set_prob has been called on the
437
+ # gate, surface the value alongside the resolved row so the
438
+ # inspector can show the per-row probability without a second
439
+ # round-trip. None when unset / inapplicable.
440
+ body = {"matches": rows}
441
+ probability = circuit_mod.get_prob(get_pool(), uuid_str)
442
+ if probability is not None:
443
+ body["probability"] = probability
444
+ # Single-relation case is the norm; if multiple tables share the UUID,
445
+ # return the list and let the front-end pick.
446
+ return jsonify(body)
447
+
448
+ @app.post("/api/set_prob")
449
+ def api_set_prob():
450
+ """Write a probability for an input/update gate via
451
+ provsql.set_prob. Backs the inspector's click-to-edit affordance:
452
+ the user opens an input gate, clicks the displayed probability,
453
+ types a new value, hits Enter, and we fire this endpoint."""
454
+ import psycopg
455
+ payload = request.get_json(silent=True) or {}
456
+ try:
457
+ uuid_str = _coerce_to_uuid(payload.get("uuid", ""))
458
+ except ValueError:
459
+ return jsonify({"error": "not a valid UUID"}), 400
460
+ raw = payload.get("probability", None)
461
+ try:
462
+ p = float(raw)
463
+ except (TypeError, ValueError):
464
+ return jsonify({"error": "probability must be a number"}), 400
465
+ if not (0.0 <= p <= 1.0):
466
+ return jsonify({"error": "probability must be between 0 and 1"}), 400
467
+ try:
468
+ with get_pool().connection() as conn, conn.cursor() as cur:
469
+ cur.execute(
470
+ "SELECT provsql.set_prob(%s::uuid, %s::double precision)",
471
+ (uuid_str, p),
472
+ )
473
+ except psycopg.Error as e:
474
+ diag = getattr(e, "diag", None)
475
+ return jsonify({
476
+ "error": "set_prob failed",
477
+ "detail": (diag.message_primary if diag else str(e)) or str(e),
478
+ "sqlstate": diag.sqlstate if diag else None,
479
+ }), 400
480
+ return jsonify({"ok": True, "probability": p})
481
+
482
+ @app.get("/api/provenance_mappings")
483
+ def api_provenance_mappings():
484
+ # Used by the circuit-mode semiring evaluation strip to populate
485
+ # the mapping select; refreshed each time the panel opens, so
486
+ # newly-created mappings show up without a page reload.
487
+ return jsonify(db.list_provenance_mappings(get_pool()))
488
+
489
+ @app.get("/api/custom_semirings")
490
+ def api_custom_semirings():
491
+ # Discovered SQL/PL wrappers around `provenance_evaluate`.
492
+ # Populates the eval strip's "Custom Semirings" optgroup; refreshed
493
+ # whenever the panel opens.
494
+ return jsonify(db.list_custom_semirings(get_pool()))
495
+
496
+ @app.post("/api/evaluate")
497
+ def api_evaluate():
498
+ import psycopg
499
+ payload = request.get_json(silent=True) or {}
500
+ try:
501
+ token = _coerce_to_uuid(payload.get("token", ""))
502
+ except ValueError:
503
+ return jsonify({"error": "token is not a valid UUID"}), 400
504
+ semiring = (payload.get("semiring") or "").strip().lower()
505
+ mapping = payload.get("mapping") or None
506
+ method = payload.get("method") or None
507
+ arguments = payload.get("arguments") or None
508
+ function = payload.get("function") or None
509
+ try:
510
+ data = db.evaluate_circuit(
511
+ get_pool(),
512
+ token=token,
513
+ semiring=semiring,
514
+ mapping=mapping,
515
+ method=method,
516
+ arguments=arguments,
517
+ function=function,
518
+ statement_timeout=app.config["STATEMENT_TIMEOUT"],
519
+ search_path=app.config.get("SEARCH_PATH", ""),
520
+ tool_search_path=app.config.get("TOOL_SEARCH_PATH", ""),
521
+ )
522
+ except ValueError as e:
523
+ return jsonify({"error": str(e)}), 400
524
+ except psycopg.errors.UndefinedFunction as e:
525
+ # Older provsql or missing helper : surface the underlying
526
+ # diagnostic so the user can see which function is missing.
527
+ return jsonify({
528
+ "error": "evaluation function unavailable on this database",
529
+ "detail": str(e).splitlines()[0],
530
+ }), 501
531
+ except psycopg.Error as e:
532
+ diag = getattr(e, "diag", None)
533
+ return jsonify({
534
+ "error": "evaluation failed",
535
+ "sqlstate": diag.sqlstate if diag else None,
536
+ "detail": str(e).strip(),
537
+ }), 500
538
+ return jsonify(data)
539
+
540
+ _OPTION_KEYS = {
541
+ "max_circuit_depth",
542
+ "max_circuit_nodes",
543
+ "max_sidebar_rows",
544
+ "max_result_rows",
545
+ "statement_timeout_seconds",
546
+ "search_path",
547
+ "tool_search_path",
548
+ }
549
+
550
+ def _current_options() -> dict:
551
+ # Surface the live values of the Studio-level options so the
552
+ # Config panel can display them after a restart, including those
553
+ # picked up from CLI flags rather than the on-disk config file.
554
+ timeout = str(app.config["STATEMENT_TIMEOUT"]).strip().lower()
555
+ # Best-effort parse of the timeout string; the CLI accepts any
556
+ # PG-parseable interval ("30s", "500ms", "1min"), but the panel
557
+ # stores it as plain seconds.
558
+ seconds: int | None = None
559
+ if timeout.endswith("ms"):
560
+ try:
561
+ seconds = max(1, int(timeout[:-2]) // 1000)
562
+ except ValueError:
563
+ pass
564
+ elif timeout.endswith("s") and not timeout.endswith("ms"):
565
+ try:
566
+ seconds = int(timeout[:-1])
567
+ except ValueError:
568
+ pass
569
+ elif timeout.endswith("min"):
570
+ try:
571
+ seconds = int(timeout[:-3]) * 60
572
+ except ValueError:
573
+ pass
574
+ return {
575
+ "max_circuit_depth": int(app.config["MAX_CIRCUIT_DEPTH"]),
576
+ "max_circuit_nodes": int(app.config["MAX_CIRCUIT_NODES"]),
577
+ "max_sidebar_rows": int(app.config["MAX_SIDEBAR_ROWS"]),
578
+ "max_result_rows": int(app.config["MAX_RESULT_ROWS"]),
579
+ "statement_timeout_seconds": seconds if seconds is not None else 30,
580
+ "search_path": app.config.get("SEARCH_PATH", "") or "",
581
+ "tool_search_path": app.config.get("TOOL_SEARCH_PATH", "") or "",
582
+ }
583
+
584
+ @app.get("/api/config")
585
+ def api_config_get():
586
+ # Returns the *effective* values of the panel GUCs after our runtime
587
+ # overrides are applied, plus the bare overrides we hold in app
588
+ # state (so the front-end can show "modified" markers if it wants).
589
+ effective = db.show_panel_gucs(get_pool(), app.config["RUNTIME_GUCS"])
590
+ return jsonify({
591
+ "effective": effective,
592
+ "overrides": dict(app.config["RUNTIME_GUCS"]),
593
+ "options": _current_options(),
594
+ })
595
+
596
+ @app.post("/api/config")
597
+ def api_config_set():
598
+ payload = request.get_json(silent=True) or {}
599
+ name = payload.get("key", "")
600
+ value = payload.get("value", "")
601
+ # Studio-level options (not GUCs) are validated and stored in app
602
+ # config, then persisted alongside the GUC overrides.
603
+ if name in _OPTION_KEYS:
604
+ try:
605
+ key, canonical = db.validate_panel_option(name, value)
606
+ except ValueError as e:
607
+ return jsonify({"error": str(e)}), 400
608
+ if key == "max_circuit_depth":
609
+ app.config["MAX_CIRCUIT_DEPTH"] = canonical
610
+ elif key == "max_circuit_nodes":
611
+ app.config["MAX_CIRCUIT_NODES"] = canonical
612
+ # Drop any cached layouts: the cap change may unblock
613
+ # circuits that were 413'd at the old cap, and stale
614
+ # cache entries would still reflect the old result.
615
+ layout_cache.clear()
616
+ elif key == "max_sidebar_rows":
617
+ app.config["MAX_SIDEBAR_ROWS"] = canonical
618
+ elif key == "max_result_rows":
619
+ app.config["MAX_RESULT_ROWS"] = canonical
620
+ elif key == "statement_timeout_seconds":
621
+ app.config["STATEMENT_TIMEOUT"] = f"{canonical}s"
622
+ elif key == "search_path":
623
+ app.config["SEARCH_PATH"] = canonical
624
+ elif key == "tool_search_path":
625
+ app.config["TOOL_SEARCH_PATH"] = canonical
626
+ db.save_persisted_options(_current_options())
627
+ return jsonify({"ok": True, "key": key, "value": canonical})
628
+ # Otherwise treat as a GUC override.
629
+ try:
630
+ canonical = db.validate_panel_guc(name, value)
631
+ except ValueError as e:
632
+ return jsonify({"error": str(e)}), 400
633
+ app.config["RUNTIME_GUCS"][name] = canonical
634
+ # Best-effort persist so a Studio restart keeps the user's choice.
635
+ db.save_persisted_gucs(app.config["RUNTIME_GUCS"])
636
+ return jsonify({"ok": True, "key": name, "value": canonical})
637
+
638
+ return app
639
+
640
+
641
+ def _coerce_to_uuid(token: str) -> str:
642
+ """Accept a UUID string (any case, with or without hyphens) and return its
643
+ canonical 36-char form. Raises ValueError otherwise. Front-end agg_token
644
+ cells should send the underlying UUID, not the formatted '<value> (*)'
645
+ text; agg_token's text representation does not carry the UUID."""
646
+ if not token:
647
+ raise ValueError("empty token")
648
+ return str(uuid_mod.UUID(token))
649
+
650
+
651
+ def _clamp_depth(raw, default_max: int) -> int:
652
+ try:
653
+ d = int(raw) if raw is not None and str(raw) != "" else default_max
654
+ except (TypeError, ValueError):
655
+ d = default_max
656
+ return max(1, min(d, default_max))
657
+
658
+
659
+ def _split_statements(sql_text: str) -> list[str]:
660
+ """Split a SQL batch into individual statements. sqlparse handles
661
+ dollar-quoting, comments, and string literals correctly."""
662
+ out: list[str] = []
663
+ for raw in sqlparse.split(sql_text):
664
+ stripped = raw.strip().rstrip(";").strip()
665
+ if stripped:
666
+ out.append(stripped)
667
+ return out