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,508 @@
|
|
|
1
|
+
"""Import from Databricks: Genie Ontology domains and pages, metric view measures and dimensions, and certification.
|
|
2
|
+
|
|
3
|
+
Read-only. There is no export of an ontology, so the parts that can be read are read one by one: Domains and Pages
|
|
4
|
+
through the REST API (both Beta, so every field is read defensively), metric views through the tables API after a SQL
|
|
5
|
+
lookup, and the `system.certification_status` tag through the tag tables. Everything lands as concepts with origin
|
|
6
|
+
`ontology` (see concepts.py and relationships.py); a rerun matches by `provenance.source_ref`.
|
|
7
|
+
"""
|
|
8
|
+
import json
|
|
9
|
+
import re
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import yaml
|
|
13
|
+
from databricks.sdk import errors
|
|
14
|
+
from pydantic import ValidationError
|
|
15
|
+
|
|
16
|
+
from . import concepts
|
|
17
|
+
from . import dbx
|
|
18
|
+
from . import relationships as rel
|
|
19
|
+
from .relationships import Concept, ConceptProvenance, Endpoint, Provenance
|
|
20
|
+
|
|
21
|
+
DOMAINS_PATH = "/api/2.0/domains"
|
|
22
|
+
PAGES_PATH = "/api/2.0/knowledge/pages"
|
|
23
|
+
TABLE_PATH = "/api/2.1/unity-catalog/tables/{full_name}"
|
|
24
|
+
MAX_REQUESTS = 100 # pages of one list, so a server that repeats a token cannot loop forever
|
|
25
|
+
VIEW_CAP = 500
|
|
26
|
+
CERT_ROW_CAP = 20000
|
|
27
|
+
CERT_TAG = "system.certification_status"
|
|
28
|
+
CERT_VALUES = ("certified", "deprecated")
|
|
29
|
+
CERT_CACHE = "certification.json" # under the profile's cache folder, next to the catalog metadata
|
|
30
|
+
UNAVAILABLE = "not available on this workspace"
|
|
31
|
+
FIELDS = ("name", "definition", "synonyms", "domain", "status")
|
|
32
|
+
_BLANK = ("", [], None)
|
|
33
|
+
|
|
34
|
+
METRIC_VIEWS_SQL = """SELECT table_catalog, table_schema, table_name
|
|
35
|
+
FROM system.information_schema.tables
|
|
36
|
+
WHERE table_type = 'METRIC_VIEW'
|
|
37
|
+
ORDER BY table_catalog, table_schema, table_name"""
|
|
38
|
+
|
|
39
|
+
# One row, one JSON string: per column `name`, `comment` and `metadata` {display_name, synonyms}, plus `view_text` (the YAML).
|
|
40
|
+
# The tables API strips display_name, synonyms and the YAML comments from a metric view, so this is the only full source.
|
|
41
|
+
METRIC_DESCRIBE_SQL = "DESCRIBE TABLE EXTENDED {view} AS JSON"
|
|
42
|
+
|
|
43
|
+
CERT_SQL = f"""SELECT 'table' AS scope, catalog_name, schema_name, table_name, CAST(NULL AS STRING) AS column_name, tag_value
|
|
44
|
+
FROM system.information_schema.table_tags
|
|
45
|
+
WHERE tag_name = '{CERT_TAG}'
|
|
46
|
+
UNION ALL
|
|
47
|
+
SELECT 'column', catalog_name, schema_name, table_name, column_name, tag_value
|
|
48
|
+
FROM system.information_schema.column_tags
|
|
49
|
+
WHERE tag_name = '{CERT_TAG}'"""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def plan(warehouse_id=None):
|
|
53
|
+
"""Every REST call and SQL statement an import run makes, for a confirm dialog. Nothing runs."""
|
|
54
|
+
return {"steps": [
|
|
55
|
+
{"id": "domains", "kind": "rest", "label": "Genie Ontology domains (Beta)", "method": "GET", "path": DOMAINS_PATH,
|
|
56
|
+
"paginated": True},
|
|
57
|
+
{"id": "pages", "kind": "rest", "label": "Genie Ontology pages (Beta)", "method": "GET", "path": PAGES_PATH,
|
|
58
|
+
"paginated": True},
|
|
59
|
+
{"id": "metric_views", "kind": "sql", "label": "Metric views, through the selected SQL warehouse",
|
|
60
|
+
"sql": METRIC_VIEWS_SQL},
|
|
61
|
+
{"id": "metric_definitions", "kind": "rest", "label": "Each metric view's definition and column kinds (measure or dimension), once per view found",
|
|
62
|
+
"method": "GET", "path": TABLE_PATH, "paginated": False},
|
|
63
|
+
{"id": "metric_describe", "kind": "sql",
|
|
64
|
+
"label": "Each metric view's column comments, display names and synonyms, once per view found, through the "
|
|
65
|
+
"selected SQL warehouse (the definition above is the fallback when this fails)",
|
|
66
|
+
"sql": METRIC_DESCRIBE_SQL.format(view="<catalog>.<schema>.<view>")},
|
|
67
|
+
{"id": "certification", "kind": "sql",
|
|
68
|
+
"label": "Certification tags (system.certification_status), through the selected SQL warehouse; refreshes the local cache",
|
|
69
|
+
"sql": CERT_SQL},
|
|
70
|
+
], "row_cap": VIEW_CAP, "warehouse_id": warehouse_id}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ---------- reading ----------
|
|
74
|
+
|
|
75
|
+
def _get(d, *keys):
|
|
76
|
+
"""The first of these keys the dict has with a value (snake_case and camelCase spellings both appear)."""
|
|
77
|
+
for k in keys:
|
|
78
|
+
if isinstance(d, dict) and d.get(k) is not None:
|
|
79
|
+
return d[k]
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _text(v):
|
|
84
|
+
return v.strip() if isinstance(v, str) else ""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _strings(v):
|
|
88
|
+
"""A list of trimmed non-empty strings from a list (anything else gives none)."""
|
|
89
|
+
return rel._clean_list([s for s in v if isinstance(s, str)]) if isinstance(v, list) else []
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _items(resp, *keys):
|
|
93
|
+
"""The records of one list response: under one of these keys, else the first list, else the list itself."""
|
|
94
|
+
if isinstance(resp, list):
|
|
95
|
+
found = resp
|
|
96
|
+
elif isinstance(resp, dict):
|
|
97
|
+
found = next((resp[k] for k in keys if isinstance(resp.get(k), list)), None)
|
|
98
|
+
if found is None:
|
|
99
|
+
found = next((v for v in resp.values() if isinstance(v, list)), [])
|
|
100
|
+
else:
|
|
101
|
+
found = []
|
|
102
|
+
return [x for x in found if isinstance(x, dict)]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _fetch_all(ex, path, *keys):
|
|
106
|
+
"""Every record of a paginated list, following next_page_token."""
|
|
107
|
+
items, token = [], None
|
|
108
|
+
for _ in range(MAX_REQUESTS):
|
|
109
|
+
resp = dbx.api_get(ex, path, {"page_token": token} if token else None)
|
|
110
|
+
items.extend(_items(resp, *keys))
|
|
111
|
+
token = _get(resp, "next_page_token", "nextPageToken")
|
|
112
|
+
if not token or not isinstance(token, str):
|
|
113
|
+
break
|
|
114
|
+
return items
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _unavailable(e):
|
|
118
|
+
"""True for a 403 or 404: the Beta endpoint is not there, or not for this user or workspace."""
|
|
119
|
+
if isinstance(e, (errors.NotFound, errors.PermissionDenied)):
|
|
120
|
+
return True
|
|
121
|
+
if getattr(e, "status_code", None) in (403, 404):
|
|
122
|
+
return True
|
|
123
|
+
return bool(re.search(r"\b40[34]\b|not found|permission denied|forbidden", str(e), re.I))
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _failure(e):
|
|
127
|
+
if _unavailable(e):
|
|
128
|
+
return {"status": "unavailable", "message": UNAVAILABLE}
|
|
129
|
+
return {"status": "failed", "message": dbx.clean_error(e)}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _domain_labels(domains):
|
|
133
|
+
"""Every way a page can name a domain (id, name, tag key, with or without `domains/`) to the domain's label."""
|
|
134
|
+
labels = {}
|
|
135
|
+
for d in domains:
|
|
136
|
+
label = _text(_get(d, "display_name", "displayName", "title")) or _text(_get(d, "tag_key", "tagKey"))
|
|
137
|
+
if not label:
|
|
138
|
+
continue
|
|
139
|
+
for key in (_get(d, "domain_id", "domainId"), _get(d, "name"), _get(d, "tag_key", "tagKey")):
|
|
140
|
+
if isinstance(key, str) and key:
|
|
141
|
+
labels[key] = labels[key.split("/")[-1]] = label
|
|
142
|
+
labels[f"domains/{key.split('/')[-1]}"] = label
|
|
143
|
+
return labels
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _page_domain(labels, page):
|
|
147
|
+
raw = _text(_get(page, "domain", "domain_name", "domainName"))
|
|
148
|
+
if not raw:
|
|
149
|
+
return None
|
|
150
|
+
return labels.get(raw) or labels.get(raw.split("/")[-1]) or raw.split("/")[-1] or None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _page_spec(page, labels):
|
|
154
|
+
"""A concept spec from one page, or (None, reason)."""
|
|
155
|
+
ref = _text(_get(page, "name"))
|
|
156
|
+
label = _text(_get(page, "display_name", "displayName", "title"))
|
|
157
|
+
if not ref:
|
|
158
|
+
return None, "the page has no name to match it by"
|
|
159
|
+
if not label:
|
|
160
|
+
return None, "the page has no display_name"
|
|
161
|
+
description, body = _text(_get(page, "description")), _text(_get(page, "body"))
|
|
162
|
+
definition = "\n\n".join(p for p in dict.fromkeys([description, body]) if p)
|
|
163
|
+
state = _text(_get(page, "state")).upper()
|
|
164
|
+
return {"ref": ref, "kind": "page", "name": label, "alt": f"{label} (ontology)", "definition": definition,
|
|
165
|
+
"synonyms": _strings(_get(page, "synonyms")), "domain": _page_domain(labels, page) or None,
|
|
166
|
+
"status": "reviewed" if state == "PUBLISHED" else "draft", "tags": [], "links": []}, None
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def parse_metric_view(text):
|
|
170
|
+
"""Measures and dimensions from a metric view's YAML: dicts of kind, name, label, comment and synonyms.
|
|
171
|
+
|
|
172
|
+
ValueError says why a definition cannot be used. Entries without a name are dropped.
|
|
173
|
+
"""
|
|
174
|
+
try:
|
|
175
|
+
doc = yaml.safe_load(text or "")
|
|
176
|
+
except yaml.YAMLError as e:
|
|
177
|
+
raise ValueError(f"the definition is not valid YAML ({str(e).splitlines()[0] if str(e) else 'parse error'})") from None
|
|
178
|
+
if not isinstance(doc, dict):
|
|
179
|
+
raise ValueError("the definition is not a YAML mapping")
|
|
180
|
+
out = []
|
|
181
|
+
for key, kind in (("measures", "measure"), ("dimensions", "dimension")):
|
|
182
|
+
for m in doc.get(key) or []:
|
|
183
|
+
name = _text(m.get("name")) if isinstance(m, dict) else ""
|
|
184
|
+
if name:
|
|
185
|
+
out.append({"kind": kind, "name": name, "label": _text(m.get("display_name")) or name,
|
|
186
|
+
"comment": _text(m.get("comment")), "synonyms": _strings(m.get("synonyms"))})
|
|
187
|
+
if not out:
|
|
188
|
+
raise ValueError("the definition has no measures or dimensions")
|
|
189
|
+
return out
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _quoted(view):
|
|
193
|
+
"""A dotted full name with each part in backticks, for a statement."""
|
|
194
|
+
return ".".join("`" + part.replace("`", "``") + "`" for part in view.split("."))
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _describe(ex, view):
|
|
198
|
+
"""`DESCRIBE TABLE EXTENDED <view> AS JSON` as a dict with a `columns` list, or None when it cannot be read."""
|
|
199
|
+
try:
|
|
200
|
+
rows = ex.query(METRIC_DESCRIBE_SQL.format(view=_quoted(view)), None, 5).get("rows") or []
|
|
201
|
+
doc = json.loads(rows[0][0])
|
|
202
|
+
except Exception:
|
|
203
|
+
return None
|
|
204
|
+
return doc if isinstance(doc, dict) and isinstance(doc.get("columns"), list) else None
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _synonyms(meta):
|
|
208
|
+
value = meta.get("synonyms")
|
|
209
|
+
if isinstance(value, str): # a JSON list inside a string
|
|
210
|
+
try:
|
|
211
|
+
value = json.loads(value)
|
|
212
|
+
except ValueError:
|
|
213
|
+
return []
|
|
214
|
+
return _strings(value)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _described_entries(doc, yaml_entries, kinds):
|
|
218
|
+
"""Entries from a DESCRIBE document. A blank display name, comment or synonym list falls back to the YAML's; the kind
|
|
219
|
+
comes from the tables API, else the YAML, else `dimension`."""
|
|
220
|
+
by_name = {e["name"]: e for e in yaml_entries}
|
|
221
|
+
out = []
|
|
222
|
+
for col in doc["columns"]:
|
|
223
|
+
name = _text(col.get("name")) if isinstance(col, dict) else ""
|
|
224
|
+
if not name:
|
|
225
|
+
continue
|
|
226
|
+
meta = col["metadata"] if isinstance(col.get("metadata"), dict) else {}
|
|
227
|
+
y = by_name.get(name, {})
|
|
228
|
+
out.append({"kind": kinds.get(name) or y.get("kind") or "dimension", "name": name,
|
|
229
|
+
"label": _text(meta.get("display_name")) or y.get("label") or name,
|
|
230
|
+
"comment": _text(col.get("comment")) or y.get("comment", ""),
|
|
231
|
+
"synonyms": _synonyms(meta) or y.get("synonyms", [])})
|
|
232
|
+
return out
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _view_entries(ex, view):
|
|
236
|
+
"""The measures and dimensions of one metric view: DESCRIBE first (on the warehouse), the YAML and the tables API's
|
|
237
|
+
column kinds as the fallback. Raises when neither source can be read."""
|
|
238
|
+
try:
|
|
239
|
+
info, info_error = dbx.table_metric_info(ex, view), None
|
|
240
|
+
except Exception as e:
|
|
241
|
+
info, info_error = {"definition": None, "kinds": {}}, e
|
|
242
|
+
kinds = info["kinds"]
|
|
243
|
+
doc = _describe(ex, view)
|
|
244
|
+
text = info["definition"] or (doc or {}).get("view_text")
|
|
245
|
+
try:
|
|
246
|
+
yaml_entries, yaml_error = parse_metric_view(text), None
|
|
247
|
+
except ValueError as e:
|
|
248
|
+
yaml_entries, yaml_error = [], e
|
|
249
|
+
if doc is not None:
|
|
250
|
+
entries = _described_entries(doc, yaml_entries, kinds)
|
|
251
|
+
if entries:
|
|
252
|
+
return entries
|
|
253
|
+
if not text:
|
|
254
|
+
raise info_error or ValueError("the tables API returned no view definition")
|
|
255
|
+
if yaml_error is not None:
|
|
256
|
+
raise yaml_error
|
|
257
|
+
return [{**e, "kind": kinds.get(e["name"]) or e["kind"]} for e in yaml_entries]
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _metric_specs(view, entries):
|
|
261
|
+
short = view.split(".")[-1]
|
|
262
|
+
return [{"ref": f"{view}#{m['name']}", "kind": m["kind"], "name": m["label"], "alt": f"{m['label']} ({short})",
|
|
263
|
+
"definition": m["comment"], "synonyms": m["synonyms"], "domain": None, "status": "draft", "tags": [view],
|
|
264
|
+
"links": [(view, m["name"], "measures" if m["kind"] == "measure" else "describes")]} for m in entries]
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
# ---------- storing ----------
|
|
268
|
+
|
|
269
|
+
def _baselines(store):
|
|
270
|
+
"""What the source said at the last import, by source_ref: the fields to tell a user's edit from a source change."""
|
|
271
|
+
store.conn.execute("CREATE TABLE IF NOT EXISTS ontology_baseline (source_ref TEXT PRIMARY KEY, fields TEXT NOT NULL)")
|
|
272
|
+
return {r["source_ref"]: json.loads(r["fields"]) for r in store.conn.execute("SELECT * FROM ontology_baseline")}
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _save_baseline(store, ref, fields):
|
|
276
|
+
with store.conn:
|
|
277
|
+
store.conn.execute("INSERT OR REPLACE INTO ontology_baseline (source_ref, fields) VALUES (?, ?)",
|
|
278
|
+
(ref, json.dumps(fields)))
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _source_values(spec):
|
|
282
|
+
"""The spec's fields as a Concept would store them, so they compare equal to what is stored."""
|
|
283
|
+
probe = Concept(name=spec["name"], definition=spec["definition"], synonyms=spec["synonyms"], domain=spec["domain"],
|
|
284
|
+
status=spec["status"])
|
|
285
|
+
return {f: getattr(probe, f) for f in FIELDS}
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _merge(current, source, base):
|
|
289
|
+
"""(changed fields, fields kept because the user edited them) for a concept that already exists.
|
|
290
|
+
|
|
291
|
+
A field the source changed since the last import is replaced unless the user changed it too. With no record of the
|
|
292
|
+
last import, only blank fields are filled.
|
|
293
|
+
"""
|
|
294
|
+
changed, kept = {}, []
|
|
295
|
+
for f in FIELDS:
|
|
296
|
+
have, new = getattr(current, f), source[f]
|
|
297
|
+
if base is None:
|
|
298
|
+
if have in _BLANK and new not in _BLANK:
|
|
299
|
+
changed[f] = new
|
|
300
|
+
elif new != base.get(f):
|
|
301
|
+
if have == base.get(f):
|
|
302
|
+
if new != have:
|
|
303
|
+
changed[f] = new
|
|
304
|
+
else:
|
|
305
|
+
kept.append(f)
|
|
306
|
+
return changed, kept
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _entry(concept, spec, added, removed, total, **more):
|
|
310
|
+
return {"id": concept.id, "name": concept.name, "kind": spec["kind"], "links_added": added,
|
|
311
|
+
"links_removed": removed, "links": total, **more}
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _sync_links(store, concept, spec, now):
|
|
315
|
+
"""Make the concept's ontology links match the spec. Returns (added, removed, total)."""
|
|
316
|
+
have = {(e.target.table, e.target.column): e for e in store.concept_links(concept.id)}
|
|
317
|
+
new = []
|
|
318
|
+
for table, column, relation in spec["links"]:
|
|
319
|
+
try:
|
|
320
|
+
target = Endpoint(table=table, column=column)
|
|
321
|
+
except ValidationError:
|
|
322
|
+
continue
|
|
323
|
+
old = have.get((table, column))
|
|
324
|
+
if old is not None and not (old.provenance.origin == "ontology" and old.relation != relation):
|
|
325
|
+
continue # already linked: by this import, or by a person, whose link is left alone
|
|
326
|
+
new.append(rel.Edge(kind="concept_link", source=Endpoint(table=f"concept:{concept.id}"), target=target,
|
|
327
|
+
directed=True, provenance=Provenance(origin="ontology", source_ref=spec["ref"], fetched_at=now),
|
|
328
|
+
status="accepted", relation=relation))
|
|
329
|
+
wanted = {(t, c) for t, c, _ in spec["links"]}
|
|
330
|
+
stale = [e.id for k, e in have.items() if e.provenance.origin == "ontology"
|
|
331
|
+
and e.provenance.source_ref == spec["ref"] and k not in wanted]
|
|
332
|
+
store.upsert_edges(new)
|
|
333
|
+
store.delete_edges(stale)
|
|
334
|
+
replaced = sum(1 for e in new if (e.target.table, e.target.column) in have)
|
|
335
|
+
return len(new) - replaced, len(stale), len(have) - len(stale) + len(new) - replaced
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _sync(store, specs, now, report):
|
|
339
|
+
"""Create or update one concept per spec and bring its links in line. Fills the report; returns the refs seen."""
|
|
340
|
+
existing = {c.provenance.source_ref: c for c in store.concepts() if c.provenance.origin == "ontology"}
|
|
341
|
+
baselines = _baselines(store)
|
|
342
|
+
taken = {c.name.casefold() for c in store.concepts()}
|
|
343
|
+
seen = set()
|
|
344
|
+
for spec in specs:
|
|
345
|
+
ref = spec["ref"]
|
|
346
|
+
if ref in seen:
|
|
347
|
+
report["skipped"].append({"name": spec["name"], "reason": f"{ref} appears twice in the source"})
|
|
348
|
+
continue
|
|
349
|
+
seen.add(ref)
|
|
350
|
+
source = _source_values(spec)
|
|
351
|
+
concept = existing.get(ref)
|
|
352
|
+
if concept is None:
|
|
353
|
+
name = next((n for n in (spec["name"], spec["alt"]) if n.casefold() not in taken), None)
|
|
354
|
+
if name is None:
|
|
355
|
+
report["skipped"].append({"name": spec["name"], "reason": "a concept with this name already exists"})
|
|
356
|
+
continue
|
|
357
|
+
taken.add(name.casefold())
|
|
358
|
+
concept = store.save_concept(Concept(
|
|
359
|
+
name=name, definition=source["definition"], synonyms=source["synonyms"], domain=source["domain"],
|
|
360
|
+
status=source["status"], tags=spec["tags"],
|
|
361
|
+
provenance=ConceptProvenance(origin="ontology", source_ref=ref, fetched_at=now)))[0]
|
|
362
|
+
added, removed, total = _sync_links(store, concept, spec, now)
|
|
363
|
+
report["created"].append(_entry(concept, spec, added, removed, total))
|
|
364
|
+
else:
|
|
365
|
+
changed, kept = _merge(concept, source, baselines.get(ref))
|
|
366
|
+
if "name" in changed and changed["name"].casefold() in taken - {concept.name.casefold()}:
|
|
367
|
+
report["skipped"].append({"name": concept.name, "reason": f"the source renamed it to \"{changed['name']}\", "
|
|
368
|
+
"which another concept already has, so the name stays"})
|
|
369
|
+
del changed["name"]
|
|
370
|
+
if changed.get("name"):
|
|
371
|
+
taken.discard(concept.name.casefold())
|
|
372
|
+
taken.add(changed["name"].casefold())
|
|
373
|
+
update = {**changed, "provenance": concept.provenance.model_copy(update={"fetched_at": now})}
|
|
374
|
+
if changed:
|
|
375
|
+
update["updated_at"] = now
|
|
376
|
+
concept = store.save_concept(concept.model_copy(update=update))[0]
|
|
377
|
+
added, removed, total = _sync_links(store, concept, spec, now)
|
|
378
|
+
if changed or added or removed:
|
|
379
|
+
report["updated"].append(_entry(concept, spec, added, removed, total, fields=sorted(changed), kept=kept))
|
|
380
|
+
else:
|
|
381
|
+
report["unchanged"] += 1
|
|
382
|
+
_save_baseline(store, ref, source)
|
|
383
|
+
return seen, existing
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _drop_stale(store, existing, seen, source_of, report):
|
|
387
|
+
"""Remove the ontology links of concepts whose source no longer lists them. The concepts themselves stay."""
|
|
388
|
+
for ref, concept in existing.items():
|
|
389
|
+
if ref in seen or not source_of(ref):
|
|
390
|
+
continue
|
|
391
|
+
removed = [e.id for e in store.concept_links(concept.id)
|
|
392
|
+
if e.provenance.origin == "ontology" and e.provenance.source_ref == ref]
|
|
393
|
+
store.delete_edges(removed)
|
|
394
|
+
report["stale"].append({"id": concept.id, "name": concept.name, "links_removed": len(removed)})
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
# ---------- the run ----------
|
|
398
|
+
|
|
399
|
+
def run(ex, store):
|
|
400
|
+
"""Read domains, pages and metric views and bring the `ontology` concepts in line. Read-only toward Databricks.
|
|
401
|
+
|
|
402
|
+
Each source can fail on its own (a 403 or 404 on a Beta endpoint is "not available on this workspace") and the
|
|
403
|
+
others still run. The last source, certification, rewrites the local certification cache and touches no concept.
|
|
404
|
+
Returns {"sources": {id: {"status": ok|unavailable|failed, ...}}, "created", "updated", "skipped",
|
|
405
|
+
"unchanged", "stale", "unreadable"}.
|
|
406
|
+
"""
|
|
407
|
+
now = dbx.now()
|
|
408
|
+
report = {"sources": {}, "created": [], "updated": [], "skipped": [], "unchanged": 0, "stale": [], "unreadable": []}
|
|
409
|
+
sources = report["sources"]
|
|
410
|
+
labels = {}
|
|
411
|
+
try:
|
|
412
|
+
domains = _fetch_all(ex, DOMAINS_PATH, "domains")
|
|
413
|
+
labels = _domain_labels(domains)
|
|
414
|
+
sources["domains"] = {"status": "ok", "count": len(domains)}
|
|
415
|
+
except Exception as e:
|
|
416
|
+
sources["domains"] = _failure(e)
|
|
417
|
+
|
|
418
|
+
page_specs, pages_ok = [], False
|
|
419
|
+
try:
|
|
420
|
+
pages = _fetch_all(ex, PAGES_PATH, "pages", "knowledge_pages")
|
|
421
|
+
pages_ok = True
|
|
422
|
+
for p in pages:
|
|
423
|
+
spec, reason = _page_spec(p, labels)
|
|
424
|
+
if spec:
|
|
425
|
+
page_specs.append(spec)
|
|
426
|
+
else:
|
|
427
|
+
report["skipped"].append({"name": _text(_get(p, "display_name", "displayName")) or _text(_get(p, "name")) or "(page)",
|
|
428
|
+
"reason": reason})
|
|
429
|
+
sources["pages"] = {"status": "ok", "count": len(pages)}
|
|
430
|
+
except Exception as e:
|
|
431
|
+
sources["pages"] = _failure(e)
|
|
432
|
+
|
|
433
|
+
metric_specs, views_ok, unreadable = [], False, set()
|
|
434
|
+
try:
|
|
435
|
+
views = [".".join(str(x) for x in (r.get("table_catalog"), r.get("table_schema"), r.get("table_name")))
|
|
436
|
+
for r in concepts._rows(ex.query(METRIC_VIEWS_SQL, None, VIEW_CAP))]
|
|
437
|
+
views_ok = True
|
|
438
|
+
for view in views:
|
|
439
|
+
try:
|
|
440
|
+
metric_specs += _metric_specs(view, _view_entries(ex, view))
|
|
441
|
+
except Exception as e:
|
|
442
|
+
reason = str(e) if isinstance(e, ValueError) else dbx.clean_error(e)
|
|
443
|
+
unreadable.add(view)
|
|
444
|
+
report["unreadable"].append({"view": view, "reason": reason})
|
|
445
|
+
sources["metric_views"] = {"status": "ok", "count": len(views), "unreadable": len(unreadable)}
|
|
446
|
+
except Exception as e:
|
|
447
|
+
sources["metric_views"] = _failure(e)
|
|
448
|
+
|
|
449
|
+
try: # the badges read this cache; a failure here (no warehouse, no access) leaves the old cache as it was
|
|
450
|
+
cert = refresh_certification(ex)
|
|
451
|
+
sources["certification"] = {"status": "ok", "count": len(cert["tables"]) + len(cert["columns"])}
|
|
452
|
+
except Exception as e:
|
|
453
|
+
sources["certification"] = _failure(e)
|
|
454
|
+
|
|
455
|
+
seen, existing = _sync(store, page_specs + metric_specs, now, report)
|
|
456
|
+
view_of = lambda ref: ref.rsplit("#", 1)[0] if "#" in ref else None # noqa: E731
|
|
457
|
+
_drop_stale(store, existing, seen,
|
|
458
|
+
lambda ref: (views_ok and view_of(ref) not in unreadable) if view_of(ref) else pages_ok, report)
|
|
459
|
+
return report
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
# ---------- certification ----------
|
|
463
|
+
|
|
464
|
+
class NeedsWarehouse(ValueError):
|
|
465
|
+
"""Raised when something needs a SQL warehouse and none is selected."""
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def certification(ex):
|
|
469
|
+
"""Runs the one warehouse query for the `system.certification_status` tag: {"tables": {name: status}, "columns":
|
|
470
|
+
{"table#column": status}, "ignored", "truncated"}. Status is `certified` or `deprecated`; any other value is ignored
|
|
471
|
+
and counted. Writes nothing: see refresh_certification."""
|
|
472
|
+
result = ex.query(CERT_SQL, None, CERT_ROW_CAP)
|
|
473
|
+
tables, columns, ignored = {}, {}, 0
|
|
474
|
+
for r in concepts._rows(result):
|
|
475
|
+
status = (r.get("tag_value") or "").strip().lower()
|
|
476
|
+
if status not in CERT_VALUES:
|
|
477
|
+
ignored += 1
|
|
478
|
+
continue
|
|
479
|
+
table = f"{r['catalog_name']}.{r['schema_name']}.{r['table_name']}"
|
|
480
|
+
if r.get("scope") == "column" and r.get("column_name"):
|
|
481
|
+
columns[f"{table}#{r['column_name']}"] = status
|
|
482
|
+
else:
|
|
483
|
+
tables[table] = status
|
|
484
|
+
return {"tables": tables, "columns": columns, "ignored": ignored, "truncated": bool(result.get("truncated"))}
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def cached_certification(ex):
|
|
488
|
+
"""The certification cache as the API returns it. Never queries; empty maps and a null `fetched_at` when nothing is cached."""
|
|
489
|
+
try:
|
|
490
|
+
payload = json.loads((Path(ex.root) / CERT_CACHE).read_text(encoding="utf-8"))
|
|
491
|
+
data = payload["data"]
|
|
492
|
+
return {"tables": dict(data["tables"]), "columns": dict(data["columns"]), "ignored": int(data["ignored"]),
|
|
493
|
+
"truncated": bool(data["truncated"]), "fetched_at": payload["fetched_at"]}
|
|
494
|
+
except (OSError, ValueError, KeyError, TypeError): # absent or unreadable: as if never fetched
|
|
495
|
+
return {"tables": {}, "columns": {}, "ignored": 0, "truncated": False, "fetched_at": None}
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def refresh_certification(ex):
|
|
499
|
+
"""Runs the one tag query, writes the cache (the {"fetched_at", "data"} file shape of the other cached metadata) and
|
|
500
|
+
returns it. Raises NeedsWarehouse without a selected warehouse. A failed query leaves the old cache in place."""
|
|
501
|
+
if not ex.warehouse_id:
|
|
502
|
+
raise NeedsWarehouse("Select a SQL warehouse first: the certification tag is read with one query on it.")
|
|
503
|
+
data = certification(ex)
|
|
504
|
+
fetched_at = dbx.now()
|
|
505
|
+
path = Path(ex.root) / CERT_CACHE
|
|
506
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
507
|
+
path.write_text(json.dumps({"fetched_at": fetched_at, "data": data}, indent=2), encoding="utf-8")
|
|
508
|
+
return {**data, "fetched_at": fetched_at}
|