Coverage for src/secchi/export.py: 71%
34 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"""Report file helpers for package and project exports."""
3from __future__ import annotations
5from datetime import UTC, datetime
6from pathlib import Path
7from typing import Any
9from secchi.models import (
10 DerivedPackageData,
11 PackageInfo,
12 PackageRef,
13)
14from secchi.schemas import PackageExport, SignalWarningSchema
17def export_package_json(
18 info: PackageInfo,
19 derived: DerivedPackageData | None,
20 ref: PackageRef,
21 project_name: str,
22 warnings: list[object] | None = None,
23) -> str:
24 document = PackageExport(
25 project=project_name,
26 package=ref.name,
27 registry=ref.registry.value,
28 exported_at=datetime.now(UTC),
29 package_info=_serialize_package_info(info) if info else None,
30 derived=_serialize_derived(derived) if derived else None,
31 warnings=[
32 SignalWarningSchema(source=warning.source, message=warning.message)
33 for warning in (warnings or [])
34 ],
35 )
36 return document.model_dump_json(indent=2, by_alias=True)
39def save_export(json_str: str, project_name: str, pkg_name: str) -> Path:
40 return save_report(json_str, project_name, pkg_name, "json")
43def save_report(
44 content: str,
45 project_name: str,
46 subject: str,
47 format_name: str,
48 directory: Path | None = None,
49) -> Path:
50 """Save a report in the target directory, creating it when necessary."""
51 date = datetime.now(UTC).strftime("%Y-%m-%d")
52 filename = report_filename(project_name, subject, format_name, date=date)
53 path = (directory or Path.cwd()) / filename
54 path.parent.mkdir(parents=True, exist_ok=True)
55 path.write_text(content)
56 return path
59def report_filename(
60 project_name: str,
61 subject: str,
62 format_name: str,
63 *,
64 date: str | None = None,
65) -> str:
66 """Return the stable filename used for terminal and browser exports."""
68 safe_project = project_name.replace("/", "_").replace(" ", "_")
69 safe_subject = subject.replace("/", "_").replace(" ", "_")
70 extension = "md" if format_name == "markdown" else format_name
71 export_date = date or datetime.now(UTC).strftime("%Y-%m-%d")
72 return f"secchi-{safe_project}-{safe_subject}-{export_date}.{extension}"
75def report_mime_type(format_name: str) -> str:
76 """Return the MIME type for a supported report format."""
78 return {
79 "markdown": "text/markdown",
80 "md": "text/markdown",
81 "json": "application/json",
82 "html": "text/html",
83 }.get(format_name, "application/octet-stream")
86def _serialize_package_info(info: PackageInfo) -> dict[str, Any]:
87 return {
88 "name": info.name,
89 "registry": info.registry.value,
90 "source_registries": [r.value for r in info.source_registries],
91 "description": info.description,
92 "author": info.author,
93 "license": info.license,
94 "homepage": info.homepage,
95 "repository_url": info.repository_url,
96 "documentation_url": info.documentation_url,
97 "latest_version": info.latest_version,
98 "latest_release_date": info.latest_release_date.isoformat()
99 if info.latest_release_date
100 else None,
101 "total_downloads": info.total_downloads,
102 "download_counts": {
103 "today": info.download_counts.today,
104 "week": info.download_counts.week,
105 "month": info.download_counts.month,
106 },
107 "github_stats": {
108 "stars": info.github_stats.stars,
109 "forks": info.github_stats.forks,
110 "open_issues": info.github_stats.open_issues,
111 "has_ci": info.github_stats.has_ci,
112 "has_readme": info.github_stats.has_readme,
113 "stars_delta_7d": info.github_stats.stars_delta_7d,
114 "open_issues_delta_7d": info.github_stats.open_issues_delta_7d,
115 "created_at": info.github_stats.created_at.isoformat()
116 if info.github_stats.created_at
117 else None,
118 "pushed_at": info.github_stats.pushed_at.isoformat()
119 if info.github_stats.pushed_at
120 else None,
121 },
122 "versions": [
123 {
124 "version": v.version,
125 "release_date": v.release_date.isoformat() if v.release_date else None,
126 "downloads": v.downloads,
127 "is_yanked": v.is_yanked,
128 "size_bytes": v.size_bytes,
129 }
130 for v in info.versions
131 ],
132 "dependencies": [
133 {
134 "name": d.name,
135 "requirement": d.requirement,
136 "optional": d.optional,
137 }
138 for d in info.dependencies
139 ],
140 "download_trend": [
141 {"date": p.date, "count": p.count} for p in info.download_trend
142 ],
143 "reverse_dependency_count": info.reverse_dependency_count,
144 "reverse_dependency_monthly_growth": info.reverse_dependency_monthly_growth,
145 "health_history": [
146 {"label": p.label, "value": p.value} for p in info.health_history
147 ],
148 "security_advisories": [
149 {
150 "id": advisory.id,
151 "summary": advisory.summary,
152 "details": advisory.details,
153 "aliases": advisory.aliases,
154 "severity": advisory.severity,
155 "published": advisory.published.isoformat()
156 if advisory.published
157 else None,
158 "modified": advisory.modified.isoformat()
159 if advisory.modified
160 else None,
161 "fixed_versions": advisory.fixed_versions,
162 "references": [
163 {"type": reference.type, "url": reference.url}
164 for reference in advisory.references
165 ],
166 "url": advisory.url,
167 }
168 for advisory in info.security_advisories
169 ],
170 }
173def _serialize_derived(derived: DerivedPackageData) -> dict[str, Any]:
174 return {
175 "health_score": {
176 "total": derived.health_score.total,
177 "grade": derived.health_score.grade,
178 "sub_scores": [
179 {"label": s.label, "score": s.score, "max_score": s.max_score}
180 for s in derived.health_score.sub_scores
181 ],
182 },
183 "downloads_30d": {
184 "total": derived.downloads_30d_total,
185 "pct_change": derived.downloads_30d_pct_change,
186 },
187 "install_breakdown": [
188 {"label": m.label, "count": m.count, "percent": m.percent}
189 for m in derived.install_breakdown.methods
190 ],
191 "reverse_dependencies": {
192 "count": derived.reverse_dependency_summary.count,
193 "monthly_growth": derived.reverse_dependency_summary.monthly_growth,
194 },
195 "health_timeline": [
196 {"label": p.label, "value": p.value} for p in derived.health_timeline
197 ],
198 "activity": [
199 {
200 "kind": ev.kind.value,
201 "timestamp": ev.timestamp.isoformat(),
202 "title": ev.title,
203 "ref": ev.ref,
204 "url": ev.url,
205 }
206 for ev in derived.activity
207 ],
208 "release_adoption": derived.release_adoption,
209 }
212def _json_default(obj: Any) -> Any:
213 if isinstance(obj, datetime):
214 return obj.isoformat()
215 raise TypeError(f"Object of type {type(obj)} is not JSON serializable")