Coverage for src/secchi/renderers/reports.py: 86%
111 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-04 22:15 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-04 22:15 +0000
1"""Portable JSON, Markdown, and HTML package intelligence reports."""
3from __future__ import annotations
5from dataclasses import dataclass, field
6from datetime import datetime, timezone
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(timezone.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 "—"} |
115## Health breakdown
117| Category | Score |
118| --- | --- |
119{rows}
121{_markdown_warnings(warnings)}
122{_markdown_attribution(info.repository_url)}
123"""
126def render_html(
127 info: PackageInfo,
128 derived: DerivedPackageData,
129 ref: PackageRef,
130 warnings: list[object] | None = None,
131) -> str:
132 rows = "".join(
133 f"<tr><td>{escape(score.label)}</td><td>{score.score} / {score.max_score}</td></tr>"
134 for score in derived.health_score.sub_scores
135 )
136 return f"""<!doctype html>
137<html lang=\"en\"><head><meta charset=\"utf-8\"><title>Secchi report: {escape(info.name)}</title>
138<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>
139</head><body><h1>{escape(info.name)}</h1><p>{escape(info.description or "No package description available.")}</p>
140<p>Registry: <code>{escape(ref.registry.value)}</code></p><h2>Overview</h2>
141<table><tr><th>Signal</th><th>Value</th></tr>
142<tr><td>Health score</td><td>{derived.health_score.total} / 100 ({escape(derived.health_score.grade)})</td></tr>
143<tr><td>Latest version</td><td>{escape(info.latest_version or "—")}</td></tr>
144<tr><td>Downloads (30d)</td><td>{derived.downloads_30d_total:,}</td></tr>
145<tr><td>GitHub stars</td><td>{info.github_stats.stars:,}</td></tr></table>
146<h2>Health breakdown</h2><table><tr><th>Category</th><th>Score</th></tr>{rows}</table>
147{_html_warnings(warnings)}
148{_html_attribution(info.repository_url)}
149</body></html>
150"""
153def render_terminal_report(info: PackageInfo, derived: DerivedPackageData) -> str:
154 """Kept for callers that need a report-like terminal representation."""
155 return render_summary(info, derived)
158def render_project_report(format_name: str, report: ProjectReport) -> str:
159 if format_name == "json":
160 return ProjectExport.model_validate(
161 _project_report_data(report)
162 ).model_dump_json(indent=2, by_alias=True)
163 if format_name == "md":
164 return _project_markdown(report)
165 if format_name == "html":
166 return _project_html(report)
167 raise ReportError(f"Unsupported report format: {format_name}")
170def default_report_path(
171 subject: str,
172 format_name: str,
173 *,
174 project: bool = False,
175 directory: Path | None = None,
176) -> Path:
177 safe = subject.replace("/", "_").replace(" ", "_")
178 suffix = "project" if project else "package"
179 date = datetime.now(timezone.utc).strftime("%Y-%m-%d")
180 extension = "md" if format_name == "markdown" else format_name
181 return (directory or Path.cwd()) / f"secchi-{safe}-{suffix}-{date}.{extension}"
184def _project_report_data(report: ProjectReport) -> dict:
185 available = [source for source in report.sources if source.info and source.derived]
186 health_scores = [
187 source.derived.health_score.total for source in available if source.derived
188 ]
189 downloads = sum(
190 source.derived.downloads_30d_total for source in available if source.derived
191 )
192 return {
193 "schema_version": PROJECT_EXPORT_SCHEMA_VERSION,
194 "schema": "secchi.project-intelligence",
195 "generated_by": "Secchi",
196 "project": {
197 "name": report.project.name,
198 "title": report.project.title or report.project.name,
199 "description": report.project.description,
200 "favorite": report.project.favorite,
201 "repository": report.project.repository_url,
202 },
203 "generated_at": report.generated_at.isoformat(),
204 "summary": {
205 "health_score": round(sum(health_scores) / len(health_scores))
206 if health_scores
207 else None,
208 "downloads_30d": downloads,
209 "source_count": len(report.sources),
210 "healthy_source_count": len(available),
211 },
212 "sources": [
213 {
214 "package": source.ref.name,
215 "registry": source.ref.registry.value,
216 "latest_version": source.info.latest_version if source.info else None,
217 "health_score": source.derived.health_score.total
218 if source.derived
219 else None,
220 "downloads_30d": source.derived.downloads_30d_total
221 if source.derived
222 else None,
223 "error": source.error,
224 "warnings": source.warnings,
225 }
226 for source in report.sources
227 ],
228 }
231def _project_markdown(report: ProjectReport) -> str:
232 data = _project_report_data(report)
233 project = data["project"]
234 summary = data["summary"]
235 rows = "\n".join(_source_markdown_row(source) for source in data["sources"])
236 return f"""# {project["title"]}
238{project["description"] or "No project description available."}
240Repository: {project["repository"] or "—"}
242## Project summary
244| Signal | Value |
245| --- | --- |
246| Health score | {summary["health_score"] if summary["health_score"] is not None else "—"} / 100 |
247| Downloads (30d) | {summary["downloads_30d"]:,} |
248| Healthy sources | {summary["healthy_source_count"]} / {summary["source_count"]} |
250## Package sources
252| Package | Registry | Latest version | Health | Downloads (30d) | Status |
253| --- | --- | --- | ---: | ---: | --- |
254{rows}
256{_markdown_attribution(project["repository"])}
257"""
260def _source_markdown_row(source: dict) -> str:
261 downloads = (
262 f"{source['downloads_30d']:,}" if source["downloads_30d"] is not None else "—"
263 )
264 return (
265 f"| {source['package']} | {source['registry']} | {source['latest_version'] or '—'} | "
266 f"{source['health_score'] if source['health_score'] is not None else '—'} | "
267 f"{downloads} | {_source_status(source)} |"
268 )
271def _project_html(report: ProjectReport) -> str:
272 data = _project_report_data(report)
273 project = data["project"]
274 summary = data["summary"]
275 rows = "".join(
276 "<tr>"
277 f"<td>{escape(source['package'])}</td>"
278 f"<td>{escape(source['registry'])}</td>"
279 f"<td>{escape(str(source['latest_version'] or '—'))}</td>"
280 f"<td>{escape(str(source['health_score'] if source['health_score'] is not None else '—'))}</td>"
281 f"<td>{source['downloads_30d'] if source['downloads_30d'] is not None else '—'}</td>"
282 f"<td>{escape(_source_status(source))}</td></tr>"
283 for source in data["sources"]
284 )
285 return f"""<!doctype html>
286<html lang="en"><head><meta charset="utf-8"><title>Secchi project report: {escape(project["title"])}</title>
287<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>
288</head><body><h1>{escape(project["title"])}</h1><p>{escape(project["description"] or "No project description available.")}</p>
289<p>Repository: {escape(project["repository"] or "—")}</p>
290<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>
291<h2>Package sources</h2><table><thead><tr><th>Package</th><th>Registry</th><th>Latest</th><th>Health</th><th>Downloads (30d)</th><th>Status</th></tr></thead><tbody>{rows}</tbody></table>
292{_html_attribution(project["repository"])}
293</body></html>
294"""
297def _markdown_attribution(repository_url: str | None) -> str:
298 star = f" · [⭐ Star the project]({repository_url})" if repository_url else ""
299 return f"Generated by [Secchi]({SECCHI_REPOSITORY_URL}){star}"
302def _source_status(source: dict) -> str:
303 if source["error"]:
304 return source["error"]
305 warnings = source.get("warnings", [])
306 return f"{len(warnings)} signal warning(s)" if warnings else "Healthy"
309def _markdown_warnings(warnings: list[object] | None) -> str:
310 if not warnings:
311 return ""
312 rows = "\n".join(f"- `{warning.source}`: {warning.message}" for warning in warnings)
313 return f"## Signal warnings\n\n{rows}\n"
316def _html_warnings(warnings: list[object] | None) -> str:
317 if not warnings:
318 return ""
319 rows = "".join(
320 f"<li><code>{escape(warning.source)}</code>: {escape(warning.message)}</li>"
321 for warning in warnings
322 )
323 return f"<h2>Signal warnings</h2><ul>{rows}</ul>"
326def _html_attribution(repository_url: str | None) -> str:
327 star = (
328 f' · <a href="{escape(repository_url, quote=True)}">⭐ Star the project</a>'
329 if repository_url
330 else ""
331 )
332 secchi_link = f'<a href="{escape(SECCHI_REPOSITORY_URL, quote=True)}">Secchi</a>'
333 return f'<footer style="margin-top:2rem;color:#5f6b7a">Generated by {secchi_link}{star}</footer>'