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,537 @@
1
+ """Databricks access + local JSON cache. Every remote call is logged to calls.jsonl in the data folder's logs/."""
2
+ import configparser, json, os, re, subprocess, time
3
+ from datetime import datetime, timezone
4
+ from pathlib import Path
5
+
6
+ from databricks.sdk import WorkspaceClient
7
+ from platformdirs import user_data_path
8
+ from databricks.sdk.service.sql import StatementParameterListItem, StatementState
9
+
10
+ from . import demo
11
+ from . import sqltools
12
+
13
+
14
+ def default_data_dir(demo: bool = False) -> Path:
15
+ """The per-user data folder (never inside the installed package). Demo mode gets a demo/ folder under it."""
16
+ base = user_data_path("databricks-explore", appauthor=False)
17
+ return base / "demo" if demo else base
18
+
19
+
20
+ DEMO = os.environ.get("EXPLORER_DEMO") == "1" # fake workspace with invented data (databricks-explore --demo)
21
+ # Everything the app writes lives here. Demo mode gets its own folder so demo canvases never mix with real ones.
22
+ DATA = Path(os.environ.get("EXPLORER_DATA_DIR") or default_data_dir(DEMO))
23
+ if DEMO:
24
+ WorkspaceClient = demo.FakeWorkspaceClient # the one hook: Explorer builds its client from this name
25
+ CACHE, LOGS, DIAGRAMS = DATA / "cache", DATA / "logs", DATA / "diagrams"
26
+ for d in (CACHE, LOGS, DIAGRAMS):
27
+ d.mkdir(parents=True, exist_ok=True)
28
+
29
+ # Column lineage for one table, either end. One row per column pair; event_time is reduced to the latest.
30
+ COLUMN_LINEAGE_SQL = """SELECT source_table_full_name, source_column_name, target_table_full_name, target_column_name,
31
+ max(event_time) AS last_seen
32
+ FROM system.access.column_lineage
33
+ WHERE (target_table_full_name = :t OR source_table_full_name = :t)
34
+ AND source_table_full_name IS NOT NULL AND target_table_full_name IS NOT NULL
35
+ AND source_column_name IS NOT NULL AND target_column_name IS NOT NULL
36
+ GROUP BY 1, 2, 3, 4
37
+ ORDER BY last_seen DESC
38
+ LIMIT 5000"""
39
+
40
+ # What the lineage API returns per upstream/downstream, besides tableInfo
41
+ ENTITY_KEYS = {"jobInfos": "job", "notebookInfos": "notebook", "pipelineInfos": "pipeline",
42
+ "dashboardV3Infos": "dashboard", "queryInfos": "query", "fileInfo": "file"}
43
+
44
+
45
+ _CONFIG_DUMP = re.compile(r"\s*\bConfig:\s*\w+=.*", re.S)
46
+
47
+
48
+ def _type_of(col):
49
+ """Manifest type name. The SDK enum lacks newer types (TIMESTAMP_NTZ, VARIANT), so fall back to type_text's base name."""
50
+ if col.type_name:
51
+ return col.type_name.value
52
+ base = re.split(r"[(<\s]", (col.type_text or "").strip())[0].upper()
53
+ return base or None
54
+
55
+
56
+ def now():
57
+ return datetime.now(timezone.utc).isoformat(timespec="seconds")
58
+
59
+
60
+ def is_internal(name):
61
+ """MV/streaming-table backing tables, e.g. __materialization_mat_<uuid>_<mv>_1. Not user-readable."""
62
+ return name.split(".")[-1].startswith("__")
63
+
64
+
65
+ def clean_error(message):
66
+ """Error text for the UI: drops the SDK's trailing "Config: host=..., profile=..." dump and anything after it."""
67
+ return _CONFIG_DUMP.sub("", str(message)).rstrip()
68
+
69
+
70
+ def safe(s):
71
+ return re.sub(r"[^A-Za-z0-9_.-]", "_", s)
72
+
73
+
74
+ def log(entry):
75
+ with open(LOGS / "calls.jsonl", "a", encoding="utf-8") as f:
76
+ f.write(json.dumps({"ts": now(), **entry}, default=str) + "\n")
77
+
78
+
79
+ # ---------- profiles / auth ----------
80
+
81
+ def cfg_path():
82
+ return Path(os.environ.get("DATABRICKS_CONFIG_FILE", Path.home() / ".databrickscfg"))
83
+
84
+
85
+ def list_profiles():
86
+ if DEMO:
87
+ return demo.PROFILES
88
+ cp = configparser.ConfigParser()
89
+ cp.read(cfg_path())
90
+ out = []
91
+ for name in cp.sections() + (["DEFAULT"] if cp.defaults() else []):
92
+ if name.startswith("__") and name.endswith("__"):
93
+ continue # tool settings such as [__settings__], not a workspace profile
94
+ sec = cp[name]
95
+ out.append({"name": name, "host": sec.get("host", ""), "auth_type": sec.get("auth_type", "")})
96
+ return out
97
+
98
+
99
+ def cli_login(profile):
100
+ """Opens the browser for U2M OAuth. Blocks until the user finishes (or 3 min)."""
101
+ t = time.time()
102
+ r = subprocess.run(["databricks", "auth", "login", "--profile", profile],
103
+ capture_output=True, text=True, timeout=180)
104
+ log({"op": "cli auth login", "profile": profile, "ms": int((time.time() - t) * 1000),
105
+ "ok": r.returncode == 0, "stderr": r.stderr[-500:]})
106
+ if r.returncode != 0:
107
+ raise RuntimeError(r.stderr or r.stdout)
108
+
109
+
110
+ class Explorer:
111
+ def __init__(self, profile):
112
+ self.profile = profile
113
+ self.w = WorkspaceClient(profile=profile)
114
+ self.root = CACHE / safe(profile)
115
+ self.warehouse_id = None
116
+ self.workspace_id = None
117
+ self.jobs = {} # async statement_id -> {max_rows, can_detect, t0}; lives only as long as the process
118
+
119
+ def whoami(self):
120
+ me = self._call("current_user.me", lambda: self.w.current_user.me().as_dict())
121
+ try:
122
+ self.workspace_id = str(self.w.get_workspace_id())
123
+ except Exception:
124
+ pass
125
+ return {"user": me.get("userName"), "host": self.w.config.host, "workspace_id": self.workspace_id}
126
+
127
+ # ---- generic helpers ----
128
+ def _call(self, op, fn, **args):
129
+ t = time.time()
130
+ try:
131
+ res = fn()
132
+ n = len(res) if isinstance(res, list) else None
133
+ log({"op": op, "profile": self.profile, **args, "ms": int((time.time() - t) * 1000), "ok": True, "count": n})
134
+ return res
135
+ except Exception as e:
136
+ log({"op": op, "profile": self.profile, **args, "ms": int((time.time() - t) * 1000), "ok": False, "error": str(e)[:500]})
137
+ raise
138
+
139
+ def _cached(self, rel, fetch, refresh):
140
+ p = self.root / rel
141
+ if p.exists() and not refresh:
142
+ return json.loads(p.read_text(encoding="utf-8"))
143
+ payload = {"fetched_at": now(), "data": fetch()}
144
+ p.parent.mkdir(parents=True, exist_ok=True)
145
+ p.write_text(json.dumps(payload, indent=2, default=str), encoding="utf-8")
146
+ return payload
147
+
148
+ @staticmethod
149
+ def _dir(full_name):
150
+ c, s, _ = full_name.split(".", 2)
151
+ return f"{safe(c)}/{safe(s)}"
152
+
153
+ # ---- SQL warehouse (optional; only used for search-everything) ----
154
+ def warehouses(self):
155
+ return self._call("warehouses.list", lambda: [
156
+ {"id": w.id, "name": w.name, "state": w.state.value if w.state else None}
157
+ for w in self.w.warehouses.list()])
158
+
159
+ def sql(self, statement, **params):
160
+ if not self.warehouse_id:
161
+ raise RuntimeError("No SQL warehouse selected.")
162
+
163
+ def run():
164
+ r = self.w.statement_execution.execute_statement(
165
+ statement=statement, warehouse_id=self.warehouse_id, wait_timeout="30s",
166
+ parameters=[StatementParameterListItem(name=k, value=str(v)) for k, v in params.items()])
167
+ while r.status.state in (StatementState.PENDING, StatementState.RUNNING):
168
+ time.sleep(1)
169
+ r = self.w.statement_execution.get_statement(r.statement_id)
170
+ if r.status.state != StatementState.SUCCEEDED:
171
+ raise RuntimeError(r.status.error.message if r.status.error else r.status.state.value)
172
+ cols = [c.name for c in r.manifest.schema.columns]
173
+ return [dict(zip(cols, row)) for row in ((r.result and r.result.data_array) or [])]
174
+ return self._call("sql", run, statement=" ".join(statement.split())[:400], params=params)
175
+
176
+ # ---- query execution (A1): sync and async, row-capped, typed columns. Contract: contracts/api.md §3 ----
177
+ def _elapsed(self, t0):
178
+ return int((time.time() - t0) * 1000)
179
+
180
+ def _submit(self, sql, params, max_rows, wait):
181
+ max_rows = int(max_rows)
182
+ if max_rows < 1:
183
+ raise ValueError("max_rows must be at least 1.")
184
+ if not self.warehouse_id:
185
+ raise RuntimeError("No SQL warehouse selected.")
186
+ wrapped, can_detect = sqltools.wrap_limit(sql, max_rows)
187
+ se = self.w.statement_execution
188
+ r = self._call("statement.execute", lambda: se.execute_statement(
189
+ statement=wrapped, warehouse_id=self.warehouse_id, wait_timeout=wait,
190
+ parameters=[StatementParameterListItem(name=k, value=str(v)) for k, v in (params or {}).items()]),
191
+ statement=" ".join(sql.split())[:400], params=params or {}, max_rows=max_rows,
192
+ limit_wrapped=can_detect)
193
+ return r, max_rows, can_detect
194
+
195
+ def _await(self, r):
196
+ """Polls until the statement leaves PENDING/RUNNING. Every poll is a logged call."""
197
+ sid = r.statement_id
198
+ while r.status.state in (StatementState.PENDING, StatementState.RUNNING):
199
+ time.sleep(1)
200
+ r = self._call("statement.get", lambda: self.w.statement_execution.get_statement(sid), statement_id=sid)
201
+ return r
202
+
203
+ def _collect(self, r, max_rows, can_detect):
204
+ """Column types from the manifest; rows from the first chunk and then every further chunk,
205
+ stopping once max_rows + 1 rows are seen (or the result ends). Trims to max_rows."""
206
+ sid = r.statement_id
207
+ cols = [{"name": c.name, "type": _type_of(c), "type_text": c.type_text or None}
208
+ for c in ((r.manifest and r.manifest.schema and r.manifest.schema.columns) or [])]
209
+ rows = list((r.result and r.result.data_array) or [])
210
+ nxt = r.result.next_chunk_index if r.result else None
211
+ while nxt is not None and len(rows) <= max_rows:
212
+ chunk = self._call("statement.result_chunk",
213
+ lambda i=nxt: self.w.statement_execution.get_statement_result_chunk_n(sid, i),
214
+ statement_id=sid, chunk_index=nxt)
215
+ rows.extend(chunk.data_array or [])
216
+ nxt = chunk.next_chunk_index
217
+ truncated = (len(rows) > max_rows) if can_detect else None
218
+ rows = rows[:max_rows]
219
+ return {"statement_id": sid, "columns": cols, "rows": rows, "row_count": len(rows), "truncated": truncated}
220
+
221
+ def query(self, sql, params=None, max_rows=5000):
222
+ """Runs sql and waits for the result (up to the Databricks wait, then polls)."""
223
+ t = time.time()
224
+ r, max_rows, can_detect = self._submit(sql, params, max_rows, wait="30s")
225
+ r = self._await(r)
226
+ if r.status.state != StatementState.SUCCEEDED:
227
+ raise RuntimeError(r.status.error.message if r.status.error else r.status.state.value)
228
+ return {**self._collect(r, max_rows, can_detect), "ms": self._elapsed(t)}
229
+
230
+ def query_async(self, sql, params=None, max_rows=5000):
231
+ """Submits without waiting and returns the statement_id. Poll with query_status()."""
232
+ t = time.time()
233
+ r, max_rows, can_detect = self._submit(sql, params, max_rows, wait="0s")
234
+ self.jobs[r.statement_id] = {"max_rows": max_rows, "can_detect": can_detect, "t0": t, "ms": None}
235
+ return r.statement_id
236
+
237
+ def query_status(self, sid):
238
+ job = self.jobs.get(sid)
239
+ if job is None:
240
+ raise RuntimeError(f"Unknown statement_id: {sid} (not submitted to this server process)")
241
+ r = self._call("statement.get", lambda: self.w.statement_execution.get_statement(sid), statement_id=sid)
242
+ state = r.status.state
243
+ out = {"statement_id": sid, "columns": [], "rows": [], "row_count": 0, "truncated": None, "ms": None}
244
+ if state not in (StatementState.PENDING, StatementState.RUNNING):
245
+ if job["ms"] is None: # first poll that sees a finished state: submit-to-completion, to poll granularity
246
+ job["ms"] = self._elapsed(job["t0"])
247
+ out["ms"] = job["ms"]
248
+ if state == StatementState.SUCCEEDED:
249
+ out.update(self._collect(r, job["max_rows"], job["can_detect"]))
250
+ elif state == StatementState.FAILED:
251
+ out["error"] = clean_error(r.status.error.message) if r.status.error else "FAILED"
252
+ return {**out, "state": state.value, "error": out.get("error")}
253
+
254
+ def query_cancel(self, sid):
255
+ """Best-effort, asynchronous. Poll query_status() to see CANCELED."""
256
+ self._call("statement.cancel", lambda: self.w.statement_execution.cancel_execution(sid), statement_id=sid)
257
+ return {"statement_id": sid, "cancel_requested": True}
258
+
259
+ # ---- metastore browsing ----
260
+ def catalogs(self, refresh=False):
261
+ return self._cached("catalogs.json", lambda: self._call(
262
+ "catalogs.list", lambda: sorted(
263
+ [{"name": c.name, "type": c.catalog_type.value if c.catalog_type else None, "comment": c.comment}
264
+ for c in self.w.catalogs.list()], key=lambda x: x["name"])), refresh)
265
+
266
+ def schemas(self, catalog, refresh=False):
267
+ return self._cached(f"{safe(catalog)}/schemas.json", lambda: self._call(
268
+ "schemas.list", lambda: sorted(
269
+ [{"name": s.name, "comment": s.comment} for s in self.w.schemas.list(catalog_name=catalog)],
270
+ key=lambda x: x["name"]), catalog=catalog), refresh)
271
+
272
+ def objects(self, catalog, schema, refresh=False):
273
+ def fetch():
274
+ return sorted([{"name": t.name, "full_name": t.full_name,
275
+ "type": t.table_type.value if t.table_type else None, "comment": t.comment}
276
+ for t in self.w.tables.list(catalog_name=catalog, schema_name=schema)
277
+ if not is_internal(t.name)],
278
+ key=lambda x: x["name"])
279
+ return self._cached(f"{safe(catalog)}/{safe(schema)}/objects.json",
280
+ lambda: self._call("tables.list", fetch, catalog=catalog, schema=schema), refresh)
281
+
282
+ def search(self, q):
283
+ """Name search. information_schema if a warehouse is set, else whatever schemas are cached locally."""
284
+ if self.warehouse_id:
285
+ rows = self.sql("""
286
+ SELECT table_catalog, table_schema, table_name, table_type
287
+ FROM system.information_schema.tables
288
+ WHERE lower(table_name) LIKE lower(concat('%', :q, '%'))
289
+ AND left(table_name, 2) <> '__' -- not NOT startswith(): on information_schema that filter drops every row
290
+ ORDER BY table_name, table_catalog, table_schema
291
+ LIMIT 300""", q=q)
292
+ return {"source": "information_schema", "data": [
293
+ {"full_name": f"{r['table_catalog']}.{r['table_schema']}.{r['table_name']}",
294
+ "name": r["table_name"], "type": r["table_type"]} for r in rows]}
295
+ hits = [o for p in self.root.glob("*/*/objects.json")
296
+ for o in json.loads(p.read_text(encoding="utf-8"))["data"] if q.lower() in o["name"].lower()]
297
+ return {"source": "cached schemas only (pick a SQL warehouse to search everything)",
298
+ "data": sorted(hits, key=lambda x: (x["name"], x["full_name"]))[:300]}
299
+
300
+ def object(self, full_name, refresh=False):
301
+ return self._cached(f"{self._dir(full_name)}/obj/{safe(full_name)}.json",
302
+ lambda: self._call("tables.get", lambda: self.w.tables.get(full_name=full_name).as_dict(),
303
+ full_name=full_name), refresh)
304
+
305
+ def lineage(self, full_name, refresh=False):
306
+ def fetch():
307
+ return self.w.api_client.do("GET", "/api/2.0/lineage-tracking/table-lineage",
308
+ body={"table_name": full_name, "include_entity_lineage": True})
309
+ return self._cached(f"{self._dir(full_name)}/lineage/{safe(full_name)}.json",
310
+ lambda: self._call("lineage-tracking/table-lineage", fetch, full_name=full_name), refresh)
311
+
312
+ def column_lineage(self, full_name, refresh=False):
313
+ """Column-level lineage rows touching one table, from system.access.column_lineage (one warehouse query).
314
+
315
+ Cached like the other metadata, and a cached copy is returned even when no warehouse is selected.
316
+ Raises RuntimeError without a warehouse and no cache, and whatever the warehouse says (no access) otherwise.
317
+ """
318
+ def fetch():
319
+ if not self.warehouse_id:
320
+ raise RuntimeError("Column lineage is read with a SQL warehouse (system.access.column_lineage). "
321
+ "Pick one.")
322
+ return self.sql(COLUMN_LINEAGE_SQL, t=full_name)
323
+ return self._cached(f"{self._dir(full_name)}/column_lineage/{safe(full_name)}.json", fetch, refresh)
324
+
325
+ # ---- edge details: what produced a lineage link, and when ----
326
+ def _link(self, kind, info):
327
+ h = self.w.config.host.rstrip("/")
328
+ i = {"job": info.get("job_id"), "notebook": info.get("notebook_id"),
329
+ "pipeline": info.get("pipeline_id"), "dashboard": info.get("dashboard_id"),
330
+ "query": info.get("query_id")}.get(kind)
331
+ url = {"job": f"{h}/jobs/{i}", "notebook": f"{h}/#notebook/{i}",
332
+ "pipeline": f"{h}/pipelines/{i}", "dashboard": f"{h}/dashboardsv3/{i}",
333
+ "query": f"{h}/sql/editor/{i}"}.get(kind)
334
+ other_ws = info.get("workspace_id") and self.workspace_id and str(info["workspace_id"]) != self.workspace_id
335
+ return {"kind": kind, "id": str(i), "url": None if other_ws or not i else url,
336
+ "workspace_id": str(info.get("workspace_id", "")), "other_workspace": bool(other_ws),
337
+ "last_seen": info.get("lineage_timestamp"), "raw": info}
338
+
339
+ def edge_details(self, src, tgt):
340
+ """Reads the cached lineage of src (downstreams) and tgt (upstreams) for this pair, both directions."""
341
+ flows = []
342
+ for a, b in ((src, tgt), (tgt, src)):
343
+ seen = set()
344
+ for owner, direction, other in ((a, "downstreams", b), (b, "upstreams", a)):
345
+ p = self.root / f"{self._dir(owner)}/lineage/{safe(owner)}.json"
346
+ if not p.exists():
347
+ continue
348
+ for item in json.loads(p.read_text(encoding="utf-8"))["data"].get(direction) or []:
349
+ ti = item.get("tableInfo") or {}
350
+ if f"{ti.get('catalog_name')}.{ti.get('schema_name')}.{ti.get('name')}" != other:
351
+ continue
352
+ producers = []
353
+ for key, kind in ENTITY_KEYS.items():
354
+ v = item.get(key)
355
+ for info in (v if isinstance(v, list) else [v] if v else []):
356
+ ent = self._link(kind, info)
357
+ if (ent["kind"], ent["id"]) not in seen:
358
+ seen.add((ent["kind"], ent["id"]))
359
+ producers.append(ent)
360
+ if producers or ti:
361
+ flows.append({"source": a, "target": b, "last_seen": ti.get("lineage_timestamp"),
362
+ "producers": sorted(producers, key=lambda x: x["last_seen"] or "", reverse=True)})
363
+ # merge duplicates found from both ends
364
+ merged = {}
365
+ for f in flows:
366
+ k = (f["source"], f["target"])
367
+ if k in merged:
368
+ ids = {(p["kind"], p["id"]) for p in merged[k]["producers"]}
369
+ merged[k]["producers"] += [p for p in f["producers"] if (p["kind"], p["id"]) not in ids]
370
+ merged[k]["last_seen"] = max(filter(None, [merged[k]["last_seen"], f["last_seen"]]), default=None)
371
+ else:
372
+ merged[k] = f
373
+ return {"flows": list(merged.values())}
374
+
375
+ # ---- graph assembly ----
376
+ def relations(self, full_name, refresh=False):
377
+ """Nodes + edges for one object: lineage, foreign keys, view dependencies."""
378
+ nodes, edges, errors = {}, {}, []
379
+
380
+ def node(fid, kind="table", **extra):
381
+ nodes.setdefault(fid, {"id": fid, "label": fid.split(".")[-1], "kind": kind, **extra})
382
+
383
+ node(full_name, focus=True)
384
+
385
+ try:
386
+ info = self.object(full_name, refresh)["data"]
387
+ nodes[full_name]["kind"] = (info.get("table_type") or "table").lower()
388
+ for con in info.get("table_constraints") or []:
389
+ fk = con.get("foreign_key_constraint")
390
+ if fk and not is_internal(fk["parent_table"]):
391
+ parent = fk["parent_table"]
392
+ node(parent)
393
+ edges[f"fk:{full_name}->{parent}"] = {
394
+ "id": f"fk:{full_name}->{parent}", "source": full_name, "target": parent, "kind": "fk",
395
+ "constraint": fk.get("name"), "child_columns": fk.get("child_columns", []),
396
+ "parent_columns": fk.get("parent_columns", []),
397
+ "label": ", ".join(fk.get("child_columns", []))}
398
+ for dep in (info.get("view_dependencies") or {}).get("dependencies", []):
399
+ t = (dep.get("table") or {}).get("table_full_name")
400
+ if t and not is_internal(t):
401
+ node(t)
402
+ edges[f"view_dep:{t}->{full_name}"] = {"id": f"view_dep:{t}->{full_name}",
403
+ "source": t, "target": full_name, "kind": "view_dep"}
404
+ except Exception as e:
405
+ errors.append(f"tables.get: {clean_error(e)}")
406
+
407
+ try:
408
+ lin = self.lineage(full_name, refresh)["data"]
409
+ for direction in ("upstreams", "downstreams"):
410
+ for item in lin.get(direction) or []:
411
+ ti = item.get("tableInfo")
412
+ if not ti:
413
+ continue
414
+ other = f"{ti['catalog_name']}.{ti['schema_name']}.{ti['name']}"
415
+ if is_internal(other):
416
+ continue
417
+ ts = ti.get("lineage_timestamp")
418
+ if other == full_name:
419
+ nodes[full_name]["self_ref"] = True
420
+ nodes[full_name]["self_ref_last_seen"] = max(filter(None, [nodes[full_name].get("self_ref_last_seen"), ts]), default=None)
421
+ continue
422
+ node(other, (ti.get("table_type") or "table").lower())
423
+ src, dst = (other, full_name) if direction == "upstreams" else (full_name, other)
424
+ # one edge per pair; a link both ways becomes bidir
425
+ key = "lineage:" + "<->".join(sorted([src, dst]))
426
+ e = edges.get(key)
427
+ if e is None:
428
+ edges[key] = {"id": key, "source": src, "target": dst, "kind": "lineage",
429
+ "bidir": False, "last_seen": ts, "last_seen_by_dir": {f"{src}->{dst}": ts}}
430
+ else:
431
+ e["last_seen_by_dir"][f"{src}->{dst}"] = ts
432
+ if (e["source"], e["target"]) != (src, dst):
433
+ e["bidir"] = True
434
+ e["last_seen"] = max(filter(None, [e["last_seen"], ts]), default=None)
435
+ except Exception as e:
436
+ errors.append(f"lineage: {clean_error(e)}")
437
+
438
+ return {"nodes": list(nodes.values()), "edges": list(edges.values()), "errors": errors}
439
+
440
+
441
+ # ---------- relationship store support (see relationships.py) ----------
442
+
443
+ def cache_times(explorer, full_name):
444
+ """When the cached object and lineage files behind relations(full_name) were fetched; None when absent."""
445
+ out = {}
446
+ for key, sub in (("object", "obj"), ("lineage", "lineage")):
447
+ p = explorer.root / f"{Explorer._dir(full_name)}/{sub}/{safe(full_name)}.json"
448
+ try:
449
+ out[key] = json.loads(p.read_text(encoding="utf-8")).get("fetched_at")
450
+ except (OSError, ValueError):
451
+ out[key] = None
452
+ return out
453
+
454
+
455
+ def cached_metadata(explorer):
456
+ """Tables with the names and comments already in the local cache, for searching without a warehouse.
457
+
458
+ Reads data/cache/<profile>/<catalog>/<schema>/objects.json (table names and comments) and obj/*.json (columns).
459
+ Returns [{"table", "type", "comment", "columns": [{"name", "comment"}] or None}] sorted by table name; `columns` is
460
+ None for a table whose object file was never fetched. Only what was browsed before is there.
461
+ """
462
+ tables = {}
463
+ for p in explorer.root.glob("*/*/objects.json"):
464
+ try:
465
+ rows = json.loads(p.read_text(encoding="utf-8")).get("data") or []
466
+ except (OSError, ValueError):
467
+ continue
468
+ for o in rows:
469
+ if o.get("full_name"):
470
+ tables[o["full_name"]] = {"table": o["full_name"], "type": o.get("type"),
471
+ "comment": o.get("comment"), "columns": None}
472
+ for p in explorer.root.glob("*/*/obj/*.json"):
473
+ try:
474
+ info = json.loads(p.read_text(encoding="utf-8")).get("data") or {}
475
+ except (OSError, ValueError):
476
+ continue
477
+ name = info.get("full_name")
478
+ if not name:
479
+ continue
480
+ entry = tables.setdefault(name, {"table": name, "type": info.get("table_type"), "comment": None, "columns": None})
481
+ entry["comment"] = info.get("comment") or entry["comment"]
482
+ entry["columns"] = [{"name": c.get("name"), "comment": c.get("comment")}
483
+ for c in info.get("columns") or [] if c.get("name")]
484
+ return sorted(tables.values(), key=lambda t: t["table"])
485
+
486
+
487
+ def cached_search(explorer, q, limit=300):
488
+ """Search table names across everything in the local cache: every catalog and schema ever browsed, and every table
489
+ whose object was ever fetched. `q` matches the table name or the full `catalog.schema.table` name, any case.
490
+ Returns {"source", "data": [{"full_name", "name", "type"}]}, name matches first, at most `limit`."""
491
+ needle = q.strip().lower()
492
+ hits = [{"full_name": t["table"], "name": t["table"].rsplit(".", 1)[-1], "type": t["type"]}
493
+ for t in cached_metadata(explorer) if needle and needle in t["table"].lower()]
494
+ hits.sort(key=lambda h: (needle not in h["name"].lower(), h["name"], h["full_name"]))
495
+ return {"source": "tables already browsed (pick a SQL warehouse to search everything, or type the full name)",
496
+ "data": hits[:limit]}
497
+
498
+
499
+ # ---------- diagrams ----------
500
+
501
+ def list_diagrams():
502
+ return sorted(p.stem for p in DIAGRAMS.glob("*.json"))
503
+
504
+
505
+ def save_diagram(name, payload):
506
+ p = DIAGRAMS / f"{safe(name)}.json"
507
+ p.write_text(json.dumps({"name": name, "saved_at": now(), **payload}, indent=2), encoding="utf-8")
508
+ return p.stem
509
+
510
+
511
+ def load_diagram(name):
512
+ return json.loads((DIAGRAMS / f"{safe(name)}.json").read_text(encoding="utf-8"))
513
+
514
+
515
+ # ---------- read-only calls for the ontology import (ontology.py) ----------
516
+
517
+ def api_get(explorer, path, query=None):
518
+ """GET a workspace REST path through the SDK's api_client, logged like every other call. Returns the parsed JSON."""
519
+ query = {k: v for k, v in (query or {}).items() if v is not None}
520
+ return explorer._call("api.get", lambda: explorer.w.api_client.do("GET", path, query=query or None),
521
+ path=path, query=query)
522
+
523
+
524
+ def table_metric_info(explorer, full_name):
525
+ """One tables.get call for a metric view: {"definition": the `view_definition` YAML or None, "kinds": {column:
526
+ "dimension"|"measure"}}. The kinds come from each column's `type_json` metadata (`metric_view.type`)."""
527
+ info = explorer._call("tables.get", lambda: explorer.w.tables.get(full_name), full_name=full_name)
528
+ kinds = {}
529
+ for col in getattr(info, "columns", None) or []:
530
+ try:
531
+ meta = (json.loads(col.type_json) or {}).get("metadata") or {}
532
+ kind = meta.get("metric_view.type")
533
+ except (TypeError, ValueError, AttributeError):
534
+ continue
535
+ if kind in ("dimension", "measure"):
536
+ kinds[col.name] = kind
537
+ return {"definition": getattr(info, "view_definition", None), "kinds": kinds}