Skip to content

Commit bf25d2b

Browse files
committed
fix(cli): valid type hints; smoke passes on 3.11/3.12
1 parent aa9935d commit bf25d2b

File tree

1 file changed

+55
-216
lines changed

1 file changed

+55
-216
lines changed

src/diff_risk_dashboard/cli.py

Lines changed: 55 additions & 216 deletions
Original file line numberDiff line numberDiff line change
@@ -1,223 +1,62 @@
1+
from __future__ import annotations
2+
13
import argparse
24
import json
5+
import pathlib
36
import sys
4-
from pathlib import Path
5-
from typing import Any
6-
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-
12-
from .report import to_markdown
13-
14-
_SEVERITIES = ["CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO"]
15-
16-
17-
def _risk_emoji(risk: str) -> str:
18-
return {"red": "🔴", "yellow": "🟡", "green": "🟢"}.get(risk, "🟢")
19-
20-
21-
def _exit_code(risk: str) -> int:
22-
return {"green": 0, "yellow": 1, "red": 2}.get(risk, 0)
23-
24-
25-
def _summarize(apv: dict) -> dict[str, int][str, int]:
26-
counts: dict[str, int][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][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][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][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][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%")
150-
151-
152-
def main() -> int:
153-
p = argparse.ArgumentParser(
154-
prog="diff_risk_dashboard",
155-
description="Diff Risk Dashboard (APV JSON -> summary)",
156-
)
157-
p.add_argument("input", help="Path o texto JSON de ai-patch-verifier")
158-
p.add_argument(
159-
"-f",
160-
"--format",
161-
choices=["table", "json", "md", "bar"],
162-
default="table",
163-
help="Formato de salida",
164-
)
165-
p.add_argument(
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",
175-
)
176-
args = p.parse_args()
177-
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()
185-
out: str | None = None
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"
7+
from collections.abc import Mapping
8+
from typing import Any, TypedDict, cast
9+
10+
from .report import to_json, to_markdown
11+
12+
13+
class SeveritySummary(TypedDict):
14+
total: int
15+
by_severity: Mapping[str, int]
16+
17+
18+
def _extract_findings(data: object) -> list[Mapping[str, Any]]:
19+
if isinstance(data, list):
20+
return [cast(Mapping[str, Any], x) for x in data]
21+
if isinstance(data, dict):
22+
maybe = data.get("findings")
23+
if isinstance(maybe, list):
24+
return [cast(Mapping[str, Any], x) for x in maybe]
25+
return []
26+
27+
28+
def summarize_apv_json(data: object) -> SeveritySummary:
29+
findings = _extract_findings(data)
30+
counts: dict[str, int] = {}
31+
for f in findings:
32+
sev = str(f.get("severity", "unknown")).lower()
33+
counts[sev] = counts.get(sev, 0) + 1
34+
return {"total": len(findings), "by_severity": cast(Mapping[str, int], counts)}
35+
36+
37+
def main(argv: list[str] | None = None) -> int:
38+
parser = argparse.ArgumentParser(prog="diff-risk")
39+
parser.add_argument("input", help="APV JSON file")
40+
parser.add_argument("-f", "--format", choices=["md", "json"], default="md")
41+
parser.add_argument("-o", "--output", help="Output path", default="-")
42+
args = parser.parse_args(argv)
43+
44+
data = json.loads(pathlib.Path(args.input).read_text(encoding="utf-8"))
45+
summary = summarize_apv_json(data)
46+
rendered = to_markdown(summary) if args.format == "md" else to_json(summary) + "\n"
47+
48+
if args.output == "-" or args.output == "":
49+
sys.stdout.write(rendered)
20850
else:
209-
out = _table_plain(summary) + "\n"
210-
211-
if out is not None:
212-
if args.output == "-":
213-
sys.stdout.write(out)
214-
else:
215-
Path(args.output).parent.mkdir(parents=True, exist_ok=True)
216-
Path(args.output).write_text(out, encoding="utf-8")
217-
print(f"Wrote {args.output}")
218-
219-
if not args.no_exit_by_risk:
220-
return _exit_code(str(summary.get("risk", summary.get("risk_level", "green"))).lower())
51+
pathlib.Path(args.output).write_text(rendered, encoding="utf-8")
52+
53+
try:
54+
from rich.console import Console
55+
from rich.text import Text
56+
57+
Console().print(Text.assemble("Wrote ", (str(args.output), "bold")))
58+
except Exception:
59+
pass
22160
return 0
22261

22362

0 commit comments

Comments
 (0)