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,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}
|