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.
@@ -0,0 +1,3 @@
1
+ """Databricks Explorer: a local Unity Catalog explorer with a query canvas, relationship graph and sketching."""
2
+
3
+ __version__ = "0.5.0" # the one place the version is defined; pyproject.toml reads it from here
@@ -0,0 +1,6 @@
1
+ """`python -m databricks_explore` does what the `databricks-explore` command does."""
2
+ import sys
3
+
4
+ from .cli import main
5
+
6
+ sys.exit(main())
@@ -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
+