witan-code 0.2.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,347 @@
1
+ """Cross-repo dependency visualization from the bridge store.
2
+
3
+ Builds a directed "A depends on B" graph from interface bindings (A consumes a
4
+ contract B provides; for ``service`` bindings, the deploying repo depends on the
5
+ repo it deploys) and renders it as a Rich terminal summary and/or a self-contained
6
+ interactive HTML graph.
7
+ """
8
+
9
+ import json
10
+ from collections import defaultdict
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+
14
+ # Per-kind edge colours, reused by the Rich legend and the HTML graph.
15
+ KIND_COLORS = {
16
+ "env_var": "#e8a33d",
17
+ "endpoint": "#4c9be8",
18
+ "package": "#56b870",
19
+ "service": "#b86fd1",
20
+ }
21
+
22
+
23
+ @dataclass
24
+ class Edge:
25
+ src: str # depends on …
26
+ dst: str # … this repo
27
+ kinds: dict[str, int] = field(default_factory=lambda: defaultdict(int))
28
+ # the individual cross-repo linkages backing this edge: {"kind", "key"}
29
+ contracts: list[dict] = field(default_factory=list)
30
+
31
+ def add(self, kind: str, key: str, confidence: float = 1.0) -> None:
32
+ self.kinds[kind] += 1
33
+ self.contracts.append({"kind": kind, "key": key, "confidence": confidence})
34
+
35
+ @property
36
+ def weight(self) -> int:
37
+ return sum(self.kinds.values())
38
+
39
+
40
+ @dataclass
41
+ class DepGraph:
42
+ repos: set[str] = field(default_factory=set)
43
+ edges: dict[tuple[str, str], Edge] = field(default_factory=dict)
44
+ # (kind, key_norm) -> {"providers": {repo}, "consumers": {repo}}
45
+ contracts: dict[tuple[str, str], dict] = field(default_factory=dict)
46
+
47
+ def edge(self, src: str, dst: str) -> Edge:
48
+ key = (src, dst)
49
+ if key not in self.edges:
50
+ self.edges[key] = Edge(src, dst)
51
+ self.repos.update((src, dst))
52
+ return self.edges[key]
53
+
54
+
55
+ def short_repo(repo: str) -> str:
56
+ """``https://github.com/mitodl/mit-learn`` → ``mitodl/mit-learn``."""
57
+ repo = repo.rstrip("/")
58
+ parts = repo.split("/")
59
+ return "/".join(parts[-2:]) if len(parts) >= 2 else repo
60
+
61
+
62
+ def cross_repo_edges(
63
+ rows: list[dict],
64
+ *,
65
+ kind: str | None = None,
66
+ min_confidence: float = 0.5,
67
+ ) -> list[dict]:
68
+ """Return the subset of binding rows that form genuine cross-repo edges.
69
+
70
+ Filters consumer bindings by ``min_confidence`` (default 0.5). Provider
71
+ bindings and non-endpoint consumers are always included (their confidence
72
+ defaults to 1.0 in the store). Original row dicts are returned unchanged;
73
+ the ``confidence`` key is already present on rows that have it (written by
74
+ the indexer). Legacy store records without a ``confidence`` key are treated
75
+ as 1.0 during filtering but are not mutated.
76
+
77
+ ``kind`` narrows to one contract kind if provided.
78
+ """
79
+ out: list[dict] = []
80
+ for row in rows:
81
+ if kind and row.get("kind") != kind:
82
+ continue
83
+ raw_conf = row.get("confidence")
84
+ conf = float(raw_conf if raw_conf is not None else 1.0)
85
+ if row.get("role") == "consumer" and row.get("kind") == "endpoint":
86
+ if conf < min_confidence:
87
+ continue
88
+ out.append(row)
89
+ return out
90
+
91
+
92
+ def build_graph(
93
+ rows: list[dict],
94
+ *,
95
+ kind: str | None = None,
96
+ repo: str | None = None,
97
+ min_confidence: float = 0.5,
98
+ min_precision: str = "heuristic",
99
+ repo_symbol_rows: list[dict] | None = None,
100
+ ) -> DepGraph:
101
+ """Compute the cross-repo dependency graph from raw binding rows.
102
+
103
+ ``kind`` filters to one contract kind; ``repo`` keeps only edges touching a
104
+ repo whose slug contains that substring. ``min_confidence`` (default 0.5)
105
+ suppresses low-confidence endpoint consumer rows before graph construction —
106
+ only endpoint consumers are filtered; all providers and non-endpoint consumers
107
+ pass through unconditionally.
108
+
109
+ ``min_precision`` (docs/EDGE_PRECISION_TIERS.md) defaults to ``"heuristic"``
110
+ — every edge this function has always produced, unchanged. Pass
111
+ ``"precise"`` plus ``repo_symbol_rows`` (a full ``all_repo_symbols`` dump)
112
+ to keep only edges also covered by a Stage-2 canonical-symbol join;
113
+ ``"fuzzy"`` is currently identical to ``"heuristic"`` (no fuzzy tier
114
+ exists yet). The special ``service`` "repo depends on what it deploys"
115
+ edge is unaffected by ``min_precision`` — it isn't a symbol-joined
116
+ consumer/provider relationship. Raises ``ValueError`` for any other
117
+ value, matching ``edges.cross_repo_edges``.
118
+ """
119
+ from . import edges as edges_module
120
+
121
+ if min_precision not in edges_module.PRECISION_TIERS:
122
+ raise ValueError(
123
+ f"min_precision must be one of {edges_module.PRECISION_TIERS!r}"
124
+ )
125
+
126
+ filtered = cross_repo_edges(rows, kind=kind, min_confidence=min_confidence)
127
+
128
+ require_precise = min_precision == "precise"
129
+ precise_pairs = None
130
+ if require_precise:
131
+ precise_pairs = edges_module.precise_pairs(repo_symbol_rows or [])
132
+
133
+ groups: dict[tuple[str, str], dict] = defaultdict(
134
+ lambda: {"providers": set(), "consumers": {}}
135
+ )
136
+ # consumers maps repo -> confidence for the best (highest) confidence row
137
+ # for each (kind, key_norm, consumer_repo) triple.
138
+ consumer_conf: dict[tuple[str, str, str], float] = {}
139
+
140
+ for b in filtered:
141
+ b_kind = b["kind"]
142
+ key_norm = b["key_norm"]
143
+ b_repo = b["repo"]
144
+ if b["role"] == "provider":
145
+ groups[(b_kind, key_norm)]["providers"].add(b_repo)
146
+ else:
147
+ groups[(b_kind, key_norm)]["consumers"][b_repo] = None # placeholder
148
+ ck = (b_kind, key_norm, b_repo)
149
+ raw_conf = b.get("confidence")
150
+ conf = float(raw_conf if raw_conf is not None else 1.0)
151
+ if ck not in consumer_conf or conf > consumer_conf[ck]:
152
+ consumer_conf[ck] = conf
153
+
154
+ graph = DepGraph()
155
+ graph.contracts = {k: dict(v) for k, v in groups.items()}
156
+ for (b_kind, key_norm), g in groups.items():
157
+ for cons in g["consumers"]:
158
+ graph.repos.add(cons)
159
+ for prov in g["providers"]:
160
+ graph.repos.add(prov)
161
+
162
+ if b_kind == "service" and key_norm.startswith("repo:"):
163
+ # The deploying repo (provider) depends on the repo it deploys.
164
+ target = key_norm[len("repo:") :]
165
+ for src in g["providers"]:
166
+ if src != target:
167
+ graph.edge(src, target).add(b_kind, short_repo(target))
168
+ continue
169
+ if b_kind == "service":
170
+ continue # image:/name: anchors aren't repo-to-repo edges
171
+ # consumer depends on provider; skip when the consumer repo itself also
172
+ # provides this key_norm (the repo self-serves its own route, so the
173
+ # path-key collision with a foreign provider must not generate an edge).
174
+ self_providing = g["providers"]
175
+ for cons in g["consumers"]:
176
+ if cons in self_providing:
177
+ continue
178
+ for prov in g["providers"]:
179
+ if cons == prov:
180
+ continue
181
+ if (
182
+ require_precise
183
+ and (cons, prov, b_kind, key_norm) not in precise_pairs
184
+ ):
185
+ continue
186
+ conf = consumer_conf.get((b_kind, key_norm, cons), 1.0)
187
+ graph.edge(cons, prov).add(b_kind, key_norm, conf)
188
+
189
+ if repo:
190
+ graph.edges = {
191
+ k: e for k, e in graph.edges.items() if repo in e.src or repo in e.dst
192
+ }
193
+ graph.repos = {r for e in graph.edges.values() for r in (e.src, e.dst)} or {
194
+ r for r in graph.repos if repo in r
195
+ }
196
+ return graph
197
+
198
+
199
+ # ── Rich terminal summary ─────────────────────────────────────────
200
+
201
+
202
+ def render_rich(graph: DepGraph, console=None) -> None:
203
+ from rich.console import Console
204
+ from rich.table import Table
205
+
206
+ console = console or Console()
207
+
208
+ if not graph.edges:
209
+ console.print(
210
+ "[yellow]No cross-repo dependencies found.[/] "
211
+ "Index at least two related repos first."
212
+ )
213
+ return
214
+
215
+ kind_totals: dict[str, int] = defaultdict(int)
216
+ for e in graph.edges.values():
217
+ for k, n in e.kinds.items():
218
+ kind_totals[k] += n
219
+
220
+ legend = " ".join(
221
+ f"[{_rich_color(k)}]●[/] {k} ({kind_totals[k]})"
222
+ for k in KIND_COLORS
223
+ if kind_totals.get(k)
224
+ )
225
+ console.print(
226
+ f"\n[bold]Cross-repo dependencies[/] "
227
+ f"{len(graph.repos)} repos · {len(graph.edges)} links\n{legend}\n"
228
+ )
229
+
230
+ table = Table(show_lines=False, header_style="bold")
231
+ table.add_column("depends on →", style="cyan", overflow="fold", no_wrap=False)
232
+ table.add_column("provider", style="green", overflow="fold", no_wrap=False)
233
+ table.add_column("links", justify="right", no_wrap=True)
234
+ table.add_column("by kind", overflow="fold", no_wrap=False)
235
+ for e in sorted(graph.edges.values(), key=lambda e: e.weight, reverse=True):
236
+ kinds = " ".join(
237
+ f"[{_rich_color(k)}]{k}:{n}[/]" for k, n in sorted(e.kinds.items())
238
+ )
239
+ table.add_row(short_repo(e.src), short_repo(e.dst), str(e.weight), kinds)
240
+ console.print(table)
241
+
242
+
243
+ def _rich_color(kind: str) -> str:
244
+ return {
245
+ "env_var": "yellow",
246
+ "endpoint": "blue",
247
+ "package": "green",
248
+ "service": "magenta",
249
+ }.get(kind, "white")
250
+
251
+
252
+ # ── Self-contained interactive HTML ───────────────────────────────
253
+
254
+
255
+ def render_html(graph: DepGraph, path: Path) -> Path:
256
+ """Write an interactive force-directed graph (vis-network) to ``path``."""
257
+ nodes = [
258
+ {"id": r, "label": short_repo(r), "shape": "box"} for r in sorted(graph.repos)
259
+ ]
260
+ edges = []
261
+ for i, e in enumerate(graph.edges.values()):
262
+ dominant = max(e.kinds, key=e.kinds.get)
263
+ title = ", ".join(f"{k}: {n}" for k, n in sorted(e.kinds.items()))
264
+ edges.append(
265
+ {
266
+ "id": i,
267
+ "from": e.src,
268
+ "to": e.dst,
269
+ "value": e.weight,
270
+ "label": f"{short_repo(e.src)} → {short_repo(e.dst)}",
271
+ "title": f"{short_repo(e.src)} → {short_repo(e.dst)} ({title}) — click for details",
272
+ "color": {"color": KIND_COLORS.get(dominant, "#888")},
273
+ "arrows": "to",
274
+ "contracts": sorted(e.contracts, key=lambda c: (c["kind"], c["key"])),
275
+ }
276
+ )
277
+ legend = "".join(
278
+ f'<span class="chip" style="background:{c}"></span>{k} '
279
+ for k, c in KIND_COLORS.items()
280
+ )
281
+ html = _HTML_TEMPLATE.format(
282
+ nodes=json.dumps(nodes),
283
+ edges=json.dumps(edges),
284
+ kind_colors=json.dumps(KIND_COLORS),
285
+ legend=legend,
286
+ n_repos=len(graph.repos),
287
+ n_edges=len(graph.edges),
288
+ )
289
+ path = path.expanduser()
290
+ path.write_text(html)
291
+ return path
292
+
293
+
294
+ _HTML_TEMPLATE = """<!doctype html>
295
+ <html><head><meta charset="utf-8"><title>Cross-repo dependencies</title>
296
+ <script src="https://unpkg.com/vis-network@9/standalone/umd/vis-network.min.js"></script>
297
+ <style>
298
+ body {{ margin: 0; font: 14px system-ui, sans-serif; background: #1b1d23; color: #ddd; }}
299
+ #bar {{ padding: 10px 16px; border-bottom: 1px solid #333; }}
300
+ #net {{ width: 100vw; height: calc(100vh - 50px); }}
301
+ .chip {{ display:inline-block; width:11px; height:11px; border-radius:2px; margin:0 4px 0 12px; vertical-align:middle; }}
302
+ #detail {{ position: fixed; top: 60px; right: 16px; width: 420px; max-height: calc(100vh - 80px);
303
+ overflow: auto; background: #23262f; border: 1px solid #3a3f4b; border-radius: 8px;
304
+ padding: 12px 14px; display: none; box-shadow: 0 6px 24px rgba(0,0,0,.4); }}
305
+ #detail h3 {{ margin: 0 0 8px; font-size: 14px; }}
306
+ #detail .close {{ float: right; cursor: pointer; color: #888; }}
307
+ #detail table {{ border-collapse: collapse; width: 100%; }}
308
+ #detail th, #detail td {{ text-align: left; padding: 4px 8px; border-bottom: 1px solid #333;
309
+ font-size: 13px; word-break: break-all; }}
310
+ #detail .kind {{ white-space: nowrap; font-weight: 600; }}
311
+ </style></head>
312
+ <body>
313
+ <div id="bar"><b>Cross-repo dependencies</b> — {n_repos} repos · {n_edges} links
314
+ &nbsp;&nbsp; "A → B" = A depends on B &nbsp;&nbsp; {legend}
315
+ &nbsp;&nbsp; <span style="color:#888">click an edge for the linkage list</span></div>
316
+ <div id="net"></div>
317
+ <div id="detail"></div>
318
+ <script>
319
+ const KIND_COLORS = {kind_colors};
320
+ const nodes = new vis.DataSet({nodes});
321
+ const edges = new vis.DataSet({edges});
322
+ const network = new vis.Network(document.getElementById("net"), {{nodes, edges}}, {{
323
+ nodes: {{ color: {{ background: "#2b2f3a", border: "#5a6", highlight: {{ background: "#39415a" }} }},
324
+ font: {{ color: "#eee" }}, borderWidth: 1 }},
325
+ edges: {{ scaling: {{ min: 1, max: 8 }}, smooth: {{ type: "dynamic" }},
326
+ font: {{ color: "#aaa", size: 11, strokeWidth: 0, align: "top" }} }},
327
+ physics: {{ stabilization: true, barnesHut: {{ springLength: 160 }} }},
328
+ interaction: {{ hover: true, tooltipDelay: 100 }}
329
+ }});
330
+
331
+ const panel = document.getElementById("detail");
332
+ function esc(s) {{ return String(s).replace(/[&<>]/g, c => ({{'&':'&amp;','<':'&lt;','>':'&gt;'}}[c])); }}
333
+ function showEdge(id) {{
334
+ const e = edges.get(id);
335
+ if (!e) return;
336
+ const rows = e.contracts.map(c =>
337
+ `<tr><td class="kind" style="color:${{KIND_COLORS[c.kind] || '#ccc'}}">${{esc(c.kind)}}</td>`
338
+ + `<td>${{esc(c.key)}}</td></tr>`).join("");
339
+ panel.innerHTML = `<span class="close" onclick="document.getElementById('detail').style.display='none'">✕</span>`
340
+ + `<h3>${{esc(e.label)}}</h3>`
341
+ + `<table><thead><tr><th>kind</th><th>contract</th></tr></thead><tbody>${{rows}}</tbody></table>`;
342
+ panel.style.display = "block";
343
+ }}
344
+ network.on("click", p => p.edges.length ? showEdge(p.edges[0]) : (panel.style.display = "none"));
345
+ </script>
346
+ </body></html>
347
+ """