ripple-sql 0.1.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.
- ripple/__init__.py +31 -0
- ripple/answer.py +473 -0
- ripple/answer_page.py +214 -0
- ripple/cache.py +80 -0
- ripple/ci.py +422 -0
- ripple/ci_signature.py +374 -0
- ripple/cli.py +733 -0
- ripple/doctor.py +225 -0
- ripple/engine/__init__.py +111 -0
- ripple/engine/budget.py +86 -0
- ripple/engine/column_lineage.py +112 -0
- ripple/engine/column_ref.py +818 -0
- ripple/engine/cte_tracing.py +1309 -0
- ripple/engine/dependencies.py +466 -0
- ripple/engine/dialect.py +132 -0
- ripple/engine/dispatch.py +12 -0
- ripple/engine/extraction.py +27 -0
- ripple/engine/jinja.py +282 -0
- ripple/engine/json_sources.py +241 -0
- ripple/engine/macro_source.py +127 -0
- ripple/engine/pipeline.py +265 -0
- ripple/engine/preprocess.py +174 -0
- ripple/engine/safe_gen.py +21 -0
- ripple/engine/schema_qualification.py +151 -0
- ripple/engine/scope.py +488 -0
- ripple/engine/select_sources.py +1038 -0
- ripple/engine/sql_script.py +729 -0
- ripple/engine/statement.py +449 -0
- ripple/engine/tech_debt.py +169 -0
- ripple/engine/tsql_catalog.py +83 -0
- ripple/engine/tsql_scalar_vars.py +248 -0
- ripple/engine/tsql_tvf.py +653 -0
- ripple/engine/tsql_xml.py +97 -0
- ripple/engine/types.py +167 -0
- ripple/engine/unused_deps.py +555 -0
- ripple/engine/validation.py +158 -0
- ripple/graph.py +1499 -0
- ripple/home.py +232 -0
- ripple/loaders/__init__.py +7 -0
- ripple/loaders/dbt.py +359 -0
- ripple/loaders/dbt_config.py +339 -0
- ripple/loaders/identity.py +328 -0
- ripple/loaders/sidecar.py +65 -0
- ripple/loaders/sqldir.py +262 -0
- ripple/loaders/types.py +197 -0
- ripple/lookml.py +163 -0
- ripple/mcp_server.py +600 -0
- ripple/names.py +40 -0
- ripple/project.py +167 -0
- ripple/py.typed +0 -0
- ripple/render.py +426 -0
- ripple/render_shims.py +209 -0
- ripple/schemas.py +155 -0
- ripple/semantic.py +232 -0
- ripple/server.py +184 -0
- ripple/sourcefiles.py +64 -0
- ripple/star_resolution.py +100 -0
- ripple/static/answer.css +146 -0
- ripple/static/answer.html +358 -0
- ripple/static/answer_twin.js +299 -0
- ripple/static/explore.js +133 -0
- ripple/usage/__init__.py +18 -0
- ripple/usage/cli.py +78 -0
- ripple/usage/collect.py +315 -0
- ripple/usage/discover.py +190 -0
- ripple/usage/ingest.py +414 -0
- ripple/usage/report.py +131 -0
- ripple_sql-0.1.0.dist-info/METADATA +285 -0
- ripple_sql-0.1.0.dist-info/RECORD +72 -0
- ripple_sql-0.1.0.dist-info/WHEEL +4 -0
- ripple_sql-0.1.0.dist-info/entry_points.txt +3 -0
- ripple_sql-0.1.0.dist-info/licenses/LICENSE +202 -0
ripple/__init__.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Ripple: offline column-level SQL lineage. See what breaks before you merge.
|
|
2
|
+
|
|
3
|
+
Library use starts here:
|
|
4
|
+
|
|
5
|
+
from ripple import LineageGraph, load_project
|
|
6
|
+
|
|
7
|
+
graph = LineageGraph.build(load_project("path/to/repo"))
|
|
8
|
+
graph.breaks("orders", "revenue")
|
|
9
|
+
|
|
10
|
+
Everything else is internal and may move between releases.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from ripple.graph import Edge, LineageGraph, UnknownTarget
|
|
14
|
+
from ripple.project import Model, Project, load_project
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
from importlib.metadata import version
|
|
18
|
+
|
|
19
|
+
__version__ = version("ripple-sql")
|
|
20
|
+
except Exception: # not installed (e.g. running from a checkout)
|
|
21
|
+
__version__ = "0.0.0.dev"
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"Edge",
|
|
25
|
+
"LineageGraph",
|
|
26
|
+
"Model",
|
|
27
|
+
"Project",
|
|
28
|
+
"UnknownTarget",
|
|
29
|
+
"__version__",
|
|
30
|
+
"load_project",
|
|
31
|
+
]
|
ripple/answer.py
ADDED
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
"""One answer, one shape, every door.
|
|
2
|
+
|
|
3
|
+
A `breaks` or `trace` result becomes nodes and links with a hop depth and a
|
|
4
|
+
trust label, plus the summary counts every surface prints. The CLI text,
|
|
5
|
+
the MCP payload, the pull-request comment, and the answer page all read
|
|
6
|
+
this shape, so they cannot disagree about what a change reaches.
|
|
7
|
+
|
|
8
|
+
Pure functions over the dicts the graph returns. Nothing here touches the
|
|
9
|
+
graph, files, or output formats.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
DASHBOARD_PREFIXES = ("metric:", "semantic:", "exposure:")
|
|
15
|
+
ASKED = "asked" # the column the question was about; depth 0 in either direction
|
|
16
|
+
PREVIEW_MODELS = 8
|
|
17
|
+
PREVIEW_COLUMNS = 5
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
KIND_WORDS = {"semantic": "semantic model", "metric": "metric", "exposure": "exposure"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def node_kind(model: str) -> str:
|
|
24
|
+
return "dashboard" if model.startswith(DASHBOARD_PREFIXES) else "model"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def noun_for(mode: str) -> str:
|
|
28
|
+
"""'model' is dbt's word; a plain SQL folder deserves a plain one."""
|
|
29
|
+
return "table" if mode == "sql-dir" else "model"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def count_hits(by_model: dict) -> tuple[int, int, int]:
|
|
33
|
+
"""(model columns, models, dashboard numbers) from a graph's by_model.
|
|
34
|
+
|
|
35
|
+
The graph counts every hit as a column. A dashboard number is not one:
|
|
36
|
+
"19 columns in 4 models and 7 dashboard numbers" read as 26 things when
|
|
37
|
+
it was 12 columns plus the 7 numbers themselves."""
|
|
38
|
+
columns = models = dashboards = 0
|
|
39
|
+
for model, hits in by_model.items():
|
|
40
|
+
if node_kind(model) == "dashboard":
|
|
41
|
+
dashboards += 1
|
|
42
|
+
else:
|
|
43
|
+
models += 1
|
|
44
|
+
columns += len(hits)
|
|
45
|
+
return columns, models, dashboards
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def where_phrase(models: int, dashboards: int, noun: str = "model") -> str:
|
|
49
|
+
parts = []
|
|
50
|
+
if models:
|
|
51
|
+
parts.append(_plural(models, noun))
|
|
52
|
+
if dashboards:
|
|
53
|
+
parts.append(_plural(dashboards, "dashboard number"))
|
|
54
|
+
return " and ".join(parts) if parts else f"0 {noun}s"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _plural(n: int, word: str) -> str:
|
|
58
|
+
return f"{n} {word}" + ("" if n == 1 else "s")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def has_hits(answer: dict) -> bool:
|
|
62
|
+
"""True when the change reaches anything: a column, a dashboard number, or a row filter."""
|
|
63
|
+
s = answer["summary"]
|
|
64
|
+
return bool(s["columns"] or s["dashboard_numbers"] or answer["row_level"])
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _node(model: str, column: str, depth: int, trust: str) -> dict:
|
|
68
|
+
return {
|
|
69
|
+
"id": f"{model}.{column}",
|
|
70
|
+
"model": model,
|
|
71
|
+
"column": column,
|
|
72
|
+
"kind": node_kind(model),
|
|
73
|
+
"depth": depth,
|
|
74
|
+
"trust": trust,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _worse(a: str, b: str) -> str:
|
|
79
|
+
# graph.TRUST_ORDER reversed; a heuristic match is rated "moderate"
|
|
80
|
+
order = ("verified", "high_confidence", "moderate", "review_required")
|
|
81
|
+
return a if order.index(a) >= order.index(b) else b
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _merge(nodes: list[dict]) -> list[dict]:
|
|
85
|
+
"""One node per column: the shortest path's depth, the worst path's trust."""
|
|
86
|
+
merged: dict[str, dict] = {}
|
|
87
|
+
for node in nodes:
|
|
88
|
+
seen = merged.get(node["id"])
|
|
89
|
+
if seen is None:
|
|
90
|
+
merged[node["id"]] = dict(node)
|
|
91
|
+
else:
|
|
92
|
+
seen["depth"] = min(seen["depth"], node["depth"])
|
|
93
|
+
seen["trust"] = _worse(seen["trust"], node["trust"])
|
|
94
|
+
return list(merged.values())
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _close_edges(nodes: list[dict], edges: list[dict], kind: str) -> list[dict]:
|
|
98
|
+
"""A link may name a column the walk never listed: a wildcard, or a
|
|
99
|
+
column reached through one. Give it a node so no link dangles.
|
|
100
|
+
|
|
101
|
+
Links point away from the asked column on breaks and toward it on trace,
|
|
102
|
+
so which end sits one step nearer depends on the kind."""
|
|
103
|
+
ids = {n["id"] for n in nodes}
|
|
104
|
+
by_id = {n["id"]: n for n in nodes}
|
|
105
|
+
for edge in edges:
|
|
106
|
+
for end, other in (("src", "dst"), ("dst", "src")):
|
|
107
|
+
if edge[end] in ids:
|
|
108
|
+
continue
|
|
109
|
+
model, _, column = edge[end].rpartition(".")
|
|
110
|
+
neighbour = by_id.get(edge[other])
|
|
111
|
+
nearer = (end == "src") == (kind == "breaks")
|
|
112
|
+
depth = max(0, (neighbour["depth"] if neighbour else 1) + (-1 if nearer else 1))
|
|
113
|
+
node = _node(model, column, depth, "review_required")
|
|
114
|
+
node["kind"] = "unknown"
|
|
115
|
+
nodes.append(node)
|
|
116
|
+
ids.add(node["id"])
|
|
117
|
+
by_id[node["id"]] = node
|
|
118
|
+
return nodes
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def breaks_answer(result: dict, noun: str = "model") -> dict:
|
|
122
|
+
"""Everything downstream of one column, as nodes and links."""
|
|
123
|
+
src = result["source"]
|
|
124
|
+
asked = _node(src["model"], src["column"], 0, "verified")
|
|
125
|
+
asked["kind"] = ASKED
|
|
126
|
+
nodes = [asked]
|
|
127
|
+
edges: list[dict] = []
|
|
128
|
+
for hits in result["by_model"].values():
|
|
129
|
+
for hit in hits:
|
|
130
|
+
nodes.append(_node(hit["model"], hit["column"], hit["depth"], hit["trust"]))
|
|
131
|
+
edge = {
|
|
132
|
+
"src": hit["via"],
|
|
133
|
+
"dst": nodes[-1]["id"],
|
|
134
|
+
"trust": hit["edge_trust"],
|
|
135
|
+
"path_trust": hit["trust"],
|
|
136
|
+
}
|
|
137
|
+
if hit.get("reason"):
|
|
138
|
+
edge["reason"] = hit["reason"]
|
|
139
|
+
edges.append(edge)
|
|
140
|
+
nodes = _close_edges(_merge(nodes), edges, "breaks")
|
|
141
|
+
rows = [
|
|
142
|
+
{"model": r["model"], "uses": list(r.get("uses", [])), "trust": r.get("trust")}
|
|
143
|
+
for r in result.get("row_level_impact", [])
|
|
144
|
+
]
|
|
145
|
+
columns, models, dashboards = count_hits(result["by_model"])
|
|
146
|
+
answer = {
|
|
147
|
+
"kind": "breaks",
|
|
148
|
+
"target": asked["id"],
|
|
149
|
+
"noun": noun,
|
|
150
|
+
"summary": {
|
|
151
|
+
"columns": columns,
|
|
152
|
+
"models": models,
|
|
153
|
+
"dashboard_numbers": dashboards,
|
|
154
|
+
"review": result["review_required"],
|
|
155
|
+
"row_level_models": len(rows),
|
|
156
|
+
"deepest_hops": max(n["depth"] for n in nodes),
|
|
157
|
+
"complete": result.get("complete", True),
|
|
158
|
+
},
|
|
159
|
+
"nodes": nodes,
|
|
160
|
+
"edges": edges,
|
|
161
|
+
"row_level": rows,
|
|
162
|
+
}
|
|
163
|
+
for key in ("truncated_at_depth", "also_matches"):
|
|
164
|
+
if result.get(key):
|
|
165
|
+
answer[key] = result[key]
|
|
166
|
+
return answer
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def trace_answer(result: dict, noun: str = "model") -> dict:
|
|
170
|
+
"""Where one column comes from, as nodes and links pointing downstream."""
|
|
171
|
+
tgt = result["target"]
|
|
172
|
+
asked = _node(tgt["model"], tgt["column"], 0, "verified")
|
|
173
|
+
asked["kind"] = ASKED
|
|
174
|
+
nodes = [asked]
|
|
175
|
+
edges: list[dict] = []
|
|
176
|
+
for hop in result["upstream"]:
|
|
177
|
+
nodes.append(_node(hop["model"], hop["column"], hop["depth"], hop["trust"]))
|
|
178
|
+
edge = {
|
|
179
|
+
"src": nodes[-1]["id"],
|
|
180
|
+
"dst": hop["feeds"],
|
|
181
|
+
"trust": hop["edge_trust"],
|
|
182
|
+
"path_trust": hop["trust"],
|
|
183
|
+
}
|
|
184
|
+
if hop.get("reason"):
|
|
185
|
+
edge["reason"] = hop["reason"]
|
|
186
|
+
edges.append(edge)
|
|
187
|
+
nodes = _close_edges(_merge(nodes), edges, "trace")
|
|
188
|
+
upstream = [n for n in nodes if n["depth"] > 0]
|
|
189
|
+
models = {n["model"] for n in upstream}
|
|
190
|
+
answer = {
|
|
191
|
+
"kind": "trace",
|
|
192
|
+
"target": asked["id"],
|
|
193
|
+
"noun": noun,
|
|
194
|
+
"summary": {
|
|
195
|
+
"columns": len(upstream),
|
|
196
|
+
"models": sum(1 for m in models if node_kind(m) == "model"),
|
|
197
|
+
"dashboard_numbers": sum(1 for m in models if node_kind(m) == "dashboard"),
|
|
198
|
+
"review": sum(1 for n in upstream if n["trust"] == "review_required"),
|
|
199
|
+
"row_level_models": 0,
|
|
200
|
+
"deepest_hops": max(n["depth"] for n in nodes),
|
|
201
|
+
"complete": result.get("complete", True),
|
|
202
|
+
},
|
|
203
|
+
"nodes": nodes,
|
|
204
|
+
"edges": edges,
|
|
205
|
+
"row_level": [],
|
|
206
|
+
}
|
|
207
|
+
if result.get("truncated_at_depth"):
|
|
208
|
+
answer["truncated_at_depth"] = result["truncated_at_depth"]
|
|
209
|
+
return answer
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def ci_answers(report: dict, noun: str = "model") -> list[dict]:
|
|
213
|
+
"""One breaks answer per changed column that reaches anything."""
|
|
214
|
+
answers = []
|
|
215
|
+
for finding in report["findings"]:
|
|
216
|
+
for col in finding.get("columns", []):
|
|
217
|
+
if not col.get("by_model") and not col.get("row_level_models"):
|
|
218
|
+
continue
|
|
219
|
+
rows = col.get("row_level_impact") or [
|
|
220
|
+
{"model": m} for m in col.get("row_level_models", [])
|
|
221
|
+
]
|
|
222
|
+
result = {
|
|
223
|
+
"source": {"model": finding["model"], "column": col["column"]},
|
|
224
|
+
"impacted_columns": col["impacted_columns"],
|
|
225
|
+
"impacted_models": len(col["by_model"]),
|
|
226
|
+
"review_required": col["review_required"],
|
|
227
|
+
"by_model": col["by_model"],
|
|
228
|
+
"row_level_impact": rows,
|
|
229
|
+
"complete": not col.get("truncated"),
|
|
230
|
+
}
|
|
231
|
+
if col.get("truncated_at_depth"):
|
|
232
|
+
result["truncated_at_depth"] = col["truncated_at_depth"]
|
|
233
|
+
answer = breaks_answer(result, noun)
|
|
234
|
+
answer["change"] = col["state"]
|
|
235
|
+
answers.append(answer)
|
|
236
|
+
return answers
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def headline_parts(answer: dict) -> tuple[str, str, str]:
|
|
240
|
+
"""(count, where, target) so a surface can style them apart.
|
|
241
|
+
|
|
242
|
+
A metric is not a table; lumping dashboard numbers into the model count
|
|
243
|
+
made "6 models" both wrong for plain-SQL users and vague for dbt users."""
|
|
244
|
+
s = answer["summary"]
|
|
245
|
+
where = where_phrase(s["models"], s["dashboard_numbers"], answer["noun"])
|
|
246
|
+
return str(s["columns"]), where, answer["target"]
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def headline(answer: dict) -> str:
|
|
250
|
+
count, where, target = headline_parts(answer)
|
|
251
|
+
if answer["kind"] == "trace":
|
|
252
|
+
return f"{target} comes from {count} columns in {where}"
|
|
253
|
+
return f"{count} columns in {where} affected by {target}"
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def hit_hardest(answer: dict, limit: int = 3) -> list[tuple[str, int]]:
|
|
257
|
+
"""The models with the most touched columns, worst first."""
|
|
258
|
+
counts: dict[str, int] = {}
|
|
259
|
+
for node in answer["nodes"]:
|
|
260
|
+
if node["depth"] > 0 and node["kind"] == "model":
|
|
261
|
+
counts[node["model"]] = counts.get(node["model"], 0) + 1
|
|
262
|
+
return sorted(counts.items(), key=lambda kv: -kv[1])[:limit]
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _groups(answer: dict) -> list[tuple[str, list[dict]]]:
|
|
266
|
+
"""(model, nodes) in first-seen order; the asked column and stand-ins skipped."""
|
|
267
|
+
groups: dict[str, list[dict]] = {}
|
|
268
|
+
for node in answer["nodes"]:
|
|
269
|
+
if node["depth"] == 0 or node["kind"] == "unknown":
|
|
270
|
+
continue
|
|
271
|
+
groups.setdefault(node["model"], []).append(node)
|
|
272
|
+
return list(groups.items())
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def row_impact_kind(row: dict) -> str:
|
|
276
|
+
"""How a reader uses the changed column on rows: filter, join, or window.
|
|
277
|
+
A window key frames the window; saying it filters rows would be wrong."""
|
|
278
|
+
kinds = {u.rsplit(" (", 1)[-1].rstrip(")") for u in row.get("uses") or [] if " (" in u}
|
|
279
|
+
if kinds == {"window"}:
|
|
280
|
+
return "window"
|
|
281
|
+
if kinds == {"join"}:
|
|
282
|
+
return "join"
|
|
283
|
+
return "filter"
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _row_line(row: dict) -> list[tuple[str, str]]:
|
|
287
|
+
uses = row.get("uses") or []
|
|
288
|
+
used = uses[0].rsplit(" (", 1) if uses else []
|
|
289
|
+
how = used[1].rstrip(")") if len(used) > 1 else "filter"
|
|
290
|
+
note = (
|
|
291
|
+
"partitions or orders a window on it"
|
|
292
|
+
if row_impact_kind(row) == "window"
|
|
293
|
+
else f"filters rows on it ({how})"
|
|
294
|
+
)
|
|
295
|
+
segments = [(f" {row['model']} ", "plain"), (f"· {note}", "dim")]
|
|
296
|
+
if row.get("trust") == "review_required":
|
|
297
|
+
segments.append((" review", "warn"))
|
|
298
|
+
return segments
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _source_label(node: dict, via: dict[str, str]) -> str:
|
|
302
|
+
"""The arrow names the source model when the column keeps its name, and
|
|
303
|
+
the source column only when the name changes. Fifteen of nineteen lines
|
|
304
|
+
on jaffle-shop said the same name twice."""
|
|
305
|
+
src = via.get(node["id"], "?")
|
|
306
|
+
src_model, _, src_column = src.rpartition(".")
|
|
307
|
+
return src_model if src_model and src_column == node["column"] else src
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _model_block(model: str, nodes: list[dict], via: dict[str, str], full: bool) -> list:
|
|
311
|
+
"""One line per (source, trust) group. A wide model repeats the same
|
|
312
|
+
source for every column; without full, each line names the first few
|
|
313
|
+
columns and one dim line counts the rest."""
|
|
314
|
+
depth = min(n["depth"] for n in nodes)
|
|
315
|
+
lines = [[(f" {model} ", "plain"), (f"· {_plural(depth, 'step')}", "dim")]]
|
|
316
|
+
groups: dict[tuple[str, bool], list[str]] = {}
|
|
317
|
+
for node in nodes:
|
|
318
|
+
key = (_source_label(node, via), node["trust"] == "review_required")
|
|
319
|
+
groups.setdefault(key, []).append(node["column"])
|
|
320
|
+
items = list(groups.items())
|
|
321
|
+
shown = items if full else items[:PREVIEW_COLUMNS]
|
|
322
|
+
hidden = sum(len(columns) for _, columns in items[len(shown) :])
|
|
323
|
+
for (label, review), columns in shown:
|
|
324
|
+
named = columns if full else columns[:PREVIEW_COLUMNS]
|
|
325
|
+
hidden += len(columns) - len(named)
|
|
326
|
+
segments = [(f" {', '.join(named)} ", "plain"), (f"← {label}", "dim")]
|
|
327
|
+
if review:
|
|
328
|
+
segments.append((" needs review", "warn"))
|
|
329
|
+
lines.append(segments)
|
|
330
|
+
if hidden:
|
|
331
|
+
lines.append([(f" and {hidden} more (--full)", "dim")])
|
|
332
|
+
return lines
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _dashboard_lines(groups: list[tuple[str, list[dict]]], full: bool) -> list:
|
|
336
|
+
"""Dashboard numbers in their own section, the kind as a word after the
|
|
337
|
+
name, so an analyst can skip it and an engineer knows which yml to open."""
|
|
338
|
+
entries: dict[tuple[str, str], dict] = {}
|
|
339
|
+
for model, nodes in groups:
|
|
340
|
+
kind, _, rest = model.partition(":")
|
|
341
|
+
if kind == "semantic":
|
|
342
|
+
entity, _, _ = rest.rpartition(".")
|
|
343
|
+
key = (entity or rest, kind)
|
|
344
|
+
columns = [n["column"] for n in nodes]
|
|
345
|
+
else:
|
|
346
|
+
key = (kind, kind)
|
|
347
|
+
columns = [rest]
|
|
348
|
+
entry = entries.setdefault(key, {"columns": [], "review": False})
|
|
349
|
+
entry["columns"].extend(columns)
|
|
350
|
+
entry["review"] |= any(n["trust"] == "review_required" for n in nodes)
|
|
351
|
+
lines: list = [[], [("Dashboard numbers", "plain")]]
|
|
352
|
+
items = list(entries.items())
|
|
353
|
+
shown = items if full else items[:PREVIEW_MODELS]
|
|
354
|
+
for (entity, kind), entry in shown:
|
|
355
|
+
names = ", ".join(entry["columns"])
|
|
356
|
+
text = (
|
|
357
|
+
f" {kind}s: {names}" if entity == kind else f" {entity} ({KIND_WORDS[kind]}): {names}"
|
|
358
|
+
)
|
|
359
|
+
segments = [(text, "plain")]
|
|
360
|
+
if entry["review"]:
|
|
361
|
+
segments.append((" needs review", "warn"))
|
|
362
|
+
lines.append(segments)
|
|
363
|
+
if len(items) > len(shown):
|
|
364
|
+
lines.append([(f" ...and {len(items) - len(shown)} more (--full)", "dim")])
|
|
365
|
+
return lines
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def breaks_lines(answer: dict, full: bool = False) -> list[list[tuple[str, str]]]:
|
|
369
|
+
"""The text every door prints, as lines of (text, role) segments.
|
|
370
|
+
|
|
371
|
+
Roles: plain, bold, dim, warn. A terminal styles them; a page or an
|
|
372
|
+
agent joins the text. A hub column can feed hundreds of models, so the
|
|
373
|
+
headline is the answer and the blocks are detail: counts and names
|
|
374
|
+
first, everything on full.
|
|
375
|
+
"""
|
|
376
|
+
s, noun, target = answer["summary"], answer["noun"], answer["target"]
|
|
377
|
+
if not has_hits(answer):
|
|
378
|
+
return [[(f"Nothing downstream reads {target}.", "plain")]]
|
|
379
|
+
count, where, _ = headline_parts(answer)
|
|
380
|
+
lines = [
|
|
381
|
+
[
|
|
382
|
+
(count, "bold"),
|
|
383
|
+
(" columns in ", "plain"),
|
|
384
|
+
(where, "bold"),
|
|
385
|
+
(f" affected by {target}", "plain"),
|
|
386
|
+
]
|
|
387
|
+
]
|
|
388
|
+
if s["review"]:
|
|
389
|
+
lines.append([(f"{s['review']} of them need review (lineage uncertain)", "warn")])
|
|
390
|
+
groups = _groups(answer)
|
|
391
|
+
models = [(m, nodes) for m, nodes in groups if node_kind(m) == "model"]
|
|
392
|
+
dashboards = [(m, nodes) for m, nodes in groups if node_kind(m) == "dashboard"]
|
|
393
|
+
if len(models) > 1:
|
|
394
|
+
named = ", ".join(f"{m} ({n})" for m, n in hit_hardest(answer))
|
|
395
|
+
lines.append([(f"Hit hardest: {named}", "dim")])
|
|
396
|
+
lines.append([])
|
|
397
|
+
via: dict[str, str] = {}
|
|
398
|
+
for edge in answer["edges"]:
|
|
399
|
+
via.setdefault(edge["dst"], edge["src"])
|
|
400
|
+
shown = models if full else models[:PREVIEW_MODELS]
|
|
401
|
+
for model, nodes in shown:
|
|
402
|
+
lines.extend(_model_block(model, nodes, via, full))
|
|
403
|
+
if len(models) > len(shown):
|
|
404
|
+
lines.append([(f" ...and {len(models) - len(shown)} more {noun}s (--full)", "dim")])
|
|
405
|
+
if dashboards:
|
|
406
|
+
lines.extend(_dashboard_lines(dashboards, full))
|
|
407
|
+
rows = answer["row_level"]
|
|
408
|
+
shown_rows = rows if full else rows[:PREVIEW_MODELS]
|
|
409
|
+
lines.extend(_row_line(row) for row in shown_rows)
|
|
410
|
+
if len(rows) > len(shown_rows):
|
|
411
|
+
lines.append([(f" ...and {len(rows) - len(shown_rows)} more (--full)", "dim")])
|
|
412
|
+
if not s["complete"]:
|
|
413
|
+
# breaks_all reports an incomplete walk without saying at which depth
|
|
414
|
+
depth = answer.get("truncated_at_depth")
|
|
415
|
+
stopped = f"at depth {depth}" if depth else "at the depth cap"
|
|
416
|
+
lines.append([(f" stopped {stopped}; the full radius may be larger", "warn")])
|
|
417
|
+
lines.append([])
|
|
418
|
+
return lines
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def trace_lines(answer: dict) -> list[list[tuple[str, str]]]:
|
|
422
|
+
"""Where the asked column comes from, one line per hop, indented by depth."""
|
|
423
|
+
target = answer["target"]
|
|
424
|
+
by_id = {n["id"]: n for n in answer["nodes"]}
|
|
425
|
+
hops = [e for e in answer["edges"] if by_id.get(e["src"], {}).get("kind") != "unknown"]
|
|
426
|
+
if not hops:
|
|
427
|
+
return [[(f"No upstream lineage for {target}.", "plain")]]
|
|
428
|
+
lines = [[(f"{target} comes from:", "plain")]]
|
|
429
|
+
for edge in hops:
|
|
430
|
+
node = by_id[edge["src"]]
|
|
431
|
+
segments = [(" " * node["depth"] + edge["src"], "plain")]
|
|
432
|
+
if edge.get("path_trust", node["trust"]) == "review_required":
|
|
433
|
+
segments.append((" review", "warn"))
|
|
434
|
+
segments.append((" ", "plain"))
|
|
435
|
+
segments.append(("→ " + edge["dst"], "dim"))
|
|
436
|
+
lines.append(segments)
|
|
437
|
+
if answer.get("truncated_at_depth"):
|
|
438
|
+
lines.append(
|
|
439
|
+
[
|
|
440
|
+
(
|
|
441
|
+
f" stopped at depth {answer['truncated_at_depth']}; "
|
|
442
|
+
"the trail may go further back (--depth to follow it)",
|
|
443
|
+
"warn",
|
|
444
|
+
)
|
|
445
|
+
]
|
|
446
|
+
)
|
|
447
|
+
return lines
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def plain_text(lines: list[list[tuple[str, str]]]) -> str:
|
|
451
|
+
"""The lines with no styling, one string, for a page or an agent."""
|
|
452
|
+
return "\n".join("".join(text for text, _ in segments) for segments in lines)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def attach(result: dict, kind: str, noun: str) -> dict:
|
|
456
|
+
"""A graph result plus its contract (`answer`) and preview text (`text`),
|
|
457
|
+
the top-level counts aligned to the contract. Every other key stays.
|
|
458
|
+
|
|
459
|
+
The graph counts a dashboard number as a column; the contract does not,
|
|
460
|
+
so a payload carrying both used to disagree with its own text."""
|
|
461
|
+
if kind == "breaks":
|
|
462
|
+
answer = breaks_answer(result, noun)
|
|
463
|
+
text = plain_text(breaks_lines(answer))
|
|
464
|
+
else:
|
|
465
|
+
answer = trace_answer(result, noun)
|
|
466
|
+
text = plain_text(trace_lines(answer))
|
|
467
|
+
s = answer["summary"]
|
|
468
|
+
out = {**result, "answer": answer, "text": text}
|
|
469
|
+
out["impacted_columns"] = s["columns"]
|
|
470
|
+
out["dashboard_numbers"] = s["dashboard_numbers"]
|
|
471
|
+
if "impacted_models" in result:
|
|
472
|
+
out["impacted_models"] = s["models"]
|
|
473
|
+
return out
|