|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""Print a markdown table of Boost dependency changes between two CI log archives. |
| 3 | +
|
| 4 | +Usage: dep_table.py BASELINE.zip AFTER.zip |
| 5 | +
|
| 6 | +Each archive is a GitHub Actions run-log zip. The `deps` job of the |
| 7 | +dependency-report workflow prints two marker-delimited boostdep reports into |
| 8 | +its log: |
| 9 | +
|
| 10 | + ===DEP-BRIEF-START=== boostdep --brief graph ===DEP-BRIEF-END=== |
| 11 | + ===DEP-PRIMARY-START=== boostdep graph (primary) ===DEP-PRIMARY-END=== |
| 12 | +
|
| 13 | +From those it derives two metrics and prints their delta vs the baseline: |
| 14 | + - the set of transitive Boost modules graph depends on (from --brief: |
| 15 | + Primary + Secondary sections = the full transitive closure), and |
| 16 | + - the header-inclusion weight of each direct dependency (from the primary |
| 17 | + report: how many distinct boost/graph files pull that dependency in). |
| 18 | +Weights are the live signal, they drop toward zero as coupling is removed; |
| 19 | +the module count is the coarse target that only moves when a dep hits zero. |
| 20 | +""" |
| 21 | +import re |
| 22 | +import sys |
| 23 | +import zipfile |
| 24 | + |
| 25 | +BRIEF = ("===DEP-BRIEF-START===", "===DEP-BRIEF-END===") |
| 26 | +PRIMARY = ("===DEP-PRIMARY-START===", "===DEP-PRIMARY-END===") |
| 27 | +# Module names as boostdep prints them, e.g. "numeric~conversion". |
| 28 | +MODULE = re.compile(r"[A-Za-z0-9_.~-]+") |
| 29 | +TIMESTAMP = re.compile(r"^\d{4}-\d\d-\d\dT[\d:.]+Z\s?") # GitHub log line prefix |
| 30 | +ANSI = re.compile(r"\x1b\[[0-9;]*m") |
| 31 | + |
| 32 | + |
| 33 | +def read_clean_lines(zip_path): |
| 34 | + """Top-level per-job logs, with GitHub timestamp and ANSI prefixes stripped.""" |
| 35 | + lines = [] |
| 36 | + with zipfile.ZipFile(zip_path) as z: |
| 37 | + for entry in z.namelist(): |
| 38 | + if "/" in entry or not entry.endswith(".txt"): |
| 39 | + continue |
| 40 | + for ln in z.read(entry).decode("utf-8", "replace").splitlines(): |
| 41 | + lines.append(TIMESTAMP.sub("", ANSI.sub("", ln))) |
| 42 | + return lines |
| 43 | + |
| 44 | + |
| 45 | +def block(lines, markers): |
| 46 | + """Lines strictly between the marker lines, matched exactly. |
| 47 | +
|
| 48 | + Exact matching is what skips the echoed `echo "<marker>"` command lines |
| 49 | + GitHub prepends to a run step, so we capture boostdep's real stdout. |
| 50 | + """ |
| 51 | + start, end = markers |
| 52 | + out, capturing = [], False |
| 53 | + for ln in lines: |
| 54 | + s = ln.strip() |
| 55 | + if s == start: |
| 56 | + capturing = True |
| 57 | + continue |
| 58 | + if s == end and capturing: |
| 59 | + break |
| 60 | + if capturing: |
| 61 | + out.append(ln) |
| 62 | + return out |
| 63 | + |
| 64 | + |
| 65 | +def parse_brief(lines): |
| 66 | + """--brief output -> set of module names (one bare name per line).""" |
| 67 | + mods = set() |
| 68 | + for ln in lines: |
| 69 | + s = ln.strip() |
| 70 | + if not s or s.startswith("#") or s.lower().startswith("brief dependency"): |
| 71 | + continue |
| 72 | + if MODULE.fullmatch(s): |
| 73 | + mods.add(s) |
| 74 | + return mods |
| 75 | + |
| 76 | + |
| 77 | +def parse_weights(lines): |
| 78 | + """Primary report -> {dependency: number of distinct graph files that pull it in}.""" |
| 79 | + weights, cur = {}, None |
| 80 | + for ln in lines: |
| 81 | + head = re.match(r"^([A-Za-z0-9_.~-]+):\s*$", ln) # "module:" at column 0 |
| 82 | + if head: |
| 83 | + cur = head.group(1) |
| 84 | + weights.setdefault(cur, set()) |
| 85 | + continue |
| 86 | + frm = re.match(r"^\s+from\s+(.+?)\s*$", ln) |
| 87 | + if frm and cur is not None: |
| 88 | + weights[cur].add(frm.group(1)) |
| 89 | + return {k: len(v) for k, v in weights.items()} |
| 90 | + |
| 91 | + |
| 92 | +def main(base_zip, pr_zip): |
| 93 | + bl, pl = read_clean_lines(base_zip), read_clean_lines(pr_zip) |
| 94 | + base_mods = parse_brief(block(bl, BRIEF)) |
| 95 | + pr_mods = parse_brief(block(pl, BRIEF)) |
| 96 | + base_w = parse_weights(block(bl, PRIMARY)) |
| 97 | + pr_w = parse_weights(block(pl, PRIMARY)) |
| 98 | + |
| 99 | + # Header-inclusion weights: the live signal. Only rows that changed, most-reduced first. |
| 100 | + print("**Header-inclusion weights** (graph files pulling each direct dependency in):") |
| 101 | + print() |
| 102 | + changed = [] |
| 103 | + for dep in base_w.keys() | pr_w.keys(): |
| 104 | + b, p = base_w.get(dep, 0), pr_w.get(dep, 0) |
| 105 | + if b != p: |
| 106 | + changed.append((p - b, dep, b, p)) |
| 107 | + if changed: |
| 108 | + print("| Dependency | develop | PR | Δ |") |
| 109 | + print("|-----|--------:|---:|----:|") |
| 110 | + for d, dep, b, p in sorted(changed): # reductions (negative delta) first |
| 111 | + print(f"| {dep} | {b} | {p} | {d:+d} |") |
| 112 | + else: |
| 113 | + print("_No header-inclusion-weight changes._") |
| 114 | + print() |
| 115 | + |
| 116 | + # Transitive module set: the coarse target. |
| 117 | + added = sorted(pr_mods - base_mods) |
| 118 | + removed = sorted(base_mods - pr_mods) |
| 119 | + nb, np_ = len(base_mods), len(pr_mods) |
| 120 | + diff = np_ - nb |
| 121 | + print(f"**Transitive Boost modules:** {nb} → {np_} ({f'{diff:+d}' if diff else '0'})") |
| 122 | + if added: |
| 123 | + print(f"- added: {', '.join(added)}") |
| 124 | + if removed: |
| 125 | + print(f"- removed: {', '.join(removed)}") |
| 126 | + |
| 127 | + |
| 128 | +if __name__ == "__main__": |
| 129 | + if len(sys.argv) != 3: |
| 130 | + sys.exit("usage: dep_table.py BASELINE.zip AFTER.zip") |
| 131 | + main(sys.argv[1], sys.argv[2]) |
0 commit comments