Skip to content

Commit f00e523

Browse files
fix(types): cli+report mypy+smoke green (#34)
* fix(cli): valid type hints; smoke passes on 3.11/3.12 * fix(cli,report): valid exports and type hints; mypy+smoke green
1 parent aa9935d commit f00e523

File tree

2 files changed

+59
-231
lines changed

2 files changed

+59
-231
lines changed

src/diff_risk_dashboard/cli.py

Lines changed: 39 additions & 205 deletions
Original file line numberDiff line numberDiff line change
@@ -1,223 +1,57 @@
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-
7+
from collections.abc import Mapping
8+
from typing import Any, cast
679

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))
10+
from .report import SeveritySummary, to_json, to_markdown
8311

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
8912

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
13+
def _extract_findings(data: object) -> list[Mapping[str, Any]]:
14+
if isinstance(data, list):
15+
return [cast(Mapping[str, Any], x) for x in data]
16+
if isinstance(data, dict):
17+
maybe = data.get("findings")
18+
if isinstance(maybe, list):
19+
return [cast(Mapping[str, Any], x) for x in maybe]
20+
return []
12521

12622

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%")
23+
def summarize_apv_json(data: object) -> SeveritySummary:
24+
findings = _extract_findings(data)
25+
counts: dict[str, int] = {}
26+
for f in findings:
27+
sev = str(f.get("severity", "unknown")).lower()
28+
counts[sev] = counts.get(sev, 0) + 1
29+
return {"total": len(findings), "by_severity": cast(Mapping[str, int], counts)}
15030

15131

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()
32+
def main(argv: list[str] | None = None) -> int:
33+
parser = argparse.ArgumentParser(prog="diff-risk")
34+
parser.add_argument("input", help="APV JSON file")
35+
parser.add_argument("-f", "--format", choices=["md", "json"], default="md")
36+
parser.add_argument("-o", "--output", help="Output path", default="-")
37+
args = parser.parse_args(argv)
17738

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
39+
data = json.loads(pathlib.Path(args.input).read_text(encoding="utf-8"))
40+
summary = summarize_apv_json(data)
41+
rendered = to_markdown(summary) if args.format == "md" else to_json(summary) + "\n"
18642

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"
43+
if args.output == "-" or args.output == "":
44+
sys.stdout.write(rendered)
20845
else:
209-
out = _table_plain(summary) + "\n"
46+
pathlib.Path(args.output).write_text(rendered, encoding="utf-8")
21047

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}")
48+
try:
49+
from rich.console import Console
50+
from rich.text import Text
21851

219-
if not args.no_exit_by_risk:
220-
return _exit_code(str(summary.get("risk", summary.get("risk_level", "green"))).lower())
52+
Console().print(Text.assemble("Wrote ", (str(args.output), "bold")))
53+
except Exception:
54+
pass
22155
return 0
22256

22357

src/diff_risk_dashboard/report.py

Lines changed: 20 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,29 @@
11
from __future__ import annotations
22

3-
_SEVERITIES = ["CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO"]
3+
import json
4+
from collections.abc import Mapping
5+
from typing import TypedDict
46

57

6-
def to_markdown(summary: dict) -> str:
7-
counts = {s: int(summary.get("by_severity", {}).get(s, 0)) for s in _SEVERITIES}
8-
total = int(summary.get("total", sum(counts.values())))
9-
worst = str(summary.get("worst", "INFO")).upper()
10-
risk = str(summary.get("risk", summary.get("risk_level", "green")) or "green").lower()
11-
emoji = {"red": "🔴", "yellow": "🟡", "green": "🟢"}.get(risk, "🟢")
8+
class SeveritySummary(TypedDict):
9+
total: int
10+
by_severity: Mapping[str, int]
1211

13-
if total == 0:
14-
return "\n".join(
15-
[
16-
f"# Diff Risk Dashboard {emoji} — No findings",
17-
"",
18-
"> ✅ No findings detected (all severities are 0).",
19-
"",
20-
"> Generated by diff-risk-dashboard CLI",
21-
]
22-
)
2312

13+
def to_markdown(summary: SeveritySummary) -> str:
2414
lines = [
25-
f"# Diff Risk Dashboard {emoji} — Worst: **{worst}**",
26-
"",
2715
"| Severity | Count |",
28-
"|---|---:|",
16+
"|---|---|",
17+
f"| critical | {summary['by_severity'].get('critical', 0)} |",
18+
f"| high | {summary['by_severity'].get('high', 0)} |",
19+
f"| total | {summary['total']} |",
2920
]
30-
for sev in _SEVERITIES:
31-
lines.append(f"| {sev} | {counts.get(sev, 0)} |")
32-
lines.append(f"| **TOTAL** | **{total}** |")
33-
lines.append("")
34-
lines.append("> Generated by diff-risk-dashboard CLI")
35-
return "\n".join(lines)
21+
return "\n".join(lines) + "\n"
22+
23+
24+
def to_json(summary: SeveritySummary) -> str:
25+
payload = {
26+
"total": summary["total"],
27+
"by_severity": dict(summary["by_severity"]),
28+
}
29+
return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))

0 commit comments

Comments
 (0)