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,472 @@
1
+ """The catalog, relationship, mining, concept and SQL tools. Text by default, JSON on request, never a workspace write."""
2
+ import json
3
+ import time
4
+ from typing import Any
5
+
6
+ from .. import concepts
7
+ from .. import dbx
8
+ from .. import mining
9
+ from .. import relationships
10
+ from ..relationships import RelationshipInvalid
11
+
12
+ from .sqlguard import NotReadOnly, check_read_only
13
+ from .tooling import B, FORMAT, S, ToolError, integer, obj, strings, text, tool
14
+
15
+ POLL_SECONDS = 1.0 # how often run_sql asks the warehouse whether the statement finished
16
+ MINE_WAIT_SECONDS = 25 # how long mine_run waits for the run before saying it is still going
17
+ CELL_CHARS = 200 # a longer cell is cut, so one blob cannot fill the agent's context
18
+ TSV_CHARS = 24000 # the most characters of rows run_sql returns
19
+ EDGE_LIMIT = 60
20
+
21
+ TABLE = {"type": "string", "minLength": 1, "description": "full name catalog.schema.table"}
22
+ ENDPOINT = {"type": "object", "required": ["table"], "additionalProperties": False,
23
+ "properties": {"table": {"type": "string", "minLength": 1, "description": "catalog.schema.table, ph:<uuid> or concept:<uuid>"},
24
+ "column": {"type": "string", "minLength": 1}}}
25
+
26
+
27
+ def _json(v: Any) -> list[dict]:
28
+ return text(json.dumps(v, indent=1, default=str))
29
+
30
+
31
+ def _wants_json(args: dict) -> bool:
32
+ return args.get("format") == "json"
33
+
34
+
35
+ def _three_part(name: str) -> str:
36
+ if len(name.split(".")) != 3 or not all(name.split(".")):
37
+ raise ToolError(f"{name!r} is not a full table name: use catalog.schema.table")
38
+ return name
39
+
40
+
41
+ def _end(e: dict) -> str:
42
+ return e["table"] + (f"#{e['column']}" if e.get("column") else "")
43
+
44
+
45
+ def edge_line(e: dict) -> str:
46
+ """One edge as one line: id, kind, status, the two ends, columns, evidence and note."""
47
+ s = f"{e['id']} {e['kind']} {e['status']} {_end(e['source'])} {'->' if e.get('directed', True) else '--'} {_end(e['target'])}"
48
+ if e.get("columns"):
49
+ s += " cols[" + ", ".join(f"{c['source'] or '*'}->{c['target'] or '*'}" for c in e["columns"][:6]) + (
50
+ ", ..." if len(e["columns"]) > 6 else "") + "]"
51
+ if e.get("relation"):
52
+ s += f" relation={e['relation']}"
53
+ ev = e.get("evidence")
54
+ if ev and ev.get("runs") is not None:
55
+ s += f" runs={ev['runs']} users={ev.get('users')}"
56
+ if e.get("note"):
57
+ s += f" note={e['note']!r}"
58
+ return s
59
+
60
+
61
+ # ---------------------------------------------------------------- status
62
+
63
+ @tool("status", "Report what the app is connected to (profile, SQL warehouse, demo mode), how many canvases exist and whether a mining run is going. Call it first.",
64
+ obj({"format": FORMAT}))
65
+ def status(ctx, args):
66
+ from .. import canvases
67
+ e = ctx.explorer()
68
+ out = {"connected": e is not None, "profile": e.profile if e else None, "warehouse_id": e.warehouse_id if e else None,
69
+ "demo": dbx.DEMO, "canvases": len(canvases.list_canvases())}
70
+ if e is not None:
71
+ out["mining"] = ctx.miner().status().get("state")
72
+ with ctx.store() as store:
73
+ out["suggestions"] = store.status_counts()
74
+ if _wants_json(args):
75
+ return _json(out)
76
+ if e is None:
77
+ return text(f"Not connected: connect a profile in the app first (the agent cannot connect or choose a profile).\n"
78
+ f"demo mode: {'yes' if dbx.DEMO else 'no'}; saved canvases: {out['canvases']} (canvas tools work without a connection)")
79
+ wh = out["warehouse_id"] or "none (pick one in the app for SQL, mining and warehouse search)"
80
+ return text(f"connected: profile {out['profile']}; warehouse {wh}; demo mode: {'yes' if out['demo'] else 'no'}\n"
81
+ f"saved canvases: {out['canvases']}; mining: {out['mining']}; join suggestions: {out['suggestions']}")
82
+
83
+
84
+ # ---------------------------------------------------------------- catalog
85
+
86
+ @tool("search_tables", "Find tables by name. scope auto uses the SQL warehouse if the app has one selected (information_schema), otherwise the local cache; cache never uses the warehouse.",
87
+ obj({"q": {"type": "string", "minLength": 1, "description": "part of a table name or of catalog.schema.table"},
88
+ "scope": {"type": "string", "enum": ["auto", "cache"]}, "limit": integer(1, 300)}, ("q",)))
89
+ def search_tables(ctx, args):
90
+ e = ctx.need_explorer()
91
+ q, limit = args["q"], args.get("limit", 25)
92
+ res = e.search(q) if args.get("scope", "auto") == "auto" and e.warehouse_id else dbx.cached_search(e, q)
93
+ rows = res["data"]
94
+ head = f"{len(rows)} match(es) for {q!r} ({res['source']})"
95
+ lines = [f"{r['full_name']} {r.get('type') or ''}".rstrip() for r in rows[:limit]]
96
+ if len(rows) > limit:
97
+ lines.append(f"... {len(rows) - limit} more; narrow the search or raise limit")
98
+ return text("\n".join([head, *lines]))
99
+
100
+
101
+ def _constraints(info: dict) -> list[str]:
102
+ out = []
103
+ for c in info.get("table_constraints") or []:
104
+ if "primary_key_constraint" in c:
105
+ out.append("PK (" + ", ".join(c["primary_key_constraint"].get("child_columns", [])) + ")")
106
+ if "foreign_key_constraint" in c:
107
+ fk = c["foreign_key_constraint"]
108
+ out.append(f"FK ({', '.join(fk.get('child_columns', []))}) -> {fk.get('parent_table')}({', '.join(fk.get('parent_columns', []))})")
109
+ return out
110
+
111
+
112
+ @tool("describe_table", "Columns, types, comments and constraints of one table. Reads the local cache; fetches from Unity Catalog once if the table was never opened.",
113
+ obj({"name": TABLE, "refresh": {"type": "boolean", "description": "re-read from Unity Catalog"}, "format": FORMAT}, ("name",)))
114
+ def describe_table(ctx, args):
115
+ e = ctx.need_explorer()
116
+ res = e.object(_three_part(args["name"]), bool(args.get("refresh")))
117
+ info = res["data"]
118
+ if _wants_json(args):
119
+ return _json(res)
120
+ lines = [f"{info.get('full_name') or args['name']} ({info.get('table_type')}); fetched {res.get('fetched_at')}"]
121
+ if info.get("comment"):
122
+ lines.append(f"comment: {info['comment']}")
123
+ cols = info.get("columns") or []
124
+ lines.append(f"{len(cols)} columns:")
125
+ for c in cols:
126
+ lines.append(f" {c.get('name')} {c.get('type_text') or ''}{'' if c.get('nullable', True) else ' NOT NULL'}"
127
+ + (f" -- {c['comment']}" if c.get("comment") else ""))
128
+ if _constraints(info):
129
+ lines.append("constraints: " + "; ".join(_constraints(info)))
130
+ if info.get("view_definition"):
131
+ lines.append("view definition: " + " ".join(info["view_definition"].split())[:400])
132
+ return text("\n".join(lines))
133
+
134
+
135
+ @tool("list_schemas", "List the schemas of a catalog, or the catalogs when no catalog is given. Cached.",
136
+ obj({"catalog": {"type": "string", "minLength": 1}, "format": FORMAT}))
137
+ def list_schemas(ctx, args):
138
+ e = ctx.need_explorer()
139
+ res = e.schemas(args["catalog"]) if args.get("catalog") else e.catalogs()
140
+ if _wants_json(args):
141
+ return _json(res)
142
+ what = f"schemas of {args['catalog']}" if args.get("catalog") else "catalogs"
143
+ return text("\n".join([f"{len(res['data'])} {what}", *(f"{r['name']}" + (f" -- {r['comment']}" if r.get("comment") else "")
144
+ for r in res["data"])]))
145
+
146
+
147
+ @tool("list_tables", "List the tables and views of one schema with their types and comments. Cached.",
148
+ obj({"catalog": {"type": "string", "minLength": 1}, "schema": {"type": "string", "minLength": 1},
149
+ "limit": integer(1, 1000), "format": FORMAT}, ("catalog", "schema")))
150
+ def list_tables(ctx, args):
151
+ e = ctx.need_explorer()
152
+ res = e.objects(args["catalog"], args["schema"])
153
+ if _wants_json(args):
154
+ return _json(res)
155
+ limit = args.get("limit", 200)
156
+ rows = res["data"]
157
+ lines = [f"{len(rows)} objects in {args['catalog']}.{args['schema']}"]
158
+ lines += [f"{r['name']} {r.get('type') or ''}" + (f" -- {r['comment']}" if r.get("comment") else "") for r in rows[:limit]]
159
+ if len(rows) > limit:
160
+ lines.append(f"... {len(rows) - limit} more")
161
+ return text("\n".join(lines))
162
+
163
+
164
+ # ---------------------------------------------------------------- relationships
165
+
166
+ @tool("get_relationships", "Edges around one table: foreign keys, lineage and view dependencies (derived) plus stored ones (mined, manual, sketch). level column adds column lineage, which may read the cached system table or run one read-only query.",
167
+ obj({"table": TABLE, "level": {"type": "string", "enum": ["table", "column"]}, "format": FORMAT}, ("table",)))
168
+ def get_relationships(ctx, args):
169
+ e = ctx.need_explorer()
170
+ table, level = relationships.check_table(args["table"]), args.get("level", "table")
171
+ rel, times, columns, notes = None, None, None, []
172
+ if not table.startswith("ph:"):
173
+ rel, times = e.relations(table), dbx.cache_times(e, table)
174
+ if level == "column":
175
+ try:
176
+ columns = e.column_lineage(table)
177
+ except Exception as err: # no warehouse, or no access to the system table: the rest still loads
178
+ notes.append(f"column_lineage: {dbx.clean_error(err)}")
179
+ with ctx.store() as store:
180
+ out = relationships.unified(store, table, rel, times, level, columns)
181
+ out["errors"] = [*out["errors"], *notes]
182
+ if _wants_json(args):
183
+ return _json(out)
184
+ edges = out["edges"]
185
+ lines = [f"{len(edges)} edge(s) at {table} ({level} level)"]
186
+ lines += [edge_line(x) for x in edges[:EDGE_LIMIT]]
187
+ if len(edges) > EDGE_LIMIT:
188
+ lines.append(f"... {len(edges) - EDGE_LIMIT} more; use format json")
189
+ lines += [f"error: {x}" for x in out["errors"]]
190
+ return text("\n".join(lines))
191
+
192
+
193
+ @tool("add_relationship", "Store a manual relationship between two tables or columns (status accepted, origin user). Local only.",
194
+ obj({"source": ENDPOINT, "target": ENDPOINT, "directed": B, "note": S}, ("source", "target")), read_only=False)
195
+ def add_relationship(ctx, args):
196
+ ctx.need_explorer()
197
+ body = relationships.parse_create({k: v for k, v in args.items() if k != "format"})
198
+ with ctx.store() as store:
199
+ edge, created = store.create_manual(body)
200
+ return text(("created: " if created else "already existed, re-accepted: ") + edge_line(edge.model_dump(mode="json")))
201
+
202
+
203
+ @tool("set_relationship_status", "Accept, reject or re-propose a stored relationship, and/or set its note. A derived id (fk:, lineage:, view_dep:) takes only a note. Local only.",
204
+ obj({"id": {"type": "string", "minLength": 1}, "status": {"type": "string", "enum": ["proposed", "accepted", "rejected"]},
205
+ "note": S}, ("id",)), read_only=False)
206
+ def set_relationship_status(ctx, args):
207
+ ctx.need_explorer()
208
+ patch = relationships.parse_patch({k: v for k, v in args.items() if k != "id"})
209
+ rid = args["id"]
210
+ with ctx.store() as store:
211
+ if rid.startswith("e_"):
212
+ edge = store.patch(rid, patch.status, patch.note)
213
+ else:
214
+ if patch.status is not None:
215
+ raise RelationshipInvalid("status: derived edges only take a note")
216
+ edge = store.set_derived_note(rid, patch.note)
217
+ return text(edge_line(edge.model_dump(mode="json")))
218
+
219
+
220
+ # ---------------------------------------------------------------- mining
221
+
222
+ @tool("mining_suggestions", "Join pairs mined from query history, most evidence first, with their review status. Reads the local store only.",
223
+ obj({"status": {"type": "string", "enum": ["proposed", "accepted", "rejected"]}, "table": TABLE,
224
+ "limit": integer(1, 200), "format": FORMAT}))
225
+ def mining_suggestions(ctx, args):
226
+ ctx.need_explorer()
227
+ with ctx.store() as store:
228
+ edges = store.suggestions(relationships.check_statuses([args.get("status", "proposed")]), table=args.get("table"))
229
+ counts = store.status_counts()
230
+ limit = args.get("limit", 20)
231
+ rows = [x.model_dump(mode="json") for x in edges]
232
+ if _wants_json(args):
233
+ return _json({"edges": rows[:limit], "counts": counts})
234
+ lines = [f"{len(rows)} {args.get('status', 'proposed')} suggestion(s); all statuses: {counts}"]
235
+ lines += [edge_line(x) for x in rows[:limit]]
236
+ if len(rows) > limit:
237
+ lines.append(f"... {len(rows) - limit} more")
238
+ return text("\n".join(lines))
239
+
240
+
241
+ @tool("usage", "The busiest tables and table pairs from the last mining run (runs and distinct users). Reads the local store only.",
242
+ obj({"limit": integer(1, 100), "format": FORMAT}))
243
+ def usage(ctx, args):
244
+ e = ctx.need_explorer()
245
+ u = mining.usage_summary(e.profile, args.get("limit", 10))
246
+ if _wants_json(args):
247
+ return _json(u)
248
+ if not u["window"]:
249
+ return text("No usage yet: no mining run has been stored. Use mine_plan, then mine_run.")
250
+ lines = [f"{u['statements']} statements, {u['window']['since']} to {u['window']['until']}", "tables:"]
251
+ lines += [f" {t['table']} runs={t['runs']} users={t['users']}" for t in u["tables"]]
252
+ lines.append("pairs read together:")
253
+ lines += [f" {' + '.join(p['tables'])} runs={p['runs']} users={p['users']}" for p in u["pairs"]]
254
+ return text("\n".join(lines))
255
+
256
+
257
+ @tool("mine_plan", "Show what a mining run would read (time window and the exact read-only SQL on system tables). Runs nothing.",
258
+ obj({"window_days": integer(1, mining.MAX_WINDOW_DAYS), "full": B}))
259
+ def mine_plan(ctx, args):
260
+ e = ctx.need_explorer()
261
+ p = mining.plan(e.profile, args.get("window_days", mining.WINDOW_DAYS), bool(args.get("full")))
262
+ lines = [f"window {p['since']} to {p['until']}; {'incremental from ' + p['cursor'] if p['incremental'] else 'full window'}; "
263
+ f"row cap {p['row_cap']}; warehouse {e.warehouse_id or 'none selected'}"]
264
+ if p.get("remine_reason"):
265
+ lines.append(f"note: {p['remine_reason']}")
266
+ for q in p["queries"]:
267
+ lines.append(f"-- {q['name']} params {json.dumps(q['params'])}\n{q['sql']}")
268
+ return text("\n".join(lines))
269
+
270
+
271
+ @tool("mine_run", "Run query-history mining: read-only SQL on system.query.history and system.access.table_lineage through the selected warehouse. Stores proposed join edges locally. Requires confirm: true; call mine_plan first.",
272
+ obj({"confirm": {"type": "boolean", "const": True, "description": "must be true"}, "window_days": integer(1, mining.MAX_WINDOW_DAYS),
273
+ "full": B}, ("confirm",)), read_only=False)
274
+ def mine_run(ctx, args):
275
+ if args.get("confirm") is not True:
276
+ raise ToolError("mine_run needs confirm: true (it uses the SQL warehouse). Call mine_plan to see what it would run.")
277
+ e = ctx.need_explorer()
278
+ miner = ctx.miner()
279
+ st = miner.start(args.get("window_days", mining.WINDOW_DAYS), bool(args.get("full")))
280
+ deadline = time.time() + MINE_WAIT_SECONDS
281
+ while st["state"] == "running" and time.time() < deadline:
282
+ time.sleep(0.25)
283
+ st = miner.status()
284
+ out = f"mining {st['state']}"
285
+ if st["state"] == "running":
286
+ return text(out + f" (phase {st.get('phase')}); still going after {MINE_WAIT_SECONDS}s. Call status, then mining_suggestions later.")
287
+ if st.get("error"):
288
+ out += f": {st['error']}"
289
+ if st.get("result"):
290
+ r = st["result"]
291
+ out += (f"; {r['statements']} statements, {r['pairs']} join pairs, {r['created']} new, {r['updated']} updated"
292
+ + ("; hit the row cap, run again to continue" if r.get("capped") else ""))
293
+ return text(out)
294
+
295
+
296
+ # ---------------------------------------------------------------- concepts
297
+
298
+ def _concept_line(c: dict) -> str:
299
+ s = f"{c['id']} {c['name']} [{c['status']}]"
300
+ if c.get("domain"):
301
+ s += f" domain={c['domain']}"
302
+ if c.get("link_count") is not None:
303
+ s += f" links={c['link_count']}"
304
+ if c.get("definition"):
305
+ s += " -- " + " ".join(c["definition"].split())[:100]
306
+ return s
307
+
308
+
309
+ def _concept_detail(d: dict) -> str:
310
+ lines = [_concept_line(d)]
311
+ if d.get("synonyms"):
312
+ lines.append("synonyms: " + ", ".join(d["synonyms"]))
313
+ if d.get("tags"):
314
+ lines.append("tags: " + ", ".join(d["tags"]))
315
+ lines.append(f"origin: {d['provenance']['origin']}")
316
+ for x in d.get("links", []):
317
+ lines.append(f"link {x['id']} {x.get('relation')} -> {_end(x['target'])}")
318
+ for x in d.get("incoming", []):
319
+ lines.append(f"incoming {x['id']} {x.get('relation')} from {_end(x['source'])}")
320
+ return "\n".join(lines)
321
+
322
+
323
+ CONCEPT_FIELDS = {"definition": S, "synonyms": strings(), "domain": {"type": ["string", "null"]},
324
+ "status": {"type": "string", "enum": ["draft", "reviewed", "certified"]}, "tags": strings()}
325
+
326
+
327
+ @tool("list_concepts", "List business concepts (glossary terms), optionally filtered by text, domain or status.",
328
+ obj({"q": S, "domain": S, "status": {"type": "string", "enum": ["draft", "reviewed", "certified"]},
329
+ "limit": integer(1, 500), "format": FORMAT}))
330
+ def list_concepts(ctx, args):
331
+ ctx.need_explorer()
332
+ with ctx.store() as store:
333
+ out = concepts.listing(store, args.get("q", ""), args.get("domain"), args.get("status"))
334
+ limit = args.get("limit", 50)
335
+ if _wants_json(args):
336
+ return _json({**out, "concepts": out["concepts"][:limit]})
337
+ rows = out["concepts"]
338
+ lines = [f"{len(rows)} concept(s); domains: {', '.join(out['domains']) or 'none'}", *map(_concept_line, rows[:limit])]
339
+ if len(rows) > limit:
340
+ lines.append(f"... {len(rows) - limit} more")
341
+ return text("\n".join(lines))
342
+
343
+
344
+ @tool("get_concept", "One concept with its definition, synonyms, tags and links to tables, columns and other concepts.",
345
+ obj({"id": {"type": "string", "minLength": 1}, "format": FORMAT}, ("id",)))
346
+ def get_concept(ctx, args):
347
+ ctx.need_explorer()
348
+ with ctx.store() as store:
349
+ d = concepts.detail(store, concepts.check_concept_id(args["id"]))
350
+ return _json(d) if _wants_json(args) else text(_concept_detail(d))
351
+
352
+
353
+ @tool("create_concept", "Create a business concept (a glossary term). The name must be unique, ignoring case. Local only.",
354
+ obj({"name": {"type": "string", "minLength": 1}, **CONCEPT_FIELDS}, ("name",)), read_only=False)
355
+ def create_concept(ctx, args):
356
+ ctx.need_explorer()
357
+ body = concepts.parse_create(args)
358
+ with ctx.store() as store:
359
+ d = concepts.detail(store, concepts.create(store, body).id)
360
+ return text("created " + _concept_detail(d))
361
+
362
+
363
+ @tool("update_concept", "Change a concept's name, definition, synonyms, domain (null clears it), status or tags. Local only.",
364
+ obj({"id": {"type": "string", "minLength": 1}, "name": {"type": "string", "minLength": 1}, **CONCEPT_FIELDS}, ("id",)),
365
+ read_only=False)
366
+ def update_concept(ctx, args):
367
+ ctx.need_explorer()
368
+ cid = concepts.check_concept_id(args["id"])
369
+ patch = concepts.parse_patch({k: v for k, v in args.items() if k != "id"})
370
+ with ctx.store() as store:
371
+ concepts.update(store, cid, patch)
372
+ d = concepts.detail(store, cid)
373
+ return text("updated " + _concept_detail(d))
374
+
375
+
376
+ @tool("link_concept", "Link a concept to a table, a column or another concept (target.table concept:<id>). relation: defines, measures, identifies, describes for tables and columns; is_a, part_of, related_to for concepts. Local only.",
377
+ obj({"id": {"type": "string", "minLength": 1}, "target": ENDPOINT,
378
+ "relation": {"type": "string", "enum": ["defines", "measures", "identifies", "describes", "is_a", "part_of", "related_to"]},
379
+ "note": S}, ("id", "target", "relation")), read_only=False)
380
+ def link_concept(ctx, args):
381
+ ctx.need_explorer()
382
+ cid = concepts.check_concept_id(args["id"])
383
+ body = concepts.parse_link({k: v for k, v in args.items() if k != "id"})
384
+ with ctx.store() as store:
385
+ edge, created = concepts.add_link(store, cid, body)
386
+ e = edge.model_dump(mode="json")
387
+ return text(f"{'linked' if created else 'link updated'}: {e['id']} {e.get('relation')} -> {_end(e['target'])}")
388
+
389
+
390
+ @tool("unlink_concept", "Remove one link of a concept, by the link id that get_concept shows. Local only.",
391
+ obj({"id": {"type": "string", "minLength": 1}, "link_id": {"type": "string", "minLength": 1}}, ("id", "link_id")), read_only=False)
392
+ def unlink_concept(ctx, args):
393
+ ctx.need_explorer()
394
+ with ctx.store() as store:
395
+ out = concepts.remove_link(store, concepts.check_concept_id(args["id"]), args["link_id"])
396
+ return text(f"removed link {out['deleted']}")
397
+
398
+
399
+ @tool("find_candidates", "Tables and columns in the local cache whose name or comment mentions a concept or its synonyms. Never uses the warehouse.",
400
+ obj({"id": {"type": "string", "minLength": 1}, "limit": integer(1, 200), "format": FORMAT}, ("id",)))
401
+ def find_candidates(ctx, args):
402
+ e = ctx.need_explorer()
403
+ with ctx.store() as store:
404
+ out = concepts.candidates(store, concepts.check_concept_id(args["id"]), dbx.cached_metadata(e))
405
+ limit = args.get("limit", 20)
406
+ if _wants_json(args):
407
+ return _json({**out, "hits": out["hits"][:limit]})
408
+ s = out["searched"]
409
+ lines = [f"terms: {', '.join(out['terms'])}; searched {s['tables']} tables ({s['tables_with_columns']} with columns), {s['columns']} columns; {len(out['hits'])} hit(s)"]
410
+ for h in out["hits"][:limit]:
411
+ where = h["table"] + (f"#{h['column']}" if h["kind"] == "column" else "")
412
+ lines.append(f"{where} {h['field']}={h['text'][:60]!r} matched {h['matched']!r}" + (" (linked)" if h["linked"] else ""))
413
+ return text("\n".join(lines))
414
+
415
+
416
+ # ---------------------------------------------------------------- SQL
417
+
418
+ def _cell(v) -> str:
419
+ s = "NULL" if v is None else str(v)
420
+ s = s.replace("\t", " ").replace("\r", " ").replace("\n", " ")
421
+ return s if len(s) <= CELL_CHARS else s[:CELL_CHARS] + "..."
422
+
423
+
424
+ def tsv(result: dict, max_rows: int) -> str:
425
+ """A result as column types, a TSV header and TSV rows, cut by row count and by size, with a closing line that says so."""
426
+ cols = result["columns"]
427
+ lines = ["columns: " + ", ".join(f"{c['name']} {c.get('type_text') or c.get('type')}" for c in cols),
428
+ "\t".join(c["name"] for c in cols)]
429
+ size, shown = sum(map(len, lines)), 0
430
+ for row in result["rows"]:
431
+ line = "\t".join(_cell(v) for v in row)
432
+ if size + len(line) > TSV_CHARS:
433
+ break
434
+ lines.append(line)
435
+ size += len(line) + 1
436
+ shown += 1
437
+ total = result["row_count"]
438
+ note = f"{shown} row(s) shown"
439
+ if shown < total:
440
+ note += f" of {total} fetched (output size cap)"
441
+ if result.get("truncated"):
442
+ note += f"; the query has more than {max_rows} rows (cap {max_rows})"
443
+ elif result.get("truncated") is None and total >= max_rows:
444
+ note += f"; cut at {max_rows} rows"
445
+ return "\n".join([*lines, f"({note}; {result.get('ms')} ms)"])
446
+
447
+
448
+ @tool("run_sql", "Run one read-only statement (SELECT or WITH...SELECT, DESCRIBE, SHOW or EXPLAIN) on the SQL warehouse the app selected. Rows and time are capped and a slow statement is cancelled. Returns column types and TSV.",
449
+ obj({"statement": {"type": "string", "minLength": 1}, "max_rows": integer(1, 1000, "default 100"),
450
+ "timeout_s": integer(1, 300, "default 60")}, ("statement",)))
451
+ def run_sql(ctx, args):
452
+ e = ctx.need_explorer()
453
+ try:
454
+ sql = check_read_only(args["statement"])
455
+ except NotReadOnly as err:
456
+ raise ToolError(f"Rejected: {err}") from err
457
+ if not e.warehouse_id:
458
+ raise ToolError("No SQL warehouse is selected: pick one in the app first.")
459
+ max_rows, timeout = args.get("max_rows", 100), args.get("timeout_s", 60)
460
+ sid = e.query_async(sql, None, max_rows)
461
+ deadline = time.time() + timeout
462
+ while True:
463
+ st = e.query_status(sid)
464
+ if st["state"] not in ("PENDING", "RUNNING"):
465
+ break
466
+ if time.time() >= deadline:
467
+ e.query_cancel(sid)
468
+ raise ToolError(f"Timed out after {timeout}s and cancelled the statement.")
469
+ time.sleep(min(POLL_SECONDS, max(0.0, deadline - time.time())))
470
+ if st["state"] != "SUCCEEDED":
471
+ raise ToolError(f"Statement {st['state'].lower()}" + (f": {st['error']}" if st.get("error") else ""))
472
+ return text(tsv(st, max_rows))