Coverage for src/secchi/renderers/reports.py: 88%
124 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 23:28 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 23:28 +0000
1"""Portable JSON, Markdown, and HTML package intelligence reports."""
3from __future__ import annotations
5from dataclasses import dataclass, field
6from datetime import UTC, datetime
7from html import escape
8from pathlib import Path
10from secchi.errors import ReportError
11from secchi.export import export_package_json
12from secchi.models import DerivedPackageData, PackageInfo, PackageRef, Project
13from secchi.renderers.summary import render_summary
14from secchi.schema import PROJECT_EXPORT_SCHEMA_VERSION
15from secchi.schemas import ProjectExport
17SECCHI_REPOSITORY_URL = "https://github.com/kannandreams/secchi"
20@dataclass
21class ProjectSourceReport:
22 ref: PackageRef
23 info: PackageInfo | None
24 derived: DerivedPackageData | None
25 error: str | None = None
26 warnings: list[dict[str, str]] = field(default_factory=list)
29@dataclass
30class ProjectReport:
31 project: Project
32 sources: list[ProjectSourceReport]
33 generated_at: datetime
36def build_project_report(
37 project: Project,
38 results: dict[str, object],
39) -> ProjectReport:
40 """Build a project report from already-fetched intelligence results."""
41 from secchi.aggregate import package_key
42 from secchi.services.intelligence import IntelligenceResult
44 sources: list[ProjectSourceReport] = []
45 for ref in project.packages:
46 result = results.get(package_key(ref))
47 if not isinstance(result, IntelligenceResult):
48 sources.append(ProjectSourceReport(ref, None, None, "No result returned."))
49 continue
50 error = result.error.message if result.error else None
51 warnings = [
52 {"source": warning.source, "message": warning.message}
53 for warning in result.warnings
54 ]
55 sources.append(
56 ProjectSourceReport(ref, result.info, result.derived, error, warnings)
57 )
58 return ProjectReport(
59 project=project,
60 sources=sources,
61 generated_at=datetime.now(UTC),
62 )
65def render_report(
66 format_name: str,
67 info: PackageInfo,
68 derived: DerivedPackageData,
69 ref: PackageRef,
70 project_name: str,
71 warnings: list[object] | None = None,
72) -> str:
73 if format_name == "json":
74 return export_package_json(info, derived, ref, project_name, warnings)
75 if format_name == "md":
76 return render_markdown(info, derived, ref, warnings)
77 if format_name == "html":
78 return render_html(info, derived, ref, warnings)
79 raise ReportError(f"Unsupported report format: {format_name}")
82def render_markdown(
83 info: PackageInfo,
84 derived: DerivedPackageData,
85 ref: PackageRef,
86 warnings: list[object] | None = None,
87) -> str:
88 change = derived.downloads_30d_pct_change
89 adoption = (
90 "No baseline available"
91 if change is None
92 else f"{change:+.1f}% vs previous 30 days"
93 )
94 rows = "\n".join(
95 f"| {score.label} | {score.score} / {score.max_score} |"
96 for score in derived.health_score.sub_scores
97 )
98 return f"""# {info.name}
100Registry: `{ref.registry.value}`
102{info.description or "No package description available."}
104## Overview
106| Signal | Value |
107| --- | --- |
108| Health score | {derived.health_score.total} / 100 ({derived.health_score.grade}) |
109| Latest version | {info.latest_version or "—"} |
110| Downloads (30d) | {derived.downloads_30d_total:,} |
111| Adoption change | {adoption} |
112| GitHub stars | {info.github_stats.stars:,} |
113| Reverse dependencies | {info.reverse_dependency_count if info.reverse_dependency_count is not None else "—"} |
114| Security advisories | {len(info.security_advisories)} affecting latest version |
116{_markdown_advisories(info)}
118## Health breakdown
120| Category | Score |
121| --- | --- |
122{rows}
124{_markdown_warnings(warnings)}
125{_markdown_attribution(info.repository_url)}
126"""
129def render_html(
130 info: PackageInfo,
131 derived: DerivedPackageData,
132 ref: PackageRef,
133 warnings: list[object] | None = None,
134) -> str:
135 rows = "".join(
136 f"<tr><td>{escape(score.label)}</td><td>{score.score} / {score.max_score}</td></tr>"
137 for score in derived.health_score.sub_scores
138 )
139 return f"""<!doctype html>
140<html lang=\"en\"><head><meta charset=\"utf-8\"><title>Secchi report: {escape(info.name)}</title>
141<style>body{{font:16px system-ui,sans-serif;max-width:900px;margin:3rem auto;padding:0 1rem;line-height:1.5;color:#18212f}}table{{border-collapse:collapse;width:100%;max-width:720px}}th,td{{border:1px solid #d7dde7;padding:.55rem;text-align:left}}th{{background:#eef2f7}}</style>
142</head><body><h1>{escape(info.name)}</h1><p>{escape(info.description or "No package description available.")}</p>
143<p>Registry: <code>{escape(ref.registry.value)}</code></p><h2>Overview</h2>
144<table><tr><th>Signal</th><th>Value</th></tr>
145<tr><td>Health score</td><td>{derived.health_score.total} / 100 ({escape(derived.health_score.grade)})</td></tr>
146<tr><td>Latest version</td><td>{escape(info.latest_version or "—")}</td></tr>
147<tr><td>Downloads (30d)</td><td>{derived.downloads_30d_total:,}</td></tr>
148<tr><td>GitHub stars</td><td>{info.github_stats.stars:,}</td></tr></table>
149{_html_advisories(info)}
150<h2>Health breakdown</h2><table><tr><th>Category</th><th>Score</th></tr>{rows}</table>
151{_html_warnings(warnings)}
152{_html_attribution(info.repository_url)}
153</body></html>
154"""
157def render_terminal_report(info: PackageInfo, derived: DerivedPackageData) -> str:
158 """Kept for callers that need a report-like terminal representation."""
159 return render_summary(info, derived)
162def render_project_report(format_name: str, report: ProjectReport) -> str:
163 if format_name == "json":
164 return ProjectExport.model_validate(
165 _project_report_data(report)
166 ).model_dump_json(indent=2, by_alias=True)
167 if format_name == "md":
168 return _project_markdown(report)
169 if format_name == "html":
170 return _project_html(report)
171 raise ReportError(f"Unsupported report format: {format_name}")
174def default_report_path(
175 subject: str,
176 format_name: str,
177 *,
178 project: bool = False,
179 directory: Path | None = None,
180) -> Path:
181 safe = subject.replace("/", "_").replace(" ", "_")
182 suffix = "project" if project else "package"
183 date = datetime.now(UTC).strftime("%Y-%m-%d")
184 extension = "md" if format_name == "markdown" else format_name
185 return (directory or Path.cwd()) / f"secchi-{safe}-{suffix}-{date}.{extension}"
188def _project_report_data(report: ProjectReport) -> dict:
189 available = [source for source in report.sources if source.info and source.derived]
190 health_scores = [
191 source.derived.health_score.total for source in available if source.derived
192 ]
193 downloads = sum(
194 source.derived.downloads_30d_total for source in available if source.derived
195 )
196 return {
197 "schema_version": PROJECT_EXPORT_SCHEMA_VERSION,
198 "schema": "secchi.project-intelligence",
199 "generated_by": "Secchi",
200 "project": {
201 "name": report.project.name,
202 "title": report.project.title or report.project.name,
203 "description": report.project.description,
204 "favorite": report.project.favorite,
205 "repository": report.project.repository_url,
206 },
207 "generated_at": report.generated_at.isoformat(),
208 "summary": {
209 "health_score": round(sum(health_scores) / len(health_scores))
210 if health_scores
211 else None,
212 "downloads_30d": downloads,
213 "source_count": len(report.sources),
214 "healthy_source_count": len(available),
215 },
216 "sources": [
217 {
218 "package": source.ref.name,
219 "registry": source.ref.registry.value,
220 "latest_version": source.info.latest_version if source.info else None,
221 "health_score": source.derived.health_score.total
222 if source.derived
223 else None,
224 "downloads_30d": source.derived.downloads_30d_total
225 if source.derived
226 else None,
227 "security_advisories": len(source.info.security_advisories)
228 if source.info
229 else None,
230 "error": source.error,
231 "warnings": source.warnings,
232 }
233 for source in report.sources
234 ],
235 }
238def _project_markdown(report: ProjectReport) -> str:
239 data = _project_report_data(report)
240 project = data["project"]
241 summary = data["summary"]
242 rows = "\n".join(_source_markdown_row(source) for source in data["sources"])
243 return f"""# {project["title"]}
245{project["description"] or "No project description available."}
247Repository: {project["repository"] or "—"}
249## Project summary
251| Signal | Value |
252| --- | --- |
253| Health score | {summary["health_score"] if summary["health_score"] is not None else "—"} / 100 |
254| Downloads (30d) | {summary["downloads_30d"]:,} |
255| Healthy sources | {summary["healthy_source_count"]} / {summary["source_count"]} |
257## Package sources
259| Package | Registry | Latest version | Health | Downloads (30d) | Advisories | Status |
260| --- | --- | --- | ---: | ---: | ---: | --- |
261{rows}
263{_markdown_attribution(project["repository"])}
264"""
267def _source_markdown_row(source: dict) -> str:
268 downloads = (
269 f"{source['downloads_30d']:,}" if source["downloads_30d"] is not None else "—"
270 )
271 return (
272 f"| {source['package']} | {source['registry']} | {source['latest_version'] or '—'} | "
273 f"{source['health_score'] if source['health_score'] is not None else '—'} | "
274 f"{downloads} | {source['security_advisories'] if source['security_advisories'] is not None else '—'} | {_source_status(source)} |"
275 )
278def _project_html(report: ProjectReport) -> str:
279 data = _project_report_data(report)
280 project = data["project"]
281 summary = data["summary"]
282 rows = "".join(
283 "<tr>"
284 f"<td>{escape(source['package'])}</td>"
285 f"<td>{escape(source['registry'])}</td>"
286 f"<td>{escape(str(source['latest_version'] or '—'))}</td>"
287 f"<td>{escape(str(source['health_score'] if source['health_score'] is not None else '—'))}</td>"
288 f"<td>{source['downloads_30d'] if source['downloads_30d'] is not None else '—'}</td>"
289 f"<td>{source['security_advisories'] if source['security_advisories'] is not None else '—'}</td>"
290 f"<td>{escape(_source_status(source))}</td></tr>"
291 for source in data["sources"]
292 )
293 return f"""<!doctype html>
294<html lang="en"><head><meta charset="utf-8"><title>Secchi project report: {escape(project["title"])}</title>
295<style>body{{font:16px system-ui,sans-serif;max-width:1000px;margin:3rem auto;padding:0 1rem;line-height:1.5;color:#18212f}}table{{border-collapse:collapse;width:100%}}th,td{{border:1px solid #d7dde7;padding:.55rem;text-align:left}}th{{background:#eef2f7}}.summary{{display:flex;gap:2rem}}.metric{{padding:1rem;background:#f5f7fa;border-radius:.4rem}}</style>
296</head><body><h1>{escape(project["title"])}</h1><p>{escape(project["description"] or "No project description available.")}</p>
297<p>Repository: {escape(project["repository"] or "—")}</p>
298<div class="summary"><div class="metric"><strong>Health</strong><br>{summary["health_score"] if summary["health_score"] is not None else "—"} / 100</div><div class="metric"><strong>Downloads (30d)</strong><br>{summary["downloads_30d"]:,}</div><div class="metric"><strong>Healthy sources</strong><br>{summary["healthy_source_count"]} / {summary["source_count"]}</div></div>
299<h2>Package sources</h2><table><thead><tr><th>Package</th><th>Registry</th><th>Latest</th><th>Health</th><th>Downloads (30d)</th><th>Advisories</th><th>Status</th></tr></thead><tbody>{rows}</tbody></table>
300{_html_attribution(project["repository"])}
301</body></html>
302"""
305def _markdown_attribution(repository_url: str | None) -> str:
306 star = f" · [⭐ Star the project]({repository_url})" if repository_url else ""
307 return f"Generated by [Secchi]({SECCHI_REPOSITORY_URL}){star}"
310def _markdown_advisories(info: PackageInfo) -> str:
311 if not info.security_advisories:
312 return (
313 "## Security advisories\n\nNo known advisories affect the latest version.\n"
314 )
315 rows = []
316 for advisory in info.security_advisories:
317 fixed = ", ".join(advisory.fixed_versions) or "No fixed version listed"
318 rows.append(
319 f"- [{advisory.id}]({advisory.url}) — "
320 f"{advisory.severity or 'Severity unavailable'}; fixed: {fixed}; "
321 f"{advisory.summary or 'No summary available.'}"
322 )
323 return "## Security advisories\n\n" + "\n".join(rows) + "\n"
326def _html_advisories(info: PackageInfo) -> str:
327 if not info.security_advisories:
328 return "<h2>Security advisories</h2><p>No known advisories affect the latest version.</p>"
329 rows = "".join(
330 "<tr>"
331 f'<td><a href="{escape(advisory.url, quote=True)}">{escape(advisory.id)}</a></td>'
332 f"<td>{escape(advisory.severity or '—')}</td>"
333 f"<td>{escape(', '.join(advisory.fixed_versions) or '—')}</td>"
334 f"<td>{escape(advisory.summary or '—')}</td>"
335 "</tr>"
336 for advisory in info.security_advisories
337 )
338 return (
339 "<h2>Security advisories</h2>"
340 "<table><tr><th>ID</th><th>Severity</th><th>Fixed version</th><th>Summary</th></tr>"
341 f"{rows}</table>"
342 )
345def _source_status(source: dict) -> str:
346 if source["error"]:
347 return source["error"]
348 warnings = source.get("warnings", [])
349 return f"{len(warnings)} signal warning(s)" if warnings else "Healthy"
352def _markdown_warnings(warnings: list[object] | None) -> str:
353 if not warnings:
354 return ""
355 rows = "\n".join(f"- `{warning.source}`: {warning.message}" for warning in warnings)
356 return f"## Signal warnings\n\n{rows}\n"
359def _html_warnings(warnings: list[object] | None) -> str:
360 if not warnings:
361 return ""
362 rows = "".join(
363 f"<li><code>{escape(warning.source)}</code>: {escape(warning.message)}</li>"
364 for warning in warnings
365 )
366 return f"<h2>Signal warnings</h2><ul>{rows}</ul>"
369def _html_attribution(repository_url: str | None) -> str:
370 star = (
371 f' · <a href="{escape(repository_url, quote=True)}">⭐ Star the project</a>'
372 if repository_url
373 else ""
374 )
375 secchi_link = f'<a href="{escape(SECCHI_REPOSITORY_URL, quote=True)}">Secchi</a>'
376 return f'<footer style="margin-top:2rem;color:#5f6b7a">Generated by {secchi_link}{star}</footer>'