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.
@@ -0,0 +1,3 @@
1
+ """dbt-graphify: Parse a dbt manifest.json into a graphify-queryable knowledge graph."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from dbt_graphify.cli import main
2
+
3
+ main()
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 2.4.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ dbt-graphify=dbt_graphify.cli:main
3
+