dbt-graphify 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.
- dbt_graphify/__init__.py +3 -0
- dbt_graphify/__main__.py +3 -0
- dbt_graphify/builder.py +420 -0
- dbt_graphify/cli.py +158 -0
- dbt_graphify/parser.py +340 -0
- dbt_graphify-0.1.0.dist-info/METADATA +181 -0
- dbt_graphify-0.1.0.dist-info/RECORD +9 -0
- dbt_graphify-0.1.0.dist-info/WHEEL +4 -0
- dbt_graphify-0.1.0.dist-info/entry_points.txt +3 -0
dbt_graphify/__init__.py
ADDED
dbt_graphify/__main__.py
ADDED
dbt_graphify/builder.py
ADDED
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
"""Build graphify-out/ artifacts from parsed dbt graph data."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
from collections import defaultdict, deque
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# ── Community detection ───────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
_LAYER_COMMUNITY = {
|
|
13
|
+
"source": 0,
|
|
14
|
+
"staging": 1,
|
|
15
|
+
"intermediate": 2,
|
|
16
|
+
"mart": 3,
|
|
17
|
+
"seed": 4,
|
|
18
|
+
"macro": 5,
|
|
19
|
+
"test": 6,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def build_community_map(nodes: list[dict]) -> tuple[dict[str, int], dict[int, str]]:
|
|
24
|
+
"""
|
|
25
|
+
Assign community integers via priority chain:
|
|
26
|
+
1. dbt group field
|
|
27
|
+
2. first tag
|
|
28
|
+
3. Louvain topology clustering (if networkx available)
|
|
29
|
+
4. layer-based fallback
|
|
30
|
+
|
|
31
|
+
Returns (uid -> community_int, community_int -> label).
|
|
32
|
+
"""
|
|
33
|
+
# Check if any node has groups or tags
|
|
34
|
+
group_index: dict[str, int] = {}
|
|
35
|
+
tag_index: dict[str, int] = {}
|
|
36
|
+
counter = [0]
|
|
37
|
+
|
|
38
|
+
def _get_or_create(index: dict, key: str) -> int:
|
|
39
|
+
if key not in index:
|
|
40
|
+
index[key] = counter[0]
|
|
41
|
+
counter[0] += 1
|
|
42
|
+
return index[key]
|
|
43
|
+
|
|
44
|
+
has_groups = any(n.get("group") for n in nodes)
|
|
45
|
+
has_tags = any(n.get("tags") for n in nodes) and not has_groups
|
|
46
|
+
|
|
47
|
+
if has_groups:
|
|
48
|
+
uid_to_community = {}
|
|
49
|
+
for n in nodes:
|
|
50
|
+
group = n.get("group") or ""
|
|
51
|
+
cid = _get_or_create(group_index, group or "__ungrouped__")
|
|
52
|
+
uid_to_community[n["uid"]] = cid
|
|
53
|
+
labels = {v: k for k, v in group_index.items()}
|
|
54
|
+
return uid_to_community, labels
|
|
55
|
+
|
|
56
|
+
if has_tags:
|
|
57
|
+
uid_to_community = {}
|
|
58
|
+
for n in nodes:
|
|
59
|
+
tags = n.get("tags") or []
|
|
60
|
+
key = tags[0] if tags else "__untagged__"
|
|
61
|
+
cid = _get_or_create(tag_index, key)
|
|
62
|
+
uid_to_community[n["uid"]] = cid
|
|
63
|
+
labels = {v: k for k, v in tag_index.items()}
|
|
64
|
+
return uid_to_community, labels
|
|
65
|
+
|
|
66
|
+
# Try topology clustering with networkx
|
|
67
|
+
try:
|
|
68
|
+
import networkx as nx
|
|
69
|
+
G = nx.DiGraph()
|
|
70
|
+
for n in nodes:
|
|
71
|
+
G.add_node(n["uid"])
|
|
72
|
+
for n in nodes:
|
|
73
|
+
for tgt in n.get("downstream", []):
|
|
74
|
+
G.add_edge(n["uid"], tgt)
|
|
75
|
+
|
|
76
|
+
undirected = G.to_undirected()
|
|
77
|
+
try:
|
|
78
|
+
from networkx.algorithms.community import louvain_communities
|
|
79
|
+
communities = louvain_communities(undirected, seed=42)
|
|
80
|
+
except (ImportError, Exception):
|
|
81
|
+
# greedy modularity as fallback
|
|
82
|
+
from networkx.algorithms.community import greedy_modularity_communities
|
|
83
|
+
communities = list(greedy_modularity_communities(undirected))
|
|
84
|
+
|
|
85
|
+
uid_to_community = {}
|
|
86
|
+
labels = {}
|
|
87
|
+
for cid, members in enumerate(communities):
|
|
88
|
+
# Name the community after the most-connected node in the cluster
|
|
89
|
+
best = max(members, key=lambda n: G.degree(n), default=next(iter(members)))
|
|
90
|
+
labels[cid] = best.split(".")[-1]
|
|
91
|
+
for uid in members:
|
|
92
|
+
uid_to_community[uid] = cid
|
|
93
|
+
# Fill any missing (isolated nodes)
|
|
94
|
+
for n in nodes:
|
|
95
|
+
if n["uid"] not in uid_to_community:
|
|
96
|
+
uid_to_community[n["uid"]] = _LAYER_COMMUNITY.get(n["layer"], 0)
|
|
97
|
+
return uid_to_community, labels
|
|
98
|
+
|
|
99
|
+
except ImportError:
|
|
100
|
+
pass
|
|
101
|
+
|
|
102
|
+
# Layer fallback
|
|
103
|
+
uid_to_community = {n["uid"]: _LAYER_COMMUNITY.get(n["layer"], 0) for n in nodes}
|
|
104
|
+
labels = {v: k.capitalize() for k, v in _LAYER_COMMUNITY.items()}
|
|
105
|
+
return uid_to_community, labels
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# ── BFS ───────────────────────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
def bfs_reachable(start: str, adjacency: dict[str, list]) -> list[str]:
|
|
111
|
+
visited, queue = set(), deque(adjacency.get(start, []))
|
|
112
|
+
while queue:
|
|
113
|
+
node = queue.popleft()
|
|
114
|
+
if node not in visited:
|
|
115
|
+
visited.add(node)
|
|
116
|
+
queue.extend(n for n in adjacency.get(node, []) if n not in visited)
|
|
117
|
+
return sorted(visited)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# ── Descriptions ──────────────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
_AUTO_DESCS: dict[str, str] = {
|
|
123
|
+
"stg_students": "Cleans raw students. Derives: full_name, age, years_enrolled, academic_standing, current_status.",
|
|
124
|
+
"stg_courses": "Cleans raw courses. Derives: difficulty_description, credit_category.",
|
|
125
|
+
"stg_departments": "Cleans raw departments. Derives: department_size, budget_millions.",
|
|
126
|
+
"stg_faculty": "Cleans raw faculty. Derives: full_name, years_of_service, rank_level, salary_band.",
|
|
127
|
+
"stg_enrollments": "Cleans raw enrollments. Derives: grade_category, enrollment_status, attendance_level.",
|
|
128
|
+
"stg_semesters": "Cleans raw semesters. Derives: semester_type, semester_duration_days, semester_status.",
|
|
129
|
+
"stg_class_sessions": "Cleans raw class sessions. Derives: day_of_week, session_hour, time_block, day_name.",
|
|
130
|
+
"stg_assignments": "Cleans raw assignments. Derives: assignment_category, due_status, days_until_due, weight_category.",
|
|
131
|
+
"stg_assignment_submissions": "Cleans raw submissions. Derives: grading_status, submission_timeliness, feedback_status.",
|
|
132
|
+
"stg_financial_aid": "Cleans raw financial aid. Derives: aid_category, support_level, disbursement_period.",
|
|
133
|
+
"stg_tuition_payments": "Cleans raw tuition payments. Derives: total_payment, payment_timeliness, payment_method_category.",
|
|
134
|
+
"int_student_enrollment_history": "Per-enrollment grain with window aggregates per student: total_enrollments, credits_attempted/earned, failed_courses_count, avg_grade_points.",
|
|
135
|
+
"int_course_performance_metrics": "Per-course aggregates: total_enrollments, pass_rate, withdrawal_rate, avg_grade_points, avg_attendance.",
|
|
136
|
+
"int_department_analytics": "Per-department aggregates: faculty_count, course_count, student_count, avg_faculty_salary, student_faculty_ratio.",
|
|
137
|
+
"int_faculty_teaching_load": "Per-faculty workload: unique_courses_taught, total_students_taught, salary_per_course, teaching_load_category.",
|
|
138
|
+
"int_student_at_risk_indicators": "Per-student risk scoring: 8 binary flags summed into risk_level (Low/Moderate/High/Critical) + recommended_intervention.",
|
|
139
|
+
"student_academic_summary": "Final mart: student academic profile with class_standing, completion_rate, progress_indicator.",
|
|
140
|
+
"student_financial_profile": "Final mart: student financial summary with aid_recipient_category, payment_reliability, primary_aid_type.",
|
|
141
|
+
"faculty_performance_dashboard": "Final mart: faculty dashboard with teaching_impact_category, engagement_effectiveness, career_stage.",
|
|
142
|
+
"department_overview": "Final mart: department overview with scale, efficiency ratios, faculty/student metrics.",
|
|
143
|
+
"course_performance_summary": "Final mart: course performance category (High/Good/Average/Needs Improvement) with grade distribution.",
|
|
144
|
+
"student_at_risk_report": "Final mart: at-risk students only (risk_level != Low Risk), ordered by total_risk_score for intervention prioritization.",
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _auto_desc(name: str, layer: str) -> str:
|
|
149
|
+
return _AUTO_DESCS.get(name, f"{layer.capitalize()} model: {name}")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _infer_materialized(layer: str, config: dict) -> str:
|
|
153
|
+
if config and "materialized" in config:
|
|
154
|
+
return config["materialized"]
|
|
155
|
+
return {
|
|
156
|
+
"source": "external",
|
|
157
|
+
"staging": "view",
|
|
158
|
+
"intermediate": "view",
|
|
159
|
+
"mart": "table",
|
|
160
|
+
"seed": "seed",
|
|
161
|
+
"macro": "macro",
|
|
162
|
+
}.get(layer, "view")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# ── graph.json ────────────────────────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
def build_graph_json(
|
|
168
|
+
parsed: dict,
|
|
169
|
+
project_name: str,
|
|
170
|
+
uid_to_community: dict[str, int],
|
|
171
|
+
community_labels: dict[int, str],
|
|
172
|
+
) -> dict:
|
|
173
|
+
"""Build graphify-compatible graph.json in NetworkX node-link format.
|
|
174
|
+
|
|
175
|
+
All node properties are FLAT at the top level — no nested 'properties' key.
|
|
176
|
+
Required by graphify's node_link_graph() loader.
|
|
177
|
+
"""
|
|
178
|
+
nodes_out = []
|
|
179
|
+
edges_out = []
|
|
180
|
+
|
|
181
|
+
for n in parsed["nodes"]:
|
|
182
|
+
uid = n["uid"]
|
|
183
|
+
name = n["name"]
|
|
184
|
+
layer = n["layer"]
|
|
185
|
+
description = n.get("description") or _auto_desc(name, layer)
|
|
186
|
+
materialized = _infer_materialized(layer, n.get("config", {}))
|
|
187
|
+
|
|
188
|
+
# FLAT node — all fields at top level
|
|
189
|
+
node = {
|
|
190
|
+
"id": uid,
|
|
191
|
+
"label": name,
|
|
192
|
+
"community": uid_to_community.get(uid, 0),
|
|
193
|
+
"type": n["resource_type"],
|
|
194
|
+
"layer": layer,
|
|
195
|
+
"source_file": n.get("file_path", ""),
|
|
196
|
+
"description": description,
|
|
197
|
+
"materialized": materialized,
|
|
198
|
+
"upstream": n.get("upstream", []),
|
|
199
|
+
"downstream": n.get("downstream", []),
|
|
200
|
+
"columns": n.get("columns", []),
|
|
201
|
+
"refs": n.get("refs", []),
|
|
202
|
+
"sources_used": n.get("sources", []),
|
|
203
|
+
"tags": n.get("tags", []),
|
|
204
|
+
}
|
|
205
|
+
if n.get("schema"):
|
|
206
|
+
node["schema"] = n["schema"]
|
|
207
|
+
|
|
208
|
+
nodes_out.append(node)
|
|
209
|
+
|
|
210
|
+
# Edges: upstream → this node
|
|
211
|
+
for parent_uid in n.get("upstream", []):
|
|
212
|
+
edges_out.append({
|
|
213
|
+
"source": parent_uid,
|
|
214
|
+
"target": uid,
|
|
215
|
+
"type": "LINEAGE",
|
|
216
|
+
"provenance": "EXTRACTED",
|
|
217
|
+
"key": 0,
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
# God nodes: one per layer (not added as graph nodes to avoid polluting BFS)
|
|
221
|
+
layer_groups: dict[str, list[str]] = defaultdict(list)
|
|
222
|
+
for n in parsed["nodes"]:
|
|
223
|
+
if n["layer"] not in ("test", "macro"):
|
|
224
|
+
layer_groups[n["layer"]].append(n["name"])
|
|
225
|
+
|
|
226
|
+
god_nodes = [
|
|
227
|
+
{
|
|
228
|
+
"layer": layer,
|
|
229
|
+
"label": f"LAYER:{layer.upper()}",
|
|
230
|
+
"count": len(names),
|
|
231
|
+
"models": sorted(names),
|
|
232
|
+
}
|
|
233
|
+
for layer, names in sorted(layer_groups.items())
|
|
234
|
+
]
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
"directed": True,
|
|
238
|
+
"multigraph": False,
|
|
239
|
+
"graph": {
|
|
240
|
+
"project": project_name,
|
|
241
|
+
"generated_by": "dbt-graphify",
|
|
242
|
+
"communities": {
|
|
243
|
+
str(cid): label for cid, label in community_labels.items()
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
"god_nodes": god_nodes,
|
|
247
|
+
"nodes": nodes_out,
|
|
248
|
+
"edges": edges_out,
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
# ── lineage.json ──────────────────────────────────────────────────────────────
|
|
253
|
+
|
|
254
|
+
def build_lineage_json(nodes: list[dict], parsed: dict) -> dict:
|
|
255
|
+
adj_up = parsed.get("upstream", {})
|
|
256
|
+
adj_down = parsed.get("downstream", {})
|
|
257
|
+
|
|
258
|
+
ancestors = {n["uid"]: bfs_reachable(n["uid"], adj_up) for n in nodes}
|
|
259
|
+
descendants = {n["uid"]: bfs_reachable(n["uid"], adj_down) for n in nodes}
|
|
260
|
+
|
|
261
|
+
return {
|
|
262
|
+
"description": (
|
|
263
|
+
"Blast-radius maps. "
|
|
264
|
+
"'ancestors' = full upstream chain (what a node reads from). "
|
|
265
|
+
"'descendants' = full downstream chain (what breaks if this node changes)."
|
|
266
|
+
),
|
|
267
|
+
"ancestors": ancestors,
|
|
268
|
+
"descendants": descendants,
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
# ── GRAPH_REPORT.md ───────────────────────────────────────────────────────────
|
|
273
|
+
|
|
274
|
+
def write_report(
|
|
275
|
+
parsed: dict,
|
|
276
|
+
out_dir: Path,
|
|
277
|
+
project_name: str,
|
|
278
|
+
lineage: dict,
|
|
279
|
+
uid_to_community: dict[str, int],
|
|
280
|
+
community_labels: dict[int, str],
|
|
281
|
+
):
|
|
282
|
+
nodes = parsed["nodes"]
|
|
283
|
+
|
|
284
|
+
layer_order = ["source", "staging", "intermediate", "mart", "seed", "macro"]
|
|
285
|
+
layer_labels = {
|
|
286
|
+
"source": "Sources",
|
|
287
|
+
"staging": "Staging — views, clean & standardize",
|
|
288
|
+
"intermediate": "Intermediate — views, aggregate & join",
|
|
289
|
+
"mart": "Marts — tables, BI-ready",
|
|
290
|
+
"seed": "Seeds — static reference data",
|
|
291
|
+
"macro": "Macros — reusable Jinja2 logic",
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
lines = [
|
|
295
|
+
f"# dbt Knowledge Graph — {project_name}",
|
|
296
|
+
"",
|
|
297
|
+
"_Generated by dbt-graphify. Query: `graphify query \"<question>\"`_",
|
|
298
|
+
"",
|
|
299
|
+
"## Architecture",
|
|
300
|
+
"",
|
|
301
|
+
"```",
|
|
302
|
+
"Source tables ──► staging/ (views, clean + standardize)",
|
|
303
|
+
" │",
|
|
304
|
+
" ▼",
|
|
305
|
+
" intermediate/ (views, aggregate + join)",
|
|
306
|
+
" │",
|
|
307
|
+
" ▼",
|
|
308
|
+
" marts/ (tables, BI-ready outputs)",
|
|
309
|
+
"```",
|
|
310
|
+
"",
|
|
311
|
+
"## Node Inventory",
|
|
312
|
+
"",
|
|
313
|
+
]
|
|
314
|
+
|
|
315
|
+
for layer in layer_order:
|
|
316
|
+
layer_nodes = [n for n in nodes if n["layer"] == layer]
|
|
317
|
+
if not layer_nodes:
|
|
318
|
+
continue
|
|
319
|
+
lines.append(f"### {layer_labels.get(layer, layer)} ({len(layer_nodes)})")
|
|
320
|
+
lines.append("")
|
|
321
|
+
for n in sorted(layer_nodes, key=lambda x: x["name"]):
|
|
322
|
+
desc = n.get("description") or _auto_desc(n["name"], layer)
|
|
323
|
+
mat = _infer_materialized(layer, n.get("config", {}))
|
|
324
|
+
up = [u.split(".")[-1] for u in n.get("upstream", [])]
|
|
325
|
+
down = [d.split(".")[-1] for d in n.get("downstream", [])]
|
|
326
|
+
cols = n.get("columns", [])
|
|
327
|
+
community_label = community_labels.get(uid_to_community.get(n["uid"], 0), "")
|
|
328
|
+
lines.append(f"**{n['name']}** `[{mat}]` · community: _{community_label}_")
|
|
329
|
+
lines.append(f"> {desc}")
|
|
330
|
+
if up:
|
|
331
|
+
lines.append(f"- Reads from: `{'`, `'.join(sorted(up))}`")
|
|
332
|
+
if down:
|
|
333
|
+
lines.append(f"- Feeds into: `{'`, `'.join(sorted(down))}`")
|
|
334
|
+
if cols:
|
|
335
|
+
shown = cols[:15]
|
|
336
|
+
suffix = "..." if len(cols) > 15 else ""
|
|
337
|
+
lines.append(f"- Columns: `{'`, `'.join(shown)}`{suffix}")
|
|
338
|
+
lines.append("")
|
|
339
|
+
|
|
340
|
+
# Blast-radius table
|
|
341
|
+
lines += [
|
|
342
|
+
"## Blast Radius",
|
|
343
|
+
"",
|
|
344
|
+
"Downstream models affected when a model changes:",
|
|
345
|
+
"",
|
|
346
|
+
"| Model | Affected downstream |",
|
|
347
|
+
"|---|---|",
|
|
348
|
+
]
|
|
349
|
+
|
|
350
|
+
for n in sorted(nodes, key=lambda x: -len(lineage["descendants"].get(x["uid"], []))):
|
|
351
|
+
if n["layer"] in ("test", "macro", "seed"):
|
|
352
|
+
continue
|
|
353
|
+
desc = [d.split(".")[-1] for d in lineage["descendants"].get(n["uid"], [])]
|
|
354
|
+
if desc:
|
|
355
|
+
lines.append(f"| `{n['name']}` | {', '.join(sorted(desc))} |")
|
|
356
|
+
|
|
357
|
+
# Community legend
|
|
358
|
+
lines += [
|
|
359
|
+
"",
|
|
360
|
+
"## Communities",
|
|
361
|
+
"",
|
|
362
|
+
"| ID | Label |",
|
|
363
|
+
"|---|---|",
|
|
364
|
+
]
|
|
365
|
+
for cid in sorted(community_labels):
|
|
366
|
+
lines.append(f"| {cid} | {community_labels[cid]} |")
|
|
367
|
+
|
|
368
|
+
lines += [
|
|
369
|
+
"",
|
|
370
|
+
"---",
|
|
371
|
+
"_Regenerate: `dbt-graphify` or `python -m dbt_graphify`_",
|
|
372
|
+
]
|
|
373
|
+
|
|
374
|
+
(out_dir / "GRAPH_REPORT.md").write_text("\n".join(lines), encoding="utf-8")
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
# ── Sidecar files ─────────────────────────────────────────────────────────────
|
|
378
|
+
|
|
379
|
+
def write_graphify_root(out_dir: Path, project_root: Path):
|
|
380
|
+
(out_dir / ".graphify_root").write_text(str(project_root.resolve()), encoding="utf-8")
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def write_graphify_labels(out_dir: Path, community_labels: dict[int, str]):
|
|
384
|
+
data = {str(cid): label for cid, label in community_labels.items()}
|
|
385
|
+
(out_dir / ".graphify_labels.json").write_text(
|
|
386
|
+
json.dumps(data, indent=2), encoding="utf-8"
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
# ── HTML via graphify cluster-only ────────────────────────────────────────────
|
|
391
|
+
|
|
392
|
+
def generate_html(out_dir: Path, project_root: Path):
|
|
393
|
+
graphify_bin = shutil.which("graphify")
|
|
394
|
+
if not graphify_bin:
|
|
395
|
+
print(
|
|
396
|
+
" [dbt-graphify] Note: install graphifyy (`pip install graphifyy`) "
|
|
397
|
+
"to also generate graph.html"
|
|
398
|
+
)
|
|
399
|
+
return
|
|
400
|
+
|
|
401
|
+
try:
|
|
402
|
+
result = subprocess.run(
|
|
403
|
+
[graphify_bin, "cluster-only", str(project_root), "--no-label"],
|
|
404
|
+
capture_output=True,
|
|
405
|
+
text=True,
|
|
406
|
+
timeout=120,
|
|
407
|
+
)
|
|
408
|
+
if result.returncode == 0:
|
|
409
|
+
print(" [dbt-graphify] graph.html generated via graphify cluster-only")
|
|
410
|
+
else:
|
|
411
|
+
print(
|
|
412
|
+
f" [dbt-graphify] Warning: graphify cluster-only exited {result.returncode} "
|
|
413
|
+
f"— graph.html not generated. graph.json is still valid."
|
|
414
|
+
)
|
|
415
|
+
if result.stderr:
|
|
416
|
+
print(f" {result.stderr.strip()[:200]}")
|
|
417
|
+
except subprocess.TimeoutExpired:
|
|
418
|
+
print(" [dbt-graphify] Warning: graphify cluster-only timed out — skipping graph.html")
|
|
419
|
+
except Exception as exc:
|
|
420
|
+
print(f" [dbt-graphify] Warning: could not run graphify cluster-only ({exc})")
|
dbt_graphify/cli.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""CLI entry point for dbt-graphify."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from collections import defaultdict
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from dbt_graphify.parser import (
|
|
9
|
+
find_manifest,
|
|
10
|
+
find_graph_summary,
|
|
11
|
+
parse_manifest,
|
|
12
|
+
parse_graph_summary,
|
|
13
|
+
)
|
|
14
|
+
from dbt_graphify.builder import (
|
|
15
|
+
build_community_map,
|
|
16
|
+
build_graph_json,
|
|
17
|
+
build_lineage_json,
|
|
18
|
+
write_report,
|
|
19
|
+
write_graphify_root,
|
|
20
|
+
write_graphify_labels,
|
|
21
|
+
generate_html,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def main():
|
|
26
|
+
args = sys.argv[1:]
|
|
27
|
+
manifest_path: Path | None = None
|
|
28
|
+
out_dir = Path("graphify-out")
|
|
29
|
+
skip_html = False
|
|
30
|
+
|
|
31
|
+
i = 0
|
|
32
|
+
while i < len(args):
|
|
33
|
+
if args[i] in ("--out", "-o") and i + 1 < len(args):
|
|
34
|
+
out_dir = Path(args[i + 1])
|
|
35
|
+
i += 2
|
|
36
|
+
elif args[i].startswith("--out="):
|
|
37
|
+
out_dir = Path(args[i].split("=", 1)[1])
|
|
38
|
+
i += 1
|
|
39
|
+
elif args[i] == "--no-html":
|
|
40
|
+
skip_html = True
|
|
41
|
+
i += 1
|
|
42
|
+
elif args[i] in ("-h", "--help"):
|
|
43
|
+
_print_help()
|
|
44
|
+
sys.exit(0)
|
|
45
|
+
elif not args[i].startswith("-"):
|
|
46
|
+
manifest_path = Path(args[i])
|
|
47
|
+
i += 1
|
|
48
|
+
else:
|
|
49
|
+
print(f"Unknown argument: {args[i]}", file=sys.stderr)
|
|
50
|
+
i += 1
|
|
51
|
+
|
|
52
|
+
cwd = Path.cwd()
|
|
53
|
+
|
|
54
|
+
# ── Source selection ──────────────────────────────────────────────────────
|
|
55
|
+
parsed = None
|
|
56
|
+
source_label = ""
|
|
57
|
+
project_root = cwd
|
|
58
|
+
|
|
59
|
+
if manifest_path is None:
|
|
60
|
+
manifest_path = find_manifest(cwd)
|
|
61
|
+
|
|
62
|
+
if manifest_path and manifest_path.exists() and manifest_path.stat().st_size > 10:
|
|
63
|
+
print(f"Reading manifest: {manifest_path}")
|
|
64
|
+
with open(manifest_path, encoding="utf-8") as f:
|
|
65
|
+
manifest = json.load(f)
|
|
66
|
+
project_root = manifest_path.parent.parent
|
|
67
|
+
parsed = parse_manifest(manifest, project_root)
|
|
68
|
+
source_label = "manifest.json"
|
|
69
|
+
else:
|
|
70
|
+
summary_path = find_graph_summary(cwd)
|
|
71
|
+
if summary_path is None:
|
|
72
|
+
print(
|
|
73
|
+
"ERROR: No manifest.json or graph_summary.json found with content.\n"
|
|
74
|
+
"Run `dbt compile` or `dbt parse` first, then retry.",
|
|
75
|
+
file=sys.stderr,
|
|
76
|
+
)
|
|
77
|
+
sys.exit(1)
|
|
78
|
+
print(f"manifest.json empty — falling back to: {summary_path}")
|
|
79
|
+
with open(summary_path, encoding="utf-8") as f:
|
|
80
|
+
summary = json.load(f)
|
|
81
|
+
project_root = summary_path.parent.parent
|
|
82
|
+
parsed = parse_graph_summary(summary, project_root)
|
|
83
|
+
source_label = "graph_summary.json"
|
|
84
|
+
|
|
85
|
+
project_name = parsed.get("project_name", "dbt_project")
|
|
86
|
+
nodes = parsed["nodes"]
|
|
87
|
+
|
|
88
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
89
|
+
|
|
90
|
+
# ── Community detection ───────────────────────────────────────────────────
|
|
91
|
+
uid_to_community, community_labels = build_community_map(nodes)
|
|
92
|
+
|
|
93
|
+
# ── graph.json ────────────────────────────────────────────────────────────
|
|
94
|
+
graph = build_graph_json(parsed, project_name, uid_to_community, community_labels)
|
|
95
|
+
with open(out_dir / "graph.json", "w", encoding="utf-8") as f:
|
|
96
|
+
json.dump(graph, f, indent=2)
|
|
97
|
+
|
|
98
|
+
# ── lineage.json ──────────────────────────────────────────────────────────
|
|
99
|
+
lineage = build_lineage_json(nodes, parsed)
|
|
100
|
+
with open(out_dir / "lineage.json", "w", encoding="utf-8") as f:
|
|
101
|
+
json.dump(lineage, f, indent=2)
|
|
102
|
+
|
|
103
|
+
# ── GRAPH_REPORT.md ───────────────────────────────────────────────────────
|
|
104
|
+
write_report(parsed, out_dir, project_name, lineage, uid_to_community, community_labels)
|
|
105
|
+
|
|
106
|
+
# ── Sidecar files ─────────────────────────────────────────────────────────
|
|
107
|
+
write_graphify_root(out_dir, project_root)
|
|
108
|
+
write_graphify_labels(out_dir, community_labels)
|
|
109
|
+
|
|
110
|
+
# ── graph.html via graphify ───────────────────────────────────────────────
|
|
111
|
+
# cluster-only needs the directory that *contains* graphify-out/, not the dbt project root
|
|
112
|
+
if not skip_html:
|
|
113
|
+
generate_html(out_dir, out_dir.parent.resolve())
|
|
114
|
+
|
|
115
|
+
# ── Summary ───────────────────────────────────────────────────────────────
|
|
116
|
+
layer_counts: dict[str, int] = defaultdict(int)
|
|
117
|
+
for n in nodes:
|
|
118
|
+
layer_counts[n["layer"]] += 1
|
|
119
|
+
|
|
120
|
+
n_nodes = len(graph["nodes"])
|
|
121
|
+
n_edges = len(graph["edges"])
|
|
122
|
+
|
|
123
|
+
print(f"\n✓ {out_dir}/graph.json ({n_nodes} nodes, {n_edges} edges) [source: {source_label}]")
|
|
124
|
+
print(f"✓ {out_dir}/lineage.json")
|
|
125
|
+
print(f"✓ {out_dir}/GRAPH_REPORT.md")
|
|
126
|
+
print(f"✓ {out_dir}/.graphify_root")
|
|
127
|
+
print(f"✓ {out_dir}/.graphify_labels.json")
|
|
128
|
+
print(f"\nNode breakdown:")
|
|
129
|
+
for layer in ["source", "staging", "intermediate", "mart", "seed", "macro"]:
|
|
130
|
+
if layer_counts[layer]:
|
|
131
|
+
print(f" {layer:14s} {layer_counts[layer]}")
|
|
132
|
+
print(f"\nQuery the graph:")
|
|
133
|
+
print(f' graphify query "which models use stg_students?"')
|
|
134
|
+
print(f' graphify query "trace the lineage of student_at_risk_report"')
|
|
135
|
+
print(f' graphify query "what breaks if I change stg_departments?"')
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _print_help():
|
|
139
|
+
print(
|
|
140
|
+
"dbt-graphify — parse dbt manifest.json into a graphify knowledge graph\n"
|
|
141
|
+
"\n"
|
|
142
|
+
"Usage:\n"
|
|
143
|
+
" dbt-graphify [manifest.json] [--out DIR] [--no-html]\n"
|
|
144
|
+
" python -m dbt_graphify [manifest.json] [--out DIR] [--no-html]\n"
|
|
145
|
+
"\n"
|
|
146
|
+
"Arguments:\n"
|
|
147
|
+
" manifest.json Path to dbt manifest.json (auto-detected if omitted)\n"
|
|
148
|
+
" --out DIR Output directory (default: graphify-out/)\n"
|
|
149
|
+
" --no-html Skip graph.html generation via graphify cluster-only\n"
|
|
150
|
+
"\n"
|
|
151
|
+
"Outputs:\n"
|
|
152
|
+
" graphify-out/graph.json NetworkX node-link graph\n"
|
|
153
|
+
" graphify-out/lineage.json Ancestor/descendant blast-radius maps\n"
|
|
154
|
+
" graphify-out/GRAPH_REPORT.md Human-readable architecture summary\n"
|
|
155
|
+
" graphify-out/.graphify_root Project root path\n"
|
|
156
|
+
" graphify-out/.graphify_labels.json Community labels\n"
|
|
157
|
+
" graphify-out/graph.html Interactive visualization (via graphify)\n"
|
|
158
|
+
)
|
dbt_graphify/parser.py
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
"""Parse dbt manifest.json or graph_summary.json into a unified graph dict."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from collections import defaultdict
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
# ── Layer inference ───────────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
def infer_layer(resource_type: str, name: str, fqn: list[str]) -> str:
|
|
12
|
+
if resource_type == "source":
|
|
13
|
+
return "source"
|
|
14
|
+
if resource_type == "seed":
|
|
15
|
+
return "seed"
|
|
16
|
+
if resource_type in ("test", "analysis"):
|
|
17
|
+
return resource_type
|
|
18
|
+
if resource_type == "macro":
|
|
19
|
+
return "macro"
|
|
20
|
+
# model — name prefix first
|
|
21
|
+
if name.startswith("stg_"):
|
|
22
|
+
return "staging"
|
|
23
|
+
if name.startswith("int_"):
|
|
24
|
+
return "intermediate"
|
|
25
|
+
# fqn segments
|
|
26
|
+
mart_segments = {"marts", "mart", "core", "academic", "finance", "financial", "reporting"}
|
|
27
|
+
staging_segments = {"staging", "stg"}
|
|
28
|
+
intermediate_segments = {"intermediate", "int"}
|
|
29
|
+
for seg in fqn:
|
|
30
|
+
seg_lower = seg.lower()
|
|
31
|
+
if seg_lower in staging_segments:
|
|
32
|
+
return "staging"
|
|
33
|
+
if seg_lower in intermediate_segments:
|
|
34
|
+
return "intermediate"
|
|
35
|
+
if seg_lower in mart_segments:
|
|
36
|
+
return "mart"
|
|
37
|
+
return "mart"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ── SQL extraction ────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
def extract_refs_from_sql(sql: str) -> list[str]:
|
|
43
|
+
return re.findall(r"\{\{\s*ref\s*\(\s*['\"](\w+)['\"]\s*\)\s*\}\}", sql)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def extract_sources_from_sql(sql: str) -> list[tuple[str, str]]:
|
|
47
|
+
return re.findall(
|
|
48
|
+
r"\{\{\s*source\s*\(\s*['\"](\w+)['\"]\s*,\s*['\"](\w+)['\"]\s*\)\s*\}\}", sql
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _extract_columns_sql(sql: str) -> list[str]:
|
|
53
|
+
"""Best-effort: extract column alias names from the final SELECT clause."""
|
|
54
|
+
clean = re.sub(r"\{\{.*?\}\}", "__jinja__", sql, flags=re.DOTALL)
|
|
55
|
+
clean = re.sub(r"\{%-?.*?-?%\}", "", clean, flags=re.DOTALL)
|
|
56
|
+
|
|
57
|
+
selects = list(re.finditer(r"\bselect\b(.*?)\bfrom\b", clean, re.IGNORECASE | re.DOTALL))
|
|
58
|
+
if not selects:
|
|
59
|
+
return []
|
|
60
|
+
|
|
61
|
+
clause = selects[-1].group(1).strip()
|
|
62
|
+
if clause.strip() == "*" and len(selects) > 1:
|
|
63
|
+
clause = selects[-2].group(1).strip()
|
|
64
|
+
|
|
65
|
+
depth, current, parts = 0, [], []
|
|
66
|
+
for ch in clause:
|
|
67
|
+
if ch == "(":
|
|
68
|
+
depth += 1
|
|
69
|
+
elif ch == ")":
|
|
70
|
+
depth -= 1
|
|
71
|
+
elif ch == "," and depth == 0:
|
|
72
|
+
parts.append("".join(current).strip())
|
|
73
|
+
current = []
|
|
74
|
+
continue
|
|
75
|
+
current.append(ch)
|
|
76
|
+
if current:
|
|
77
|
+
parts.append("".join(current).strip())
|
|
78
|
+
|
|
79
|
+
result = []
|
|
80
|
+
for part in parts:
|
|
81
|
+
part = part.strip()
|
|
82
|
+
if not part:
|
|
83
|
+
continue
|
|
84
|
+
alias = re.search(r"\bas\s+(\w+)\s*$", part, re.IGNORECASE)
|
|
85
|
+
if alias:
|
|
86
|
+
result.append(alias.group(1))
|
|
87
|
+
else:
|
|
88
|
+
word = re.search(r"(\w+)\s*$", part)
|
|
89
|
+
if word:
|
|
90
|
+
result.append(word.group(1))
|
|
91
|
+
return result
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ── File discovery ────────────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
def find_manifest(start: Path) -> Path | None:
|
|
97
|
+
candidates = [
|
|
98
|
+
start / "target" / "manifest.json",
|
|
99
|
+
start / "dbt_project" / "target" / "manifest.json",
|
|
100
|
+
start / "manifest.json",
|
|
101
|
+
]
|
|
102
|
+
for c in candidates:
|
|
103
|
+
if c.exists() and c.stat().st_size > 10:
|
|
104
|
+
return c
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def find_graph_summary(start: Path) -> Path | None:
|
|
109
|
+
candidates = [
|
|
110
|
+
start / "target" / "graph_summary.json",
|
|
111
|
+
start / "dbt_project" / "target" / "graph_summary.json",
|
|
112
|
+
start / "graph_summary.json",
|
|
113
|
+
]
|
|
114
|
+
for c in candidates:
|
|
115
|
+
if c.exists() and c.stat().st_size > 10:
|
|
116
|
+
return c
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _find_sql_file(name: str, project_root: Path) -> Path | None:
|
|
121
|
+
for p in project_root.rglob(f"{name}.sql"):
|
|
122
|
+
# Skip compiled/run output directories
|
|
123
|
+
if "compiled" not in p.parts and "run" not in p.parts:
|
|
124
|
+
return p
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# ── Manifest parser ───────────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
def parse_manifest(manifest: dict, project_root: Path) -> dict:
|
|
131
|
+
"""Parse dbt manifest.json into a unified graph dict."""
|
|
132
|
+
nodes_raw = manifest.get("nodes", {})
|
|
133
|
+
sources_raw = manifest.get("sources", {})
|
|
134
|
+
macros_raw = manifest.get("macros", {})
|
|
135
|
+
parent_map = manifest.get("parent_map", {})
|
|
136
|
+
child_map = manifest.get("child_map", {})
|
|
137
|
+
|
|
138
|
+
# Build upstream/downstream from parent_map
|
|
139
|
+
upstream: dict[str, set] = defaultdict(set)
|
|
140
|
+
downstream: dict[str, set] = defaultdict(set)
|
|
141
|
+
for node_id, parents in parent_map.items():
|
|
142
|
+
for parent_id in parents:
|
|
143
|
+
upstream[node_id].add(parent_id)
|
|
144
|
+
downstream[parent_id].add(node_id)
|
|
145
|
+
|
|
146
|
+
nodes = []
|
|
147
|
+
|
|
148
|
+
def _process_model(uid: str, raw: dict, resource_type: str):
|
|
149
|
+
name = raw.get("name", uid.split(".")[-1])
|
|
150
|
+
fqn = raw.get("fqn", [])
|
|
151
|
+
layer = infer_layer(resource_type, name, fqn)
|
|
152
|
+
config = raw.get("config", {})
|
|
153
|
+
description = raw.get("description", "").strip()
|
|
154
|
+
columns = list(raw.get("columns", {}).keys())
|
|
155
|
+
file_path = raw.get("original_file_path", raw.get("path", ""))
|
|
156
|
+
raw_code = raw.get("raw_code", raw.get("raw_sql", ""))
|
|
157
|
+
tags = raw.get("tags", [])
|
|
158
|
+
group = raw.get("group", None)
|
|
159
|
+
|
|
160
|
+
refs_in_code = extract_refs_from_sql(raw_code) if raw_code else []
|
|
161
|
+
sources_in_code = extract_sources_from_sql(raw_code) if raw_code else []
|
|
162
|
+
|
|
163
|
+
# Enrich from SQL file if code or columns missing
|
|
164
|
+
if (not raw_code or not columns) and project_root and file_path:
|
|
165
|
+
sql_path = project_root / file_path
|
|
166
|
+
if not sql_path.exists():
|
|
167
|
+
found = _find_sql_file(name, project_root)
|
|
168
|
+
if found:
|
|
169
|
+
sql_path = found
|
|
170
|
+
if sql_path.exists():
|
|
171
|
+
raw_code = sql_path.read_text(encoding="utf-8")
|
|
172
|
+
refs_in_code = extract_refs_from_sql(raw_code)
|
|
173
|
+
sources_in_code = extract_sources_from_sql(raw_code)
|
|
174
|
+
if not columns:
|
|
175
|
+
columns = _extract_columns_sql(raw_code)
|
|
176
|
+
if not file_path:
|
|
177
|
+
try:
|
|
178
|
+
file_path = str(sql_path.relative_to(project_root.parent))
|
|
179
|
+
except ValueError:
|
|
180
|
+
file_path = str(sql_path)
|
|
181
|
+
|
|
182
|
+
up_ids = sorted(upstream.get(uid, set()))
|
|
183
|
+
down_ids = sorted(downstream.get(uid, set()))
|
|
184
|
+
|
|
185
|
+
nodes.append({
|
|
186
|
+
"uid": uid,
|
|
187
|
+
"name": name,
|
|
188
|
+
"resource_type": resource_type,
|
|
189
|
+
"layer": layer,
|
|
190
|
+
"description": description,
|
|
191
|
+
"file_path": file_path,
|
|
192
|
+
"config": config,
|
|
193
|
+
"columns": columns[:40],
|
|
194
|
+
"refs": refs_in_code,
|
|
195
|
+
"sources": [f"{s[0]}.{s[1]}" for s in sources_in_code],
|
|
196
|
+
"tags": tags,
|
|
197
|
+
"group": group,
|
|
198
|
+
"upstream": up_ids,
|
|
199
|
+
"downstream": down_ids,
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
for uid, raw in nodes_raw.items():
|
|
203
|
+
rt = raw.get("resource_type", "model")
|
|
204
|
+
if rt in ("test", "analysis"):
|
|
205
|
+
continue
|
|
206
|
+
_process_model(uid, raw, rt)
|
|
207
|
+
|
|
208
|
+
for uid, raw in sources_raw.items():
|
|
209
|
+
name = raw.get("name", "")
|
|
210
|
+
source_name = raw.get("source_name", "")
|
|
211
|
+
description = raw.get("description", "").strip()
|
|
212
|
+
columns = list(raw.get("columns", {}).keys())
|
|
213
|
+
down_ids = sorted(downstream.get(uid, set()))
|
|
214
|
+
|
|
215
|
+
nodes.append({
|
|
216
|
+
"uid": uid,
|
|
217
|
+
"name": name,
|
|
218
|
+
"resource_type": "source",
|
|
219
|
+
"layer": "source",
|
|
220
|
+
"description": description or f"Source table: {source_name}.{name}",
|
|
221
|
+
"file_path": raw.get("original_file_path", ""),
|
|
222
|
+
"config": {"materialized": "external"},
|
|
223
|
+
"columns": columns[:40],
|
|
224
|
+
"refs": [],
|
|
225
|
+
"sources": [],
|
|
226
|
+
"tags": raw.get("tags", []),
|
|
227
|
+
"group": raw.get("group", None),
|
|
228
|
+
"schema": source_name,
|
|
229
|
+
"upstream": [],
|
|
230
|
+
"downstream": down_ids,
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
for uid, raw in macros_raw.items():
|
|
234
|
+
name = raw.get("name", uid.split(".")[-1])
|
|
235
|
+
nodes.append({
|
|
236
|
+
"uid": uid,
|
|
237
|
+
"name": name,
|
|
238
|
+
"resource_type": "macro",
|
|
239
|
+
"layer": "macro",
|
|
240
|
+
"description": raw.get("description", "").strip() or f"Jinja2 macro: {name}",
|
|
241
|
+
"file_path": raw.get("original_file_path", ""),
|
|
242
|
+
"config": {"materialized": "macro"},
|
|
243
|
+
"columns": [],
|
|
244
|
+
"refs": [],
|
|
245
|
+
"sources": [],
|
|
246
|
+
"tags": [],
|
|
247
|
+
"group": None,
|
|
248
|
+
"upstream": [],
|
|
249
|
+
"downstream": [],
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
meta = manifest.get("metadata", {})
|
|
253
|
+
return {
|
|
254
|
+
"meta": meta,
|
|
255
|
+
"project_name": meta.get("project_name", "dbt_project"),
|
|
256
|
+
"nodes": nodes,
|
|
257
|
+
"upstream": {k: list(v) for k, v in upstream.items()},
|
|
258
|
+
"downstream": {k: list(v) for k, v in downstream.items()},
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
# ── graph_summary.json fallback ───────────────────────────────────────────────
|
|
263
|
+
|
|
264
|
+
def parse_graph_summary(summary: dict, project_root: Path) -> dict:
|
|
265
|
+
"""Fallback: parse dbt graph_summary.json + raw SQL files."""
|
|
266
|
+
linked = summary.get("linked", {})
|
|
267
|
+
|
|
268
|
+
id_to_name: dict[int, str] = {}
|
|
269
|
+
id_to_type: dict[int, str] = {}
|
|
270
|
+
for nid, nd in linked.items():
|
|
271
|
+
id_to_name[int(nid)] = nd["name"]
|
|
272
|
+
id_to_type[int(nid)] = nd["type"]
|
|
273
|
+
|
|
274
|
+
upstream: dict[str, set] = defaultdict(set)
|
|
275
|
+
downstream: dict[str, set] = defaultdict(set)
|
|
276
|
+
|
|
277
|
+
for nid_str, nd in linked.items():
|
|
278
|
+
nid = int(nid_str)
|
|
279
|
+
if nd["type"] == "test":
|
|
280
|
+
continue
|
|
281
|
+
src_name = nd["name"]
|
|
282
|
+
for succ_id in nd.get("succ", []):
|
|
283
|
+
tgt_name = id_to_name[succ_id]
|
|
284
|
+
if id_to_type[succ_id] == "test":
|
|
285
|
+
continue
|
|
286
|
+
upstream[tgt_name].add(src_name)
|
|
287
|
+
downstream[src_name].add(tgt_name)
|
|
288
|
+
|
|
289
|
+
nodes = []
|
|
290
|
+
for nid_str, nd in linked.items():
|
|
291
|
+
node_type = nd["type"]
|
|
292
|
+
if node_type == "test":
|
|
293
|
+
continue
|
|
294
|
+
|
|
295
|
+
full_name = nd["name"]
|
|
296
|
+
short_name = full_name.split(".")[-1]
|
|
297
|
+
fqn = full_name.split(".")
|
|
298
|
+
layer = infer_layer(node_type, short_name, fqn)
|
|
299
|
+
|
|
300
|
+
columns, refs_in_code, sources_in_code, file_path = [], [], [], ""
|
|
301
|
+
|
|
302
|
+
if node_type == "model" and project_root:
|
|
303
|
+
sql_file = _find_sql_file(short_name, project_root)
|
|
304
|
+
if sql_file:
|
|
305
|
+
try:
|
|
306
|
+
file_path = str(sql_file.relative_to(project_root.parent))
|
|
307
|
+
except ValueError:
|
|
308
|
+
file_path = str(sql_file)
|
|
309
|
+
sql = sql_file.read_text(encoding="utf-8")
|
|
310
|
+
refs_in_code = extract_refs_from_sql(sql)
|
|
311
|
+
sources_in_code = extract_sources_from_sql(sql)
|
|
312
|
+
columns = _extract_columns_sql(sql)
|
|
313
|
+
|
|
314
|
+
up_ids = sorted(upstream.get(full_name, set()))
|
|
315
|
+
down_ids = sorted(downstream.get(full_name, set()))
|
|
316
|
+
|
|
317
|
+
nodes.append({
|
|
318
|
+
"uid": full_name,
|
|
319
|
+
"name": short_name,
|
|
320
|
+
"resource_type": node_type,
|
|
321
|
+
"layer": layer,
|
|
322
|
+
"description": "",
|
|
323
|
+
"file_path": file_path,
|
|
324
|
+
"config": {},
|
|
325
|
+
"columns": columns[:40],
|
|
326
|
+
"refs": refs_in_code,
|
|
327
|
+
"sources": [f"{s[0]}.{s[1]}" for s in sources_in_code],
|
|
328
|
+
"tags": [],
|
|
329
|
+
"group": None,
|
|
330
|
+
"upstream": up_ids,
|
|
331
|
+
"downstream": down_ids,
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
return {
|
|
335
|
+
"meta": {"source": "graph_summary.json"},
|
|
336
|
+
"project_name": "dbt_project",
|
|
337
|
+
"nodes": nodes,
|
|
338
|
+
"upstream": {k: list(v) for k, v in upstream.items()},
|
|
339
|
+
"downstream": {k: list(v) for k, v in downstream.items()},
|
|
340
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dbt-graphify
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Parse a dbt manifest.json into a graphify-queryable knowledge graph
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Keywords: dbt,graphify,lineage,knowledge-graph,data-catalog
|
|
7
|
+
Author: Ponder Labs
|
|
8
|
+
Requires-Python: >=3.9
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
12
|
+
Classifier: Topic :: Database
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Provides-Extra: all
|
|
19
|
+
Provides-Extra: clustering
|
|
20
|
+
Provides-Extra: yaml
|
|
21
|
+
Requires-Dist: PyYAML (>=6.0) ; extra == "all"
|
|
22
|
+
Requires-Dist: PyYAML (>=6.0) ; extra == "yaml"
|
|
23
|
+
Requires-Dist: networkx (>=3.0) ; extra == "all"
|
|
24
|
+
Requires-Dist: networkx (>=3.0) ; extra == "clustering"
|
|
25
|
+
Project-URL: Homepage, https://github.com/ponder-co/dbt-graphify
|
|
26
|
+
Project-URL: Repository, https://github.com/ponder-co/dbt-graphify
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# dbt-graphify
|
|
30
|
+
|
|
31
|
+
Parse a dbt `manifest.json` into a [graphify](https://github.com/graphifyy/graphifyy)-queryable knowledge graph. Ask AI questions about your dbt lineage without reading hundreds of SQL files.
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
dbt manifest.json ──► dbt-graphify ──► graphify-out/graph.json
|
|
35
|
+
│
|
|
36
|
+
┌─────────────────────┤
|
|
37
|
+
▼ ▼
|
|
38
|
+
graphify query graph.html
|
|
39
|
+
"what breaks if I (interactive
|
|
40
|
+
change stg_orders?" D3 viewer)
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Why
|
|
44
|
+
|
|
45
|
+
Graphify can't read dbt projects directly — Jinja2 SQL (`{{ ref('model') }}`) breaks its extractor, and it doesn't understand dbt's layer semantics (staging → intermediate → mart). The result is a noisy, incomplete graph that costs more tokens to query than just grepping the files.
|
|
46
|
+
|
|
47
|
+
dbt already computes the perfect graph in `manifest.json`. This tool reshapes it into the format graphify expects, with zero LLM calls.
|
|
48
|
+
|
|
49
|
+
## Installation
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pip install dbt-graphify
|
|
53
|
+
|
|
54
|
+
# With topology-based community detection (recommended):
|
|
55
|
+
pip install "dbt-graphify[clustering]"
|
|
56
|
+
|
|
57
|
+
# With YAML source description parsing:
|
|
58
|
+
pip install "dbt-graphify[yaml]"
|
|
59
|
+
|
|
60
|
+
# Everything:
|
|
61
|
+
pip install "dbt-graphify[all]"
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
No runtime dependencies without extras — stdlib only.
|
|
65
|
+
|
|
66
|
+
## Usage
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
# Auto-detect manifest.json in standard dbt locations
|
|
70
|
+
dbt-graphify
|
|
71
|
+
|
|
72
|
+
# Explicit manifest path
|
|
73
|
+
dbt-graphify path/to/target/manifest.json
|
|
74
|
+
|
|
75
|
+
# Custom output directory
|
|
76
|
+
dbt-graphify --out /tmp/my-graph
|
|
77
|
+
|
|
78
|
+
# Skip graph.html generation
|
|
79
|
+
dbt-graphify --no-html
|
|
80
|
+
|
|
81
|
+
# Module form (no install required)
|
|
82
|
+
python -m dbt_graphify
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### Fallback: no compiled manifest
|
|
86
|
+
|
|
87
|
+
If `manifest.json` is empty (dbt not compiled), the tool falls back to `graph_summary.json`, which dbt writes even during `dbt parse`:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
cd your-dbt-project/
|
|
91
|
+
dbt parse # fast, no DB connection needed
|
|
92
|
+
dbt-graphify # auto-finds target/graph_summary.json
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Output
|
|
96
|
+
|
|
97
|
+
All files written to `graphify-out/` (or `--out` dir):
|
|
98
|
+
|
|
99
|
+
| File | Description |
|
|
100
|
+
|---|---|
|
|
101
|
+
| `graph.json` | NetworkX node-link graph — loaded by `graphify query` |
|
|
102
|
+
| `lineage.json` | Full ancestor/descendant maps for blast-radius analysis |
|
|
103
|
+
| `GRAPH_REPORT.md` | Human-readable architecture summary with blast-radius table |
|
|
104
|
+
| `.graphify_root` | Project root path (read by graphify for incremental updates) |
|
|
105
|
+
| `.graphify_labels.json` | Community integer → label name mapping |
|
|
106
|
+
| `graph.html` | Interactive D3 visualization (generated via `graphify cluster-only`) |
|
|
107
|
+
|
|
108
|
+
## Querying with graphify
|
|
109
|
+
|
|
110
|
+
After running `dbt-graphify`, use [graphify](https://github.com/graphifyy/graphifyy) to query the graph:
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
graphify query "which models depend on stg_customers?"
|
|
114
|
+
graphify query "trace the full lineage of orders_mart"
|
|
115
|
+
graphify query "what breaks if I change stg_payments?"
|
|
116
|
+
graphify path "raw_orders" "revenue_report"
|
|
117
|
+
graphify explain "int_order_metrics"
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### Claude Code integration
|
|
121
|
+
|
|
122
|
+
Install the graphify skill and run `/dbt-graphify` to regenerate the graph, then use `/graphify query` for any lineage question:
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
graphify install claude # installs the graphify skill + hook
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
After that, any `graphify query` or `graphify path` command in Claude Code will use the knowledge graph instead of reading raw SQL files — typically 70–90% fewer tokens per question.
|
|
129
|
+
|
|
130
|
+
## Node structure
|
|
131
|
+
|
|
132
|
+
Each node in `graph.json` carries these flat fields (readable by `graphify query`):
|
|
133
|
+
|
|
134
|
+
| Field | Example |
|
|
135
|
+
|---|---|
|
|
136
|
+
| `id` | `model.my_project.stg_orders` |
|
|
137
|
+
| `label` | `stg_orders` |
|
|
138
|
+
| `layer` | `staging` |
|
|
139
|
+
| `type` | `model` |
|
|
140
|
+
| `materialized` | `view` |
|
|
141
|
+
| `description` | `Cleans raw orders. Derives: status_label, days_to_ship.` |
|
|
142
|
+
| `source_file` | `models/staging/stg_orders.sql` |
|
|
143
|
+
| `community` | `1` |
|
|
144
|
+
| `upstream` | `["source.my_project.raw.orders"]` |
|
|
145
|
+
| `downstream` | `["model.my_project.int_order_metrics", ...]` |
|
|
146
|
+
| `columns` | `["order_id", "customer_id", "status_label", ...]` |
|
|
147
|
+
|
|
148
|
+
## Community detection
|
|
149
|
+
|
|
150
|
+
Communities are assigned via this priority chain so the tool works on any dbt project, not just a specific domain:
|
|
151
|
+
|
|
152
|
+
1. **dbt `group`** — if your models use [dbt groups](https://docs.getdbt.com/docs/build/groups), each group becomes a community
|
|
153
|
+
2. **dbt `tags`** — first tag on each model becomes the community
|
|
154
|
+
3. **Topology clustering** — Louvain modularity on the DAG (requires `pip install "dbt-graphify[clustering]"`)
|
|
155
|
+
4. **Layer fallback** — source / staging / intermediate / mart / seed (always available)
|
|
156
|
+
|
|
157
|
+
## Blast-radius analysis
|
|
158
|
+
|
|
159
|
+
`lineage.json` contains full ancestor and descendant maps computed via BFS:
|
|
160
|
+
|
|
161
|
+
```python
|
|
162
|
+
import json
|
|
163
|
+
|
|
164
|
+
with open("graphify-out/lineage.json") as f:
|
|
165
|
+
lineage = json.load(f)
|
|
166
|
+
|
|
167
|
+
# Everything downstream of stg_customers
|
|
168
|
+
affected = lineage["descendants"]["model.my_project.stg_customers"]
|
|
169
|
+
print(f"Changing stg_customers breaks: {affected}")
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
## Requirements
|
|
173
|
+
|
|
174
|
+
- Python ≥ 3.9
|
|
175
|
+
- A dbt project with `dbt compile` or `dbt parse` run (produces `target/manifest.json` or `target/graph_summary.json`)
|
|
176
|
+
- [graphify](https://github.com/graphifyy/graphifyy) (`pip install graphifyy`) for `graph.html` and query CLI — optional but recommended
|
|
177
|
+
|
|
178
|
+
## License
|
|
179
|
+
|
|
180
|
+
MIT
|
|
181
|
+
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
dbt_graphify/__init__.py,sha256=UJ-5DtBgzGJeV64yM2JkKB-YWwQ0Fv2K9NmivOREB4k,112
|
|
2
|
+
dbt_graphify/__main__.py,sha256=Xxg8lpMbaSrz_E_p1ixA9ZzmWZTvltVqSzZovvo2aKE,42
|
|
3
|
+
dbt_graphify/builder.py,sha256=7JKbZWWI67gBsI1NABXTydFOW7JH6KnVNJLocSV8wgU,17110
|
|
4
|
+
dbt_graphify/cli.py,sha256=wsaR_8zysjZQLaoyLa4kYvqG_asOT9glvRuCYWUpBkk,6910
|
|
5
|
+
dbt_graphify/parser.py,sha256=-KNNsZ5Cql2BoRoQuN8GKH8EZZJnSah9e6UmW_VnygQ,12328
|
|
6
|
+
dbt_graphify-0.1.0.dist-info/METADATA,sha256=MJWFxk0DQDUf8rlG-rUW0UKqnmivfBGZyCoWhETETR4,6460
|
|
7
|
+
dbt_graphify-0.1.0.dist-info/WHEEL,sha256=EGEvSphFYqXKs23-kQBeyNoJP1nrT8ZJKQoi5p5DYL8,88
|
|
8
|
+
dbt_graphify-0.1.0.dist-info/entry_points.txt,sha256=O4eNv-wgpxqgaQz0KdXPLygY9aksEUe7GWGmm_aBahE,54
|
|
9
|
+
dbt_graphify-0.1.0.dist-info/RECORD,,
|