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

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

2 

3from __future__ import annotations 

4 

5from dataclasses import dataclass, field 

6from datetime import datetime, timezone 

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(timezone.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 

115## Health breakdown 

116 

117| Category | Score | 

118| --- | --- | 

119{rows} 

120 

121{_markdown_warnings(warnings)} 

122{_markdown_attribution(info.repository_url)} 

123""" 

124 

125 

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

151 

152 

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) 

156 

157 

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

168 

169 

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

182 

183 

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 } 

229 

230 

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

237 

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

239 

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

241 

242## Project summary 

243 

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

249 

250## Package sources 

251 

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

253| --- | --- | --- | ---: | ---: | --- | 

254{rows} 

255 

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

257""" 

258 

259 

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 ) 

269 

270 

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

295 

296 

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

300 

301 

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" 

307 

308 

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" 

314 

315 

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

324 

325 

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