Skip to content

Commit a132c4b

Browse files
feat(cli): colored Rich table, proportional bars & --format bar; better TTY/redirect UX (#16)
1 parent 2295553 commit a132c4b

File tree

1 file changed

+189
-25
lines changed

1 file changed

+189
-25
lines changed

src/diff_risk_dashboard/cli.py

Lines changed: 189 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,61 +1,225 @@
1-
from __future__ import annotations
2-
31
import argparse
42
import json
53
import sys
6-
from collections.abc import Mapping
74
from pathlib import Path
85
from typing import Any
96

10-
from .core import summarize_apv_json
7+
from rich.box import ROUNDED
8+
from rich.console import Console
9+
from rich.table import Table
10+
from rich.text import Text
11+
1112
from .report import to_markdown
1213

14+
_SEVERITIES = ["CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO"]
15+
16+
17+
def _risk_emoji(risk: str) -> str:
18+
return {"red": "🔴", "yellow": "🟡", "green": "🟢"}.get(risk, "🟢")
19+
1320

1421
def _exit_code(risk: str) -> int:
1522
return {"green": 0, "yellow": 1, "red": 2}.get(risk, 0)
1623

1724

18-
def _print_table(summary: Mapping[str, Any]) -> None:
19-
by = summary.get("by_severity", {}) or {}
20-
print("Severity\tCount")
21-
for sev in ["CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO"]:
22-
print(f"{sev}\t{by.get(sev, by.get(sev.lower(), 0))}")
23-
print(f"TOTAL\t{summary.get('total', 0)}")
25+
def _summarize(apv: dict) -> dict:
26+
counts: dict[str, int] = {}
27+
for k, v in (apv.get("by_severity") or {}).items():
28+
counts[str(k).upper()] = int(v or 0)
29+
total = sum(counts.get(s, 0) for s in _SEVERITIES)
30+
worst = next((s for s in _SEVERITIES if counts.get(s, 0) > 0), "INFO")
31+
risk = str(apv.get("risk_level") or apv.get("risk") or "green").lower()
32+
return {"total": total, "by_severity": counts, "worst": worst, "risk_level": risk}
33+
34+
35+
def _table_plain(summary: dict[str, Any]) -> str:
36+
counts: dict[str, int] = {s: int(summary["by_severity"].get(s, 0)) for s in _SEVERITIES}
37+
total = int(summary["total"])
38+
w_sev = max(len("Severity"), max(len(s) for s in _SEVERITIES))
39+
w_cnt = max(len("Count"), len(str(total)))
40+
header = f'{"Severity".ljust(w_sev)} {"Count".rjust(w_cnt)} {"Share":>5}'
41+
sep = f'{"-"*w_sev} {"-"*w_cnt} {"-"*5}'
42+
lines = [header, sep]
43+
for s in _SEVERITIES:
44+
n = counts.get(s, 0)
45+
pct = f"{(n/total*100):.0f}%" if total else "0%"
46+
lines.append(f"{s.ljust(w_sev)} {str(n).rjust(w_cnt)} {pct:>5}")
47+
lines.append(
48+
f'{"TOTAL".ljust(w_sev)} {str(total).rjust(w_cnt)} ' f'{"100%" if total else "0%":>5}'
49+
)
50+
return "\n".join(lines)
51+
52+
53+
def _bar_plain(summary: dict[str, Any], width: int = 80) -> str:
54+
counts: dict[str, int] = {s: int(summary["by_severity"].get(s, 0)) for s in _SEVERITIES}
55+
total = int(summary["total"])
56+
maxc = max(counts.values()) if counts else 0
57+
bar_w = max(10, min(40, width - 24))
58+
lines = []
59+
for s in _SEVERITIES:
60+
n = counts.get(s, 0)
61+
w = 0 if maxc == 0 or n == 0 else max(1, round(n / maxc * bar_w))
62+
pct = f"{(n/total*100):.0f}%" if total else "0%"
63+
lines.append(f"{s:<8} {str(n).rjust(4)} {pct:>4} " + ("█" * w))
64+
lines.append(f'{"TOTAL":<8} {str(total).rjust(4)} 100%')
65+
return "\n".join(lines)
66+
67+
68+
def _table_rich(summary: dict[str, Any], width: int) -> Table:
69+
counts: dict[str, int] = {s: int(summary["by_severity"].get(s, 0)) for s in _SEVERITIES}
70+
total = int(summary["total"])
71+
worst = str(summary.get("worst", "UNKNOWN")).upper()
72+
risk = str(summary.get("risk", summary.get("risk_level", "green")) or "green").lower()
73+
emoji = _risk_emoji(risk)
74+
colors = {
75+
"CRITICAL": "bold bright_red",
76+
"HIGH": "red3",
77+
"MEDIUM": "yellow3",
78+
"LOW": "green3",
79+
"INFO": "cyan3",
80+
}
81+
maxc = max(counts.values()) if counts else 0
82+
bar_w = max(10, min(32, width - 42))
83+
84+
def bar(n: int) -> str:
85+
if maxc == 0 or n == 0:
86+
return ""
87+
w = max(1, round(n / maxc * bar_w))
88+
return "█" * w
89+
90+
title = Text.assemble(
91+
("Diff Risk Dashboard ", "bold"),
92+
(emoji + " ",),
93+
("— Worst: ", "dim"),
94+
(worst, "bold"),
95+
)
96+
table = Table(
97+
title=title,
98+
header_style="bold cyan",
99+
box=ROUNDED,
100+
expand=True,
101+
show_lines=False,
102+
pad_edge=False,
103+
)
104+
table.add_column("Severity", justify="left", no_wrap=True)
105+
table.add_column("Count", justify="right")
106+
table.add_column("Share", justify="right")
107+
table.add_column("Bar", justify="left", no_wrap=True)
108+
for s in _SEVERITIES:
109+
n = counts.get(s, 0)
110+
pct = f"{(n/total*100):.0f}%" if total else "0%"
111+
col = colors.get(s, "")
112+
table.add_row(
113+
f"[{col}]{s}[/]",
114+
f"[{col}]{n}[/]",
115+
f"[{col}]{pct}[/]",
116+
f"[{col}]{bar(n)}[/]",
117+
)
118+
table.add_row(
119+
"[bold]TOTAL[/bold]",
120+
f"[bold]{total}[/bold]",
121+
"[bold]100%[/bold]" if total else "0%",
122+
"",
123+
)
124+
return table
125+
126+
127+
def _bar_rich(summary: dict[str, Any], width: int) -> None:
128+
console = Console()
129+
counts: dict[str, int] = {s: int(summary["by_severity"].get(s, 0)) for s in _SEVERITIES}
130+
total = int(summary["total"])
131+
maxc = max(counts.values()) if counts else 0
132+
bar_w = max(10, min(40, width - 24))
133+
colors = {
134+
"CRITICAL": "bright_red",
135+
"HIGH": "red3",
136+
"MEDIUM": "yellow3",
137+
"LOW": "green3",
138+
"INFO": "cyan3",
139+
}
140+
for s in _SEVERITIES:
141+
n = counts.get(s, 0)
142+
w = 0 if maxc == 0 or n == 0 else max(1, round(n / maxc * bar_w))
143+
pct = f"{(n/total*100):.0f}%" if total else "0%"
144+
console.print(
145+
f"[{colors[s]}]{s:<8}[/] "
146+
f"[{colors[s]}]{n:>4} {pct:>4}[/] "
147+
f"[{colors[s]}]{'█'*w}[/]"
148+
)
149+
console.print(f"[bold]TOTAL[/bold] {total:>4} 100%")
24150

25151

26152
def main() -> int:
27153
p = argparse.ArgumentParser(
28-
prog="diff_risk_dashboard", description="Diff Risk Dashboard (APV JSON -> summary)"
154+
prog="diff_risk_dashboard",
155+
description="Diff Risk Dashboard (APV JSON -> summary)",
29156
)
30157
p.add_argument("input", help="Path o texto JSON de ai-patch-verifier")
31158
p.add_argument(
32-
"-f", "--format", choices=["table", "json", "md"], default="table", help="Formato de salida"
159+
"-f",
160+
"--format",
161+
choices=["table", "json", "md", "bar"],
162+
default="table",
163+
help="Formato de salida",
33164
)
34-
p.add_argument("-o", "--output", default="-", help="Archivo de salida; '-' = stdout")
35165
p.add_argument(
36-
"--no-exit-by-risk", action="store_true", help="No ajustar el exit code por nivel de riesgo"
166+
"-o",
167+
"--output",
168+
default="-",
169+
help="Archivo de salida; '-' = stdout",
170+
)
171+
p.add_argument(
172+
"--no-exit-by-risk",
173+
action="store_true",
174+
help="No ajustar el exit code por nivel de riesgo",
37175
)
38176
args = p.parse_args()
39177

40-
summary: Mapping[str, Any] = summarize_apv_json(args.input)
178+
apv = (
179+
json.loads(Path(args.input).read_text(encoding="utf-8"))
180+
if Path(args.input).exists()
181+
else json.loads(args.input)
182+
)
183+
summary = _summarize(apv)
184+
fmt = args.format.lower()
41185
out: str | None = None
42-
if args.format == "json":
43-
out = json.dumps(summary, indent=2)
44-
elif args.format == "md":
45-
out = to_markdown(summary)
186+
187+
if fmt == "table":
188+
if args.output == "-" and sys.stdout.isatty():
189+
console = Console()
190+
console.print(_table_rich(summary, console.width))
191+
console.print(
192+
Text(
193+
"Tip: usa -f md para reporte Markdown o -f json para máquinas.",
194+
style="dim",
195+
)
196+
)
197+
else:
198+
out = _table_plain(summary) + "\n"
199+
elif fmt == "bar":
200+
if args.output == "-" and sys.stdout.isatty():
201+
_bar_rich(summary, Console().width)
202+
else:
203+
out = _bar_plain(summary) + "\n"
204+
elif fmt == "json":
205+
out = json.dumps(summary, indent=2, ensure_ascii=False) + "\n"
206+
elif fmt == "md":
207+
out = to_markdown(summary) + "\n"
46208
else:
47-
_print_table(summary)
209+
out = _table_plain(summary) + "\n"
48210

49211
if out is not None:
50212
if args.output == "-":
51-
print(out)
213+
sys.stdout.write(out)
52214
else:
215+
Path(args.output).parent.mkdir(parents=True, exist_ok=True)
53216
Path(args.output).write_text(out, encoding="utf-8")
54-
print(f"Wrote {args.output}", file=sys.stderr)
217+
print(f"Wrote {args.output}")
55218

56-
risk = str(summary.get("risk", summary.get("risk_level", "green")))
57-
return 0 if args.no_exit_by_risk else _exit_code(risk)
219+
if not args.no_exit_by_risk:
220+
return _exit_code(str(summary.get("risk", summary.get("risk_level", "green"))).lower())
221+
return 0
58222

59223

60-
if __name__ == "__main__": # pragma: no cover
224+
if __name__ == "__main__":
61225
raise SystemExit(main())

0 commit comments

Comments
 (0)