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