databricks-explore 0.5.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.
- databricks_explore/__init__.py +3 -0
- databricks_explore/__main__.py +6 -0
- databricks_explore/app.py +673 -0
- databricks_explore/canvases.py +333 -0
- databricks_explore/cli.py +80 -0
- databricks_explore/concepts.py +325 -0
- databricks_explore/dbx.py +537 -0
- databricks_explore/demo.py +653 -0
- databricks_explore/demo_history.py +157 -0
- databricks_explore/genie.py +281 -0
- databricks_explore/layout.py +1764 -0
- databricks_explore/mcp_server/__init__.py +93 -0
- databricks_explore/mcp_server/auth.py +95 -0
- databricks_explore/mcp_server/rpc.py +88 -0
- databricks_explore/mcp_server/schema.py +68 -0
- databricks_explore/mcp_server/sqlguard.py +59 -0
- databricks_explore/mcp_server/tooling.py +74 -0
- databricks_explore/mcp_server/tools_canvas.py +430 -0
- databricks_explore/mcp_server/tools_data.py +472 -0
- databricks_explore/mining.py +433 -0
- databricks_explore/ontology.py +508 -0
- databricks_explore/relationships.py +987 -0
- databricks_explore/sqltools.py +525 -0
- databricks_explore/web_dist/THIRD-PARTY-NOTICES.txt +5853 -0
- databricks_explore/web_dist/assets/index-6I1cPS7i.css +1 -0
- databricks_explore/web_dist/assets/index-CR9j5CJG.js +330 -0
- databricks_explore/web_dist/index.html +13 -0
- databricks_explore-0.5.0.dist-info/METADATA +207 -0
- databricks_explore-0.5.0.dist-info/RECORD +32 -0
- databricks_explore-0.5.0.dist-info/WHEEL +4 -0
- databricks_explore-0.5.0.dist-info/entry_points.txt +2 -0
- databricks_explore-0.5.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,673 @@
|
|
|
1
|
+
"""The Flask app: REST routes, the built React UI and the MCP server. Start it with the `databricks-explore` command."""
|
|
2
|
+
import mimetypes
|
|
3
|
+
import time
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from flask import Flask, abort, jsonify, request, send_from_directory
|
|
7
|
+
from werkzeug.exceptions import HTTPException, MethodNotAllowed, NotFound
|
|
8
|
+
|
|
9
|
+
from . import __version__, canvases
|
|
10
|
+
from . import concepts
|
|
11
|
+
from . import dbx
|
|
12
|
+
from . import demo
|
|
13
|
+
from . import genie
|
|
14
|
+
from . import layout
|
|
15
|
+
from . import mcp_server
|
|
16
|
+
from . import mining
|
|
17
|
+
from . import ontology
|
|
18
|
+
from . import relationships
|
|
19
|
+
from . import sqltools
|
|
20
|
+
|
|
21
|
+
# Windows can map these to text/plain through the registry, and a browser then refuses module scripts and styles.
|
|
22
|
+
for _ext, _type in ((".js", "text/javascript"), (".mjs", "text/javascript"), (".css", "text/css"), (".svg", "image/svg+xml")):
|
|
23
|
+
mimetypes.add_type(_type, _ext)
|
|
24
|
+
|
|
25
|
+
app = Flask(__name__, static_folder=None) # the React build in web_dist is the only UI
|
|
26
|
+
STATE = {"ex": None} # single local user -> one active connection
|
|
27
|
+
WEB_DIST = Path(__file__).resolve().parent / "web_dist" # `npm run build` in web/ writes it; the wheel ships it
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@app.before_request
|
|
31
|
+
def local_only():
|
|
32
|
+
"""Every route, not only /mcp: refuse a Host or Origin that is not this machine. A DNS-rebinding page reaches
|
|
33
|
+
the app under its own name, and would otherwise use the connected workspace's credentials through /api/*."""
|
|
34
|
+
if not mcp_server.auth.host_is_local(request.host) or not mcp_server.auth.origin_is_local(request.headers.get("Origin")):
|
|
35
|
+
return jsonify({"error": "this app answers only requests addressed to 127.0.0.1 or localhost"}), 403
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def ex():
|
|
39
|
+
if not STATE["ex"]:
|
|
40
|
+
raise RuntimeError("Not connected. Pick a profile first.")
|
|
41
|
+
return STATE["ex"]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def refresh():
|
|
45
|
+
return request.args.get("refresh") == "1"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _api_route_exists(path):
|
|
49
|
+
"""True when some route other than the SPA fallback serves this path under any method."""
|
|
50
|
+
adapter = app.url_map.bind("localhost")
|
|
51
|
+
for method in ("GET", "POST", "PUT", "PATCH", "DELETE"):
|
|
52
|
+
try:
|
|
53
|
+
if adapter.match(path, method=method)[0] != "spa":
|
|
54
|
+
return True
|
|
55
|
+
except HTTPException:
|
|
56
|
+
pass
|
|
57
|
+
return False
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@app.errorhandler(Exception)
|
|
61
|
+
def err(e):
|
|
62
|
+
"""JSON error body. HTTP errors (404, 405, ...) keep their status; anything else is a 500."""
|
|
63
|
+
if isinstance(e, HTTPException):
|
|
64
|
+
if isinstance(e, MethodNotAllowed) and request.path.startswith("/api/") and not _api_route_exists(request.path):
|
|
65
|
+
e = NotFound() # only the SPA fallback matched this path, so it is an unknown API route
|
|
66
|
+
resp = jsonify({"error": e.description})
|
|
67
|
+
resp.status_code = e.code
|
|
68
|
+
if isinstance(e, MethodNotAllowed) and e.valid_methods:
|
|
69
|
+
resp.headers["Allow"] = ", ".join(e.valid_methods)
|
|
70
|
+
return resp
|
|
71
|
+
return jsonify({"error": dbx.clean_error(e)}), 500
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@app.get("/")
|
|
75
|
+
def index():
|
|
76
|
+
if not (WEB_DIST / "index.html").is_file():
|
|
77
|
+
return "Frontend not built. Run `npm run build` in web/ (or `npm run dev`).", 503
|
|
78
|
+
return send_from_directory(WEB_DIST, "index.html")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@app.get("/<path:path>")
|
|
82
|
+
def spa(path):
|
|
83
|
+
if path == "api" or path.startswith("api/"):
|
|
84
|
+
abort(404) # unknown API route: same JSON error shape as every other failure
|
|
85
|
+
if (WEB_DIST / path).is_file():
|
|
86
|
+
return send_from_directory(WEB_DIST, path)
|
|
87
|
+
return index() # client-side routes fall back to index.html
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@app.get("/api/profiles")
|
|
91
|
+
def profiles():
|
|
92
|
+
return jsonify({"profiles": dbx.list_profiles(), "config": "demo mode: invented data, no Databricks" if dbx.DEMO else str(dbx.cfg_path())})
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@app.get("/api/session")
|
|
96
|
+
def session():
|
|
97
|
+
"""What the server is connected to right now. Null fields when not connected. No connection needed."""
|
|
98
|
+
e = STATE["ex"]
|
|
99
|
+
return jsonify({"profile": e.profile if e else None, "warehouse_id": e.warehouse_id if e else None, "version": __version__})
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@app.post("/api/connect")
|
|
103
|
+
def connect():
|
|
104
|
+
body = request.json
|
|
105
|
+
profile = body["profile"]
|
|
106
|
+
# interactive=False is used when the page restores a previous session on load: it must never open a browser.
|
|
107
|
+
interactive = body.get("interactive", True)
|
|
108
|
+
try:
|
|
109
|
+
e = dbx.Explorer(profile)
|
|
110
|
+
who = e.whoami()
|
|
111
|
+
except Exception as first_error:
|
|
112
|
+
if not interactive:
|
|
113
|
+
return jsonify({"error": f"Could not reconnect to {profile}: {dbx.clean_error(first_error)}"}), 401
|
|
114
|
+
dbx.cli_login(profile) # browser pops up; blocks until done
|
|
115
|
+
e = dbx.Explorer(profile)
|
|
116
|
+
who = e.whoami()
|
|
117
|
+
STATE["ex"] = e
|
|
118
|
+
return jsonify({"profile": profile, **who})
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@app.get("/api/warehouses")
|
|
122
|
+
def warehouses():
|
|
123
|
+
return jsonify(ex().warehouses())
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@app.post("/api/warehouse")
|
|
127
|
+
def set_warehouse():
|
|
128
|
+
ex().warehouse_id = request.json.get("id") or None
|
|
129
|
+
return jsonify({"warehouse_id": ex().warehouse_id})
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@app.get("/api/catalogs")
|
|
133
|
+
def catalogs():
|
|
134
|
+
return jsonify(ex().catalogs(refresh()))
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@app.get("/api/schemas")
|
|
138
|
+
def schemas():
|
|
139
|
+
return jsonify(ex().schemas(request.args["catalog"], refresh()))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@app.get("/api/objects")
|
|
143
|
+
def objects():
|
|
144
|
+
return jsonify(ex().objects(request.args["catalog"], request.args["schema"], refresh()))
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@app.get("/api/search")
|
|
148
|
+
def search():
|
|
149
|
+
e = ex()
|
|
150
|
+
if e.warehouse_id:
|
|
151
|
+
return jsonify(e.search(request.args["q"]))
|
|
152
|
+
return jsonify(dbx.cached_search(e, request.args["q"])) # every cached table, in every catalog
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@app.get("/api/object")
|
|
156
|
+
def obj():
|
|
157
|
+
return jsonify(ex().object(request.args["name"], refresh()))
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@app.get("/api/relations")
|
|
161
|
+
def relations():
|
|
162
|
+
return jsonify(ex().relations(request.args["name"], refresh()))
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@app.get("/api/edge")
|
|
166
|
+
def edge():
|
|
167
|
+
return jsonify(ex().edge_details(request.args["src"], request.args["tgt"]))
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@app.get("/api/diagrams")
|
|
171
|
+
def diagrams():
|
|
172
|
+
return jsonify(dbx.list_diagrams())
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@app.get("/api/diagrams/<name>")
|
|
176
|
+
def diagram(name):
|
|
177
|
+
return jsonify(dbx.load_diagram(name))
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@app.post("/api/diagrams")
|
|
181
|
+
def save():
|
|
182
|
+
body = request.json
|
|
183
|
+
return jsonify({"saved": dbx.save_diagram(body["name"], body)})
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# ---------- relationships (relationships.py): derived edges plus the persisted ones ----------
|
|
187
|
+
|
|
188
|
+
@app.errorhandler(relationships.RelationshipInvalid)
|
|
189
|
+
def relationship_invalid(e):
|
|
190
|
+
return jsonify({"error": str(e)}), 400
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@app.errorhandler(relationships.RelationshipNotFound)
|
|
194
|
+
def relationship_not_found(e):
|
|
195
|
+
return jsonify({"error": str(e)}), 404
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def rel_store():
|
|
199
|
+
"""The store for the connected profile."""
|
|
200
|
+
return relationships.open_store(ex().profile)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@app.get("/api/relationships")
|
|
204
|
+
def relationships_for_table():
|
|
205
|
+
table = relationships.check_table(request.args.get("table", ""))
|
|
206
|
+
e = ex()
|
|
207
|
+
level = request.args.get("level", "table")
|
|
208
|
+
rel, times, columns, notes = None, None, None, []
|
|
209
|
+
if not table.startswith("ph:"): # a placeholder has no catalog metadata
|
|
210
|
+
rel, times = e.relations(table, refresh()), dbx.cache_times(e, table)
|
|
211
|
+
if level == "column": # one warehouse query, cached. With no warehouse or no access the rest still loads.
|
|
212
|
+
try:
|
|
213
|
+
columns = e.column_lineage(table, refresh())
|
|
214
|
+
except Exception as err:
|
|
215
|
+
notes.append(f"column_lineage: {dbx.clean_error(err)}")
|
|
216
|
+
with rel_store() as store:
|
|
217
|
+
out = relationships.unified(store, table, rel, times, level, columns)
|
|
218
|
+
return jsonify({**out, "errors": [*out["errors"], *notes]})
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@app.get("/api/relationships/suggestions")
|
|
222
|
+
def relationship_suggestions():
|
|
223
|
+
statuses = request.args.get("status", "proposed").split(",")
|
|
224
|
+
with rel_store() as store:
|
|
225
|
+
edges = store.suggestions(relationships.check_statuses(statuses), table=request.args.get("table") or None)
|
|
226
|
+
counts = store.status_counts()
|
|
227
|
+
return jsonify({"edges": [x.model_dump(mode="json") for x in edges], "counts": counts})
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
@app.post("/api/relationships/bulk")
|
|
231
|
+
def relationship_bulk():
|
|
232
|
+
body = relationships.parse_bulk(request.get_json(silent=True))
|
|
233
|
+
with rel_store() as store:
|
|
234
|
+
return jsonify({"updated": store.set_status_many(body.ids, body.status)})
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
@app.post("/api/relationships")
|
|
238
|
+
def relationship_create():
|
|
239
|
+
body = relationships.parse_create(request.get_json(silent=True))
|
|
240
|
+
with rel_store() as store:
|
|
241
|
+
edge, created = store.create_manual(body)
|
|
242
|
+
return jsonify(edge.model_dump(mode="json")), 201 if created else 200
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
@app.patch("/api/relationships/<rid>")
|
|
246
|
+
def relationship_patch(rid):
|
|
247
|
+
patch = relationships.parse_patch(request.get_json(silent=True))
|
|
248
|
+
with rel_store() as store:
|
|
249
|
+
if rid.startswith("e_"):
|
|
250
|
+
edge = store.patch(rid, patch.status, patch.note)
|
|
251
|
+
else: # a derived edge: its id is its natural key, and only a note can be attached
|
|
252
|
+
if patch.status is not None:
|
|
253
|
+
raise relationships.RelationshipInvalid("status: derived edges only take a note")
|
|
254
|
+
edge = store.set_derived_note(rid, patch.note)
|
|
255
|
+
return jsonify(edge.model_dump(mode="json"))
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
@app.delete("/api/relationships/<rid>")
|
|
259
|
+
def relationship_delete(rid):
|
|
260
|
+
with rel_store() as store:
|
|
261
|
+
if rid.startswith("e_"):
|
|
262
|
+
store.delete(rid)
|
|
263
|
+
else:
|
|
264
|
+
store.clear_derived_note(rid)
|
|
265
|
+
return jsonify({"deleted": rid})
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
@app.get("/api/relationships/export")
|
|
269
|
+
def relationships_export():
|
|
270
|
+
with rel_store() as store:
|
|
271
|
+
return jsonify(store.export())
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
@app.post("/api/relationships/import")
|
|
275
|
+
def relationships_import():
|
|
276
|
+
file = relationships.parse_export(request.get_json(silent=True))
|
|
277
|
+
with rel_store() as store:
|
|
278
|
+
return jsonify(store.import_file(file))
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
# ---------- concepts (concepts.py): a business glossary linked to tables, columns and other concepts ----------
|
|
282
|
+
|
|
283
|
+
@app.get("/api/concepts")
|
|
284
|
+
def concept_list():
|
|
285
|
+
status = request.args.get("status") or None
|
|
286
|
+
with rel_store() as store:
|
|
287
|
+
return jsonify(concepts.listing(store, request.args.get("q", ""), request.args.get("domain") or None, status))
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
@app.post("/api/concepts")
|
|
291
|
+
def concept_create():
|
|
292
|
+
body = concepts.parse_create(request.get_json(silent=True))
|
|
293
|
+
with rel_store() as store:
|
|
294
|
+
return jsonify(concepts.detail(store, concepts.create(store, body).id)), 201
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
@app.get("/api/concepts/export")
|
|
298
|
+
def concepts_export():
|
|
299
|
+
with rel_store() as store:
|
|
300
|
+
return jsonify(store.export(concepts_only=True))
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
@app.get("/api/concepts/for-tables")
|
|
304
|
+
def concepts_for_tables():
|
|
305
|
+
with rel_store() as store:
|
|
306
|
+
return jsonify(concepts.for_tables(store, request.args.getlist("table")))
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
@app.get("/api/concepts/suggest/plan")
|
|
310
|
+
def concept_suggest_plan():
|
|
311
|
+
return jsonify(concepts.tag_plan(ex().warehouse_id))
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
@app.post("/api/concepts/suggest/run")
|
|
315
|
+
def concept_suggest_run():
|
|
316
|
+
result = ex().query(concepts.TAG_SQL, None, concepts.TAG_ROW_CAP)
|
|
317
|
+
with rel_store() as store:
|
|
318
|
+
return jsonify(concepts.tag_run(store, result))
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
# ---------- ontology import (ontology.py): read-only, into concepts with origin "ontology" ----------
|
|
322
|
+
|
|
323
|
+
@app.get("/api/concepts/import/plan")
|
|
324
|
+
def ontology_plan():
|
|
325
|
+
return jsonify(ontology.plan(ex().warehouse_id))
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
@app.post("/api/concepts/import/run")
|
|
329
|
+
def ontology_run():
|
|
330
|
+
e = ex()
|
|
331
|
+
with rel_store() as store:
|
|
332
|
+
return jsonify(ontology.run(e, store))
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
@app.errorhandler(ontology.NeedsWarehouse)
|
|
336
|
+
def needs_warehouse(e):
|
|
337
|
+
return jsonify({"error": str(e)}), 400
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
@app.get("/api/certification")
|
|
341
|
+
def certification():
|
|
342
|
+
return jsonify(ontology.cached_certification(ex())) # the local cache only: nothing here ever wakes a warehouse
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
@app.post("/api/certification/refresh")
|
|
346
|
+
def certification_refresh():
|
|
347
|
+
return jsonify(ontology.refresh_certification(ex()))
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
@app.get("/api/concepts/<cid>")
|
|
351
|
+
def concept_get(cid):
|
|
352
|
+
with rel_store() as store:
|
|
353
|
+
return jsonify(concepts.detail(store, concepts.check_concept_id(cid)))
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
@app.patch("/api/concepts/<cid>")
|
|
357
|
+
def concept_patch(cid):
|
|
358
|
+
cid = concepts.check_concept_id(cid)
|
|
359
|
+
patch = concepts.parse_patch(request.get_json(silent=True))
|
|
360
|
+
with rel_store() as store:
|
|
361
|
+
concepts.update(store, cid, patch)
|
|
362
|
+
return jsonify(concepts.detail(store, cid))
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
@app.delete("/api/concepts/<cid>")
|
|
366
|
+
def concept_delete(cid):
|
|
367
|
+
with rel_store() as store:
|
|
368
|
+
return jsonify(concepts.remove(store, concepts.check_concept_id(cid)))
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
@app.post("/api/concepts/<cid>/links")
|
|
372
|
+
def concept_link_add(cid):
|
|
373
|
+
cid = concepts.check_concept_id(cid)
|
|
374
|
+
body = concepts.parse_link(request.get_json(silent=True))
|
|
375
|
+
with rel_store() as store:
|
|
376
|
+
edge, created = concepts.add_link(store, cid, body)
|
|
377
|
+
return jsonify(edge.model_dump(mode="json")), 201 if created else 200
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
@app.delete("/api/concepts/<cid>/links/<edge_id>")
|
|
381
|
+
def concept_link_remove(cid, edge_id):
|
|
382
|
+
with rel_store() as store:
|
|
383
|
+
return jsonify(concepts.remove_link(store, concepts.check_concept_id(cid), edge_id))
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
@app.get("/api/concepts/<cid>/candidates")
|
|
387
|
+
def concept_candidates(cid):
|
|
388
|
+
with rel_store() as store:
|
|
389
|
+
return jsonify(concepts.candidates(store, concepts.check_concept_id(cid), dbx.cached_metadata(ex())))
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
# ---------- query-history mining (mining.py): explicit, user-triggered runs ----------
|
|
393
|
+
|
|
394
|
+
@app.errorhandler(mining.MiningInvalid)
|
|
395
|
+
def mining_invalid(e):
|
|
396
|
+
return jsonify({"error": str(e)}), 400
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
@app.errorhandler(mining.MiningBusy)
|
|
400
|
+
def mining_busy(e):
|
|
401
|
+
return jsonify({"error": str(e)}), 409
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def miner():
|
|
405
|
+
"""The Miner for the connected Explorer."""
|
|
406
|
+
m = STATE.get("miner")
|
|
407
|
+
if m is None or m.ex is not ex():
|
|
408
|
+
m = STATE["miner"] = mining.Miner(ex())
|
|
409
|
+
return m
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _mining_args(source):
|
|
413
|
+
"""window_days and full from a query string or body, as ints and bools. Bad types are MiningInvalid."""
|
|
414
|
+
days = source.get("window_days", mining.WINDOW_DAYS)
|
|
415
|
+
if isinstance(days, str):
|
|
416
|
+
if not days.strip().isdigit():
|
|
417
|
+
raise mining.MiningInvalid(f"window_days: must be a whole number from 1 to {mining.MAX_WINDOW_DAYS}")
|
|
418
|
+
days = int(days)
|
|
419
|
+
full = source.get("full", False)
|
|
420
|
+
return days, full in (True, "1", "true")
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
@app.get("/api/mining/plan")
|
|
424
|
+
def mining_plan():
|
|
425
|
+
days, full = _mining_args(request.args)
|
|
426
|
+
return jsonify({**mining.plan(ex().profile, days, full), "warehouse_id": ex().warehouse_id})
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
@app.post("/api/mining/runs")
|
|
430
|
+
def mining_start():
|
|
431
|
+
body = request.get_json(silent=True) or {}
|
|
432
|
+
if not isinstance(body, dict):
|
|
433
|
+
raise mining.MiningInvalid("(root): body must be a JSON object")
|
|
434
|
+
days, full = _mining_args(body)
|
|
435
|
+
return jsonify(miner().start(days, full)), 202
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
@app.get("/api/mining/status")
|
|
439
|
+
def mining_status():
|
|
440
|
+
return jsonify(miner().status())
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
@app.get("/api/mining/usage")
|
|
444
|
+
def mining_usage():
|
|
445
|
+
limit = request.args.get("limit", "10")
|
|
446
|
+
if not limit.isdigit() or not 1 <= int(limit) <= 100:
|
|
447
|
+
raise mining.MiningInvalid("limit: must be a whole number from 1 to 100")
|
|
448
|
+
return jsonify(mining.usage_summary(ex().profile, int(limit)))
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _query_sql(body):
|
|
452
|
+
"""The SQL to run: with a windows map, {{title}} references are expanded first."""
|
|
453
|
+
return sqltools.expand_refs(body["sql"], body["windows"]) if body.get("windows") else body["sql"]
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
@app.post("/api/query")
|
|
457
|
+
def query():
|
|
458
|
+
body = request.json
|
|
459
|
+
return jsonify(ex().query(_query_sql(body), body.get("params"), body.get("max_rows", 5000)))
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
@app.post("/api/query/tables")
|
|
463
|
+
def query_tables():
|
|
464
|
+
"""Tables a query reads, after {{title}} expansion. No Databricks call; sqlglot only."""
|
|
465
|
+
return jsonify({"tables": sqltools.tables_read(_query_sql(request.json))})
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
@app.post("/api/query/async")
|
|
469
|
+
def query_async():
|
|
470
|
+
body = request.json
|
|
471
|
+
return jsonify({"statement_id": ex().query_async(_query_sql(body), body.get("params"), body.get("max_rows", 5000))})
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
@app.get("/api/query/<sid>")
|
|
475
|
+
def query_status(sid):
|
|
476
|
+
return jsonify(ex().query_status(sid))
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
@app.delete("/api/query/<sid>")
|
|
480
|
+
def query_cancel(sid):
|
|
481
|
+
return jsonify(ex().query_cancel(sid))
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
# ---------- Ask (genie.py): Genie One questions over MCP. Nothing is stored; the page keeps the conversation ----------
|
|
485
|
+
|
|
486
|
+
@app.errorhandler(genie.GenieInvalid)
|
|
487
|
+
def genie_invalid(e):
|
|
488
|
+
return jsonify({"error": str(e)}), 400
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
@app.errorhandler(genie.GenieError)
|
|
492
|
+
def genie_failed(e):
|
|
493
|
+
return jsonify({"error": dbx.clean_error(e)}), e.status
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def genie_client():
|
|
497
|
+
"""The Genie client for the connected Explorer (the fake one in demo mode), pointed at the selected warehouse."""
|
|
498
|
+
e = ex()
|
|
499
|
+
cached = STATE.get("genie")
|
|
500
|
+
if cached is None or cached[0] is not e:
|
|
501
|
+
cached = STATE["genie"] = (e, demo.FakeGenie() if dbx.DEMO else genie.GenieClient(e.w))
|
|
502
|
+
cached[1].warehouse_id = e.warehouse_id
|
|
503
|
+
return cached[1]
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def _genie_call(op, fn):
|
|
507
|
+
"""Run one Genie call and log its name and timing. Never the question, the answer or the rows."""
|
|
508
|
+
t = time.time()
|
|
509
|
+
try:
|
|
510
|
+
out = fn()
|
|
511
|
+
except Exception as e:
|
|
512
|
+
dbx.log({"op": op, "ms": int((time.time() - t) * 1000), "ok": False, "error": dbx.clean_error(e)[:200]})
|
|
513
|
+
raise
|
|
514
|
+
dbx.log({"op": op, "ms": int((time.time() - t) * 1000), "ok": True})
|
|
515
|
+
return jsonify(out)
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
@app.post("/api/ask")
|
|
519
|
+
def ask():
|
|
520
|
+
question, cid = genie.parse_ask(request.get_json(silent=True))
|
|
521
|
+
return _genie_call("genie.ask", lambda: genie_client().ask(question, cid))
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
@app.get("/api/ask/poll")
|
|
525
|
+
def ask_poll():
|
|
526
|
+
cid, rid = genie.parse_ids(request.args, "conversation_id", "response_id")
|
|
527
|
+
return _genie_call("genie.poll", lambda: genie_client().poll(cid, rid))
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
@app.get("/api/ask/result")
|
|
531
|
+
def ask_result():
|
|
532
|
+
cid, rid, item = genie.parse_ids(request.args, "conversation_id", "response_id", "item_id")
|
|
533
|
+
return _genie_call("genie.result", lambda: genie_client().query_result(cid, rid, item))
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
@app.post("/api/ask/cancel")
|
|
537
|
+
def ask_cancel():
|
|
538
|
+
(cid,) = genie.parse_ids(request.get_json(silent=True) or {}, "conversation_id")
|
|
539
|
+
return _genie_call("genie.cancel", lambda: genie_client().cancel(cid))
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
@app.get("/api/canvases")
|
|
543
|
+
def canvas_list():
|
|
544
|
+
return jsonify(canvases.list_canvases())
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
@app.post("/api/canvases")
|
|
548
|
+
def canvas_save():
|
|
549
|
+
body = request.get_json(silent=True)
|
|
550
|
+
base = request.headers.get("X-Base-Revision") # the page sends the revision it loaded, to avoid overwriting a newer save
|
|
551
|
+
if base is not None and not base.isdigit():
|
|
552
|
+
return jsonify({"error": "X-Base-Revision: must be a whole number"}), 400
|
|
553
|
+
try:
|
|
554
|
+
name, revision = canvases.write_canvas(body, expect=int(base) if base is not None else None)
|
|
555
|
+
return jsonify({"saved": name, "revision": revision})
|
|
556
|
+
except canvases.CanvasInvalid as e:
|
|
557
|
+
return jsonify({"error": str(e)}), 400
|
|
558
|
+
except canvases.CanvasConflict as e:
|
|
559
|
+
return jsonify({"error": str(e), **e.info}), 409
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
@app.get("/api/canvases/<name>")
|
|
563
|
+
def canvas_get(name):
|
|
564
|
+
try:
|
|
565
|
+
return jsonify(canvases.load_canvas(name))
|
|
566
|
+
except canvases.CanvasNotFound as e:
|
|
567
|
+
return jsonify({"error": str(e)}), 404
|
|
568
|
+
except canvases.CanvasInvalid as e:
|
|
569
|
+
return jsonify({"error": str(e)}), 400
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
@app.get("/api/canvases/<name>/revision")
|
|
573
|
+
def canvas_revision(name):
|
|
574
|
+
"""The canvas's revision, who wrote it last (ui, agent or undo) and how many undo snapshots exist. The page polls this."""
|
|
575
|
+
return jsonify(canvases.revision_info(name))
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
@app.post("/api/canvases/<name>/undo")
|
|
579
|
+
def canvas_undo(name):
|
|
580
|
+
"""Restore the state before the last agent write. Another undo goes one agent write further back."""
|
|
581
|
+
return jsonify(canvases.undo_canvas(name))
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
# ---- Layout: doctor, tidy, arrange, describe, render. Each takes {"canvas": {...}} or {"name": "<saved>"}. ----
|
|
585
|
+
|
|
586
|
+
@app.errorhandler(layout.LayoutError)
|
|
587
|
+
def layout_bad_request(e):
|
|
588
|
+
return jsonify({"error": str(e)}), 400
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
@app.errorhandler(canvases.CanvasInvalid)
|
|
592
|
+
def canvas_bad_request(e):
|
|
593
|
+
return jsonify({"error": str(e)}), 400
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
@app.errorhandler(canvases.CanvasNotFound)
|
|
597
|
+
def canvas_not_found(e):
|
|
598
|
+
return jsonify({"error": str(e)}), 404
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def _layout_body():
|
|
602
|
+
"""(body, canvas, grid) for a layout route. The canvas is validated and never saved."""
|
|
603
|
+
body = request.get_json(silent=True)
|
|
604
|
+
if not isinstance(body, dict):
|
|
605
|
+
raise layout.LayoutError("body must be a JSON object")
|
|
606
|
+
if isinstance(body.get("canvas"), dict):
|
|
607
|
+
data = body["canvas"]
|
|
608
|
+
canvases.parse_canvas({**data, "name": data.get("name") or "unsaved"})
|
|
609
|
+
elif isinstance(body.get("name"), str):
|
|
610
|
+
data = canvases.load_canvas(body["name"])
|
|
611
|
+
else:
|
|
612
|
+
raise layout.LayoutError('send {"canvas": {...}} or {"name": "<saved canvas>"}')
|
|
613
|
+
grid = body.get("grid")
|
|
614
|
+
if grid is not None and (isinstance(grid, bool) or not isinstance(grid, (int, float)) or grid <= 0):
|
|
615
|
+
raise layout.LayoutError("grid must be a positive number")
|
|
616
|
+
return body, data, grid
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def _layout_list(body, key):
|
|
620
|
+
v = body.get(key)
|
|
621
|
+
if v is not None and not (isinstance(v, list) and all(isinstance(i, str) for i in v)):
|
|
622
|
+
raise layout.LayoutError(f"{key} must be a list of strings")
|
|
623
|
+
return v
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
@app.post("/api/layout/doctor")
|
|
627
|
+
def layout_doctor():
|
|
628
|
+
_, data, grid = _layout_body()
|
|
629
|
+
return jsonify(layout.doctor(data, grid))
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
@app.post("/api/layout/tidy")
|
|
633
|
+
def layout_tidy():
|
|
634
|
+
body, data, grid = _layout_body()
|
|
635
|
+
out, changes = layout.tidy(data, _layout_list(body, "ids"), _layout_list(body, "rules"), grid)
|
|
636
|
+
return jsonify({"changes": changes, "canvas": out,
|
|
637
|
+
"score": {"before": layout.doctor(data, grid)["score"], "after": layout.doctor(out, grid)["score"]}})
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
@app.post("/api/layout/arrange")
|
|
641
|
+
def layout_arrange():
|
|
642
|
+
body, data, grid = _layout_body()
|
|
643
|
+
opts = body.get("options") or {}
|
|
644
|
+
if not isinstance(opts, dict) or not isinstance(body.get("mode"), str) or {"canvas", "ids", "mode", "grid"} & set(opts):
|
|
645
|
+
raise layout.LayoutError("send mode (a string) and optionally options (an object of mode options)")
|
|
646
|
+
out, changes = layout.arrange(data, _layout_list(body, "ids"), body["mode"], grid=grid, **opts)
|
|
647
|
+
return jsonify({"changes": changes, "canvas": out,
|
|
648
|
+
"score": {"before": layout.doctor(data, grid)["score"], "after": layout.doctor(out, grid)["score"]}})
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
@app.post("/api/layout/describe")
|
|
652
|
+
def layout_describe():
|
|
653
|
+
_, data, grid = _layout_body()
|
|
654
|
+
return jsonify({"text": layout.describe(data, grid=grid)})
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
@app.post("/api/layout/render")
|
|
658
|
+
def layout_render():
|
|
659
|
+
body, data, grid = _layout_body()
|
|
660
|
+
width = body.get("width", 1200)
|
|
661
|
+
if isinstance(width, bool) or not isinstance(width, int):
|
|
662
|
+
raise layout.LayoutError("width must be an integer")
|
|
663
|
+
hi = body.get("highlight")
|
|
664
|
+
if hi == "issues": # every node an error or warning names
|
|
665
|
+
hi = sorted({n for i in layout.doctor(data, grid)["issues"] if i["severity"] != "info" for n in i["nodes"]}, key=str)
|
|
666
|
+
elif hi is not None and not (isinstance(hi, list) and all(isinstance(i, str) for i in hi)):
|
|
667
|
+
raise layout.LayoutError('highlight must be a list of ids or "issues"')
|
|
668
|
+
return app.response_class(layout.render_png(data, width, hi), mimetype="image/png")
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
# ---- MCP server (mcp_server/): /mcp for coding agents, /api/agent-access for the page. Local only, bearer token. ----
|
|
672
|
+
mcp_server.register(app, explorer=lambda: STATE["ex"], store=rel_store, miner=miner)
|
|
673
|
+
|