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

1"""Portable JSON, Markdown, and HTML package intelligence reports.""" 

2 

3from __future__ import annotations 

4 

5from dataclasses import dataclass, field 

6from datetime import UTC, datetime 

7from html import escape 

8from pathlib import Path 

9 

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 

16 

17SECCHI_REPOSITORY_URL = "https://github.com/kannandreams/secchi" 

18 

19 

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) 

27 

28 

29@dataclass 

30class ProjectReport: 

31 project: Project 

32 sources: list[ProjectSourceReport] 

33 generated_at: datetime 

34 

35 

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 

43 

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 ) 

63 

64 

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}") 

80 

81 

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} 

99 

100Registry: `{ref.registry.value}` 

101 

102{info.description or "No package description available."} 

103 

104## Overview 

105 

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 | 

115 

116{_markdown_advisories(info)} 

117 

118## Health breakdown 

119 

120| Category | Score | 

121| --- | --- | 

122{rows} 

123 

124{_markdown_warnings(warnings)} 

125{_markdown_attribution(info.repository_url)} 

126""" 

127 

128 

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""" 

155 

156 

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) 

160 

161 

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}") 

172 

173 

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}" 

186 

187 

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 } 

236 

237 

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"]} 

244 

245{project["description"] or "No project description available."} 

246 

247Repository: {project["repository"] or "—"} 

248 

249## Project summary 

250 

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"]} | 

256 

257## Package sources 

258 

259| Package | Registry | Latest version | Health | Downloads (30d) | Advisories | Status | 

260| --- | --- | --- | ---: | ---: | ---: | --- | 

261{rows} 

262 

263{_markdown_attribution(project["repository"])} 

264""" 

265 

266 

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 ) 

276 

277 

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""" 

303 

304 

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}" 

308 

309 

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" 

324 

325 

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 ) 

343 

344 

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" 

350 

351 

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" 

357 

358 

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>" 

367 

368 

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>'