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,325 @@
1
+ """Concepts: a small business glossary over the catalog. Request bodies, links, search, candidates and tag suggestions.
2
+
3
+ The data (concepts, and concept_link edges to tables, columns and other concepts) lives in the relationship store,
4
+ see relationships.py. Everything here works on an open Store. Nothing here calls Databricks: the tag suggestions take
5
+ the rows of a query the route ran, and candidates read only the local cache.
6
+ """
7
+ import re
8
+ import uuid
9
+ from typing import Annotated, Literal, Optional
10
+
11
+ from pydantic import BaseModel, ConfigDict, Field
12
+
13
+ from . import dbx
14
+ from . import relationships as rel
15
+ from .relationships import Concept, ConceptProvenance, Endpoint, Provenance, RelationshipInvalid
16
+
17
+ TAG_ROW_CAP = 20000
18
+ MAX_CANDIDATES = 200
19
+ SYSTEM_TAG_PREFIX = "system." # governed tags Databricks owns, such as system.certification_status
20
+ _CONCEPT = "concept:"
21
+
22
+ # Every Unity Catalog tag, at all four levels, as one row each: scope, where it sits, and the key and value.
23
+ TAG_SQL = """SELECT 'catalog' AS scope, catalog_name, CAST(NULL AS STRING) AS schema_name,
24
+ CAST(NULL AS STRING) AS table_name, CAST(NULL AS STRING) AS column_name, tag_name, tag_value
25
+ FROM system.information_schema.catalog_tags
26
+ UNION ALL
27
+ SELECT 'schema', catalog_name, schema_name, CAST(NULL AS STRING), CAST(NULL AS STRING), tag_name, tag_value
28
+ FROM system.information_schema.schema_tags
29
+ UNION ALL
30
+ SELECT 'table', catalog_name, schema_name, table_name, CAST(NULL AS STRING), tag_name, tag_value
31
+ FROM system.information_schema.table_tags
32
+ UNION ALL
33
+ SELECT 'column', catalog_name, schema_name, table_name, column_name, tag_name, tag_value
34
+ FROM system.information_schema.column_tags"""
35
+
36
+
37
+ # ---------- request bodies ----------
38
+
39
+ class ConceptCreate(BaseModel):
40
+ """Body of POST /api/concepts."""
41
+ model_config = ConfigDict(extra="forbid")
42
+ name: Annotated[str, Field(min_length=1, max_length=200)]
43
+ definition: str = ""
44
+ synonyms: list[str] = Field(default_factory=list)
45
+ domain: Optional[str] = None
46
+ status: Literal["draft", "reviewed", "certified"] = "draft"
47
+ tags: list[str] = Field(default_factory=list)
48
+
49
+
50
+ class ConceptPatch(BaseModel):
51
+ """Body of PATCH /api/concepts/<id>: any of the editable fields."""
52
+ model_config = ConfigDict(extra="forbid")
53
+ name: Optional[Annotated[str, Field(min_length=1, max_length=200)]] = None
54
+ definition: Optional[str] = None
55
+ synonyms: Optional[list[str]] = None
56
+ domain: Optional[str] = None
57
+ status: Optional[Literal["draft", "reviewed", "certified"]] = None
58
+ tags: Optional[list[str]] = None
59
+
60
+
61
+ class LinkCreate(BaseModel):
62
+ """Body of POST /api/concepts/<id>/links: a table, a column (table plus column) or concept:<uuid>, and a relation."""
63
+ model_config = ConfigDict(extra="forbid")
64
+ target: Endpoint
65
+ relation: str
66
+ note: str = ""
67
+
68
+
69
+ def parse_create(data):
70
+ """Validate a POST /api/concepts body."""
71
+ return rel._parse(ConceptCreate, data)
72
+
73
+
74
+ def parse_patch(data):
75
+ """Validate a PATCH body: at least one field."""
76
+ patch = rel._parse(ConceptPatch, data)
77
+ if not patch.model_fields_set:
78
+ raise RelationshipInvalid("(root): give at least one field to change")
79
+ for field in ("name", "definition", "synonyms", "status", "tags"):
80
+ if field in patch.model_fields_set and getattr(patch, field) is None:
81
+ raise RelationshipInvalid(f"{field}: cannot be null")
82
+ return patch
83
+
84
+
85
+ def parse_link(data):
86
+ """Validate a link body, including that the relation fits the target (table or column, or concept)."""
87
+ body = rel._parse(LinkCreate, data)
88
+ to_concept = body.target.table.startswith(_CONCEPT)
89
+ if body.target.table.startswith("ph:"):
90
+ raise RelationshipInvalid("target.table: a concept links to a table, a column or a concept")
91
+ allowed = rel.CONCEPT_RELATIONS if to_concept else rel.TABLE_RELATIONS
92
+ if body.relation not in allowed:
93
+ raise RelationshipInvalid(f"relation: a link to {'a concept' if to_concept else 'a table or column'} "
94
+ f"is one of {', '.join(allowed)}")
95
+ return body
96
+
97
+
98
+ def check_concept_id(cid):
99
+ """A concept id from a URL, normalized; RelationshipNotFound when it is not even a uuid."""
100
+ try:
101
+ return str(uuid.UUID(cid))
102
+ except ValueError:
103
+ raise rel.RelationshipNotFound(f"concept not found: {cid}") from None
104
+
105
+
106
+ # ---------- concepts ----------
107
+
108
+ def create(store, body):
109
+ """Add a manual concept. Returns it. A name already taken (any case) is RelationshipInvalid."""
110
+ now = dbx.now()
111
+ concept = Concept(**body.model_dump(), provenance=ConceptProvenance(origin="manual", fetched_at=now),
112
+ created_at=now, updated_at=now)
113
+ return store.save_concept(concept)[0]
114
+
115
+
116
+ def update(store, cid, patch):
117
+ """Change the given fields of a concept."""
118
+ old = store.concept(cid)
119
+ fields = patch.model_dump(exclude_unset=True)
120
+ new = Concept(**{**old.model_dump(), **fields, "updated_at": dbx.now()})
121
+ return store.save_concept(new)[0]
122
+
123
+
124
+ def _matches(c, q):
125
+ q = q.casefold()
126
+ return q in c.name.casefold() or q in c.definition.casefold() or any(q in s.casefold() for s in c.synonyms)
127
+
128
+
129
+ def listing(store, q="", domain=None, status=None):
130
+ """Concepts by name, optionally those matching text in name, synonyms or definition, a domain and a status.
131
+
132
+ Returns {"concepts": [concept + link_count], "domains": every domain in use}.
133
+ """
134
+ if status and status not in rel.CONCEPT_STATUSES:
135
+ raise RelationshipInvalid(f"status: must be one of {', '.join(rel.CONCEPT_STATUSES)}")
136
+ everything = store.concepts()
137
+ counts = store.link_counts()
138
+ out = [c for c in everything
139
+ if (not q.strip() or _matches(c, q.strip()))
140
+ and (not domain or (c.domain or "").casefold() == domain.casefold())
141
+ and (not status or c.status == status)]
142
+ domains = {}
143
+ for c in everything:
144
+ if c.domain:
145
+ domains.setdefault(c.domain.casefold(), c.domain) # the first spelling stands for its case variants
146
+ return {"concepts": [{**c.model_dump(mode="json"), "link_count": counts.get(c.id, 0)} for c in out],
147
+ "domains": sorted(domains.values(), key=str.casefold)}
148
+
149
+
150
+ def detail(store, cid):
151
+ """A concept with the links that start at it and the concept-to-concept links that point at it."""
152
+ c = store.concept(cid)
153
+ return {**c.model_dump(mode="json"),
154
+ "links": [e.model_dump(mode="json") for e in store.concept_links(cid)],
155
+ "incoming": [e.model_dump(mode="json") for e in store.concept_links(cid, incoming=True)]}
156
+
157
+
158
+ def remove(store, cid):
159
+ """Delete a concept and its links, both ways. Returns {"deleted", "links_removed"}."""
160
+ return {"deleted": cid, "links_removed": store.delete_concept(cid)}
161
+
162
+
163
+ # ---------- links ----------
164
+
165
+ def add_link(store, cid, body):
166
+ """Link a concept to a table, a column or another concept. Linking again changes the relation. Returns (edge, created)."""
167
+ store.concept(cid)
168
+ now = dbx.now()
169
+ edge = rel.Edge(kind="concept_link", source=Endpoint(table=_CONCEPT + cid), target=body.target, directed=True,
170
+ provenance=Provenance(origin="user", fetched_at=now), status="accepted", note=body.note,
171
+ relation=body.relation)
172
+ return store.upsert(edge)
173
+
174
+
175
+ def remove_link(store, cid, edge_id):
176
+ """Remove one link of this concept (outgoing or incoming). RelationshipNotFound if it is not one."""
177
+ edge = store.get(edge_id)
178
+ mine = _CONCEPT + cid
179
+ if edge.kind != "concept_link" or mine not in (edge.source.table, edge.target.table):
180
+ raise rel.RelationshipNotFound(f"link not found: {edge_id}")
181
+ store.delete(edge_id)
182
+ return {"deleted": edge_id}
183
+
184
+
185
+ def for_tables(store, tables):
186
+ """The concepts linked to any of these tables, with those links (target table or one of its columns)."""
187
+ links = store.links_to_tables(tables)
188
+ ids = list(dict.fromkeys(e.source.table[len(_CONCEPT):] for e in links))
189
+ found = {c.id: c for c in store.concepts() if c.id in ids}
190
+ links = [e for e in links if e.source.table[len(_CONCEPT):] in found]
191
+ return {"concepts": [found[i].model_dump(mode="json") for i in ids if i in found],
192
+ "links": [e.model_dump(mode="json") for e in links]}
193
+
194
+
195
+ # ---------- candidates: tables and columns whose names or comments mention the concept ----------
196
+
197
+ def _norm(text):
198
+ return " ".join(re.findall(r"[a-z0-9]+", (text or "").lower()))
199
+
200
+
201
+ def _term_pattern(term):
202
+ """A whole-word pattern over normalized text, with a plural allowed on the last word."""
203
+ return re.compile(r"(?<![a-z0-9])" + re.escape(_norm(term)) + r"(?:s|es)?(?![a-z0-9])")
204
+
205
+
206
+ def candidates(store, cid, metadata):
207
+ """Cached tables and columns whose name or comment mentions the concept's name or a synonym.
208
+
209
+ `metadata` is dbx.cached_metadata(). A name hit outranks a comment hit. Only whole words match, with a plural
210
+ allowed (`customer` finds `dim_customers`, not `customerid`). Each hit says whether the concept already links to it.
211
+ Returns {"terms", "hits", "searched": {"tables", "tables_with_columns", "columns"}, "truncated"}.
212
+ """
213
+ c = store.concept(cid)
214
+ terms = [t for t in dict.fromkeys([c.name, *c.synonyms]) if _norm(t)]
215
+ patterns = [(t, _term_pattern(t)) for t in terms]
216
+ linked = {(e.target.table, e.target.column) for e in store.concept_links(cid)}
217
+ hits, cols = [], 0
218
+
219
+ def check(table, column, name, comment):
220
+ best = None
221
+ for field, text, score in (("name", name, 3), ("comment", comment, 1)):
222
+ norm = _norm(text)
223
+ for term, pat in patterns:
224
+ if norm and pat.search(norm) and (best is None or score > best["score"]):
225
+ best = {"kind": "column" if column else "table", "table": table, "column": column,
226
+ "field": field, "matched": term, "text": text, "score": score,
227
+ "linked": (table, column) in linked}
228
+ if best:
229
+ hits.append(best)
230
+
231
+ for t in metadata:
232
+ check(t["table"], None, t["table"].split(".")[-1], t.get("comment"))
233
+ for col in t.get("columns") or []:
234
+ cols += 1
235
+ check(t["table"], col["name"], col["name"], col.get("comment"))
236
+ hits.sort(key=lambda h: (-h["score"], h["linked"], h["table"], h["column"] or ""))
237
+ return {"terms": terms, "hits": hits[:MAX_CANDIDATES], "truncated": len(hits) > MAX_CANDIDATES,
238
+ "searched": {"tables": len(metadata), "tables_with_columns": sum(1 for t in metadata if t.get("columns") is not None),
239
+ "columns": cols}}
240
+
241
+
242
+ # ---------- suggestions from Unity Catalog tags ----------
243
+
244
+ def tag_plan(warehouse_id=None):
245
+ """The read-only SQL a tag suggestion run executes, for a confirm dialog. Nothing runs."""
246
+ return {"queries": [{"name": "uc_tags", "sql": TAG_SQL}], "row_cap": TAG_ROW_CAP, "warehouse_id": warehouse_id}
247
+
248
+
249
+ def _tag_name(key, value):
250
+ return f"{key}={value}" if value else key
251
+
252
+
253
+ def _rows(result):
254
+ names = [c["name"] for c in result.get("columns") or []]
255
+ return [dict(zip(names, r)) for r in result.get("rows") or []]
256
+
257
+
258
+ def tag_run(store, result):
259
+ """Turn the rows of TAG_SQL into draft concepts, one per tag key and value, linked with `describes`.
260
+
261
+ A concept made by an earlier run (origin uc_tag, same key and value) is updated, never duplicated: its
262
+ definition, status and anything a person edited stay, links to newly tagged tables and columns are added, and links
263
+ this run made to things no longer tagged are removed. A link a person made to the same target is left alone.
264
+ Catalog and schema tags have no table or column to link, so they only add a line to the definition.
265
+ Tags whose name starts with `system.` are skipped (listed under `skipped`): the app shows certification as a badge.
266
+ Returns {"created": [...], "updated": [...], "skipped": [...], "rows", "truncated"}; each created or updated
267
+ entry is {"id", "name", "links_added", "links_removed", "links"}.
268
+ """
269
+ groups, system = {}, {}
270
+ for r in _rows(result):
271
+ key, value = (r.get("tag_name") or "").strip(), (r.get("tag_value") or "").strip()
272
+ if not key:
273
+ continue
274
+ if key.startswith(SYSTEM_TAG_PREFIX):
275
+ system[(key, value)] = True
276
+ continue
277
+ g = groups.setdefault((key, value), {"targets": {}, "scopes": []})
278
+ scope = r.get("scope")
279
+ if scope in ("table", "column") and r.get("table_name"):
280
+ table = f"{r['catalog_name']}.{r['schema_name']}.{r['table_name']}"
281
+ g["targets"][(table, r.get("column_name") if scope == "column" else None)] = True
282
+ elif scope in ("catalog", "schema"):
283
+ g["scopes"].append(f"{scope} {r['catalog_name']}" + (f".{r['schema_name']}" if scope == "schema" else ""))
284
+ now = dbx.now()
285
+ existing = {c.provenance.source_ref: c for c in store.concepts() if c.provenance.origin == "uc_tag"}
286
+ taken = {c.name.casefold() for c in store.concepts()}
287
+ created, updated = [], []
288
+ skipped = [{"name": _tag_name(k, v), "reason": "a Databricks system tag, shown as a badge instead"}
289
+ for k, v in sorted(system)]
290
+ for (key, value), g in sorted(groups.items()):
291
+ ref = _tag_name(key, value)
292
+ wanted = [(Endpoint(table=t, column=col)) for (t, col) in g["targets"]]
293
+ concept = existing.get(ref)
294
+ if concept is None:
295
+ name = ref
296
+ if name.casefold() in taken:
297
+ name = f"{ref} (UC tag)"
298
+ if name.casefold() in taken:
299
+ skipped.append({"name": ref, "reason": "a concept with this name already exists"})
300
+ continue
301
+ taken.add(name.casefold())
302
+ where = f" Also set on {', '.join(g['scopes'][:5])}" + (" and more." if len(g["scopes"]) > 5 else ".") \
303
+ if g["scopes"] else ""
304
+ concept = store.save_concept(Concept(
305
+ name=name, definition=f"Unity Catalog tag {ref}.{where}", tags=[key],
306
+ provenance=ConceptProvenance(origin="uc_tag", source_ref=ref, fetched_at=now)))[0]
307
+ bucket = created
308
+ else:
309
+ concept = store.save_concept(concept.model_copy(update={
310
+ "provenance": concept.provenance.model_copy(update={"fetched_at": now}), "updated_at": now}))[0]
311
+ bucket = updated
312
+ have = {(e.target.table, e.target.column): e for e in store.concept_links(concept.id)}
313
+ new = [rel.Edge(kind="concept_link", source=Endpoint(table=_CONCEPT + concept.id), target=ep, directed=True,
314
+ provenance=Provenance(origin="uc_tag", source_ref=ref, fetched_at=now), status="accepted",
315
+ relation="describes")
316
+ for ep in wanted if (ep.table, ep.column) not in have]
317
+ stale = [e.id for k, e in have.items()
318
+ if e.provenance.origin == "uc_tag" and e.provenance.source_ref == ref
319
+ and (k[0], k[1]) not in g["targets"]]
320
+ store.upsert_edges(new)
321
+ store.delete_edges(stale)
322
+ bucket.append({"id": concept.id, "name": concept.name, "links_added": len(new), "links_removed": len(stale),
323
+ "links": len(have) + len(new) - len(stale)})
324
+ return {"created": created, "updated": updated, "skipped": skipped, "rows": len(_rows(result)),
325
+ "truncated": bool(result.get("truncated"))}