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

1"""Report file helpers for package and project exports.""" 

2 

3from __future__ import annotations 

4 

5from datetime import UTC, datetime 

6from pathlib import Path 

7from typing import Any 

8 

9from secchi.models import ( 

10 DerivedPackageData, 

11 PackageInfo, 

12 PackageRef, 

13) 

14from secchi.schemas import PackageExport, SignalWarningSchema 

15 

16 

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) 

37 

38 

39def save_export(json_str: str, project_name: str, pkg_name: str) -> Path: 

40 return save_report(json_str, project_name, pkg_name, "json") 

41 

42 

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 

57 

58 

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

67 

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

73 

74 

75def report_mime_type(format_name: str) -> str: 

76 """Return the MIME type for a supported report format.""" 

77 

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

84 

85 

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 } 

171 

172 

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 } 

210 

211 

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