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