Coverage for src/secchi/derived.py: 78%

210 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-08-04 22:15 +0000

1"""Derived metrics — pure functions over an already-fetched PackageInfo. 

2 

3No network or filesystem I/O lives here: everything is arithmetic over data the 

4adapters/utils already fetched. This keeps the "real vs. derived" boundary sharp 

5and makes the scoring logic unit-testable without mocking httpx. 

6""" 

7 

8from __future__ import annotations 

9 

10from datetime import datetime, timezone 

11 

12from secchi.models import ( 

13 ActivityEvent, 

14 ActivityEventKind, 

15 DerivedPackageData, 

16 HealthScore, 

17 HealthSubScore, 

18 InstallBreakdown, 

19 InstallMethod, 

20 PackageInfo, 

21 Registry, 

22 ReverseDependencySummary, 

23) 

24 

25 

26def _now() -> datetime: 

27 return datetime.now(timezone.utc) 

28 

29 

30def _aware(dt: datetime | None) -> datetime | None: 

31 if dt is None: 

32 return None 

33 return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc) 

34 

35 

36def _days_since(dt: datetime | None) -> float | None: 

37 dt = _aware(dt) 

38 if dt is None: 

39 return None 

40 return (_now() - dt).total_seconds() / 86400 

41 

42 

43# ── Health score ───────────────────────────────────────────────────────────── 

44 

45 

46def _score_maintained(info: PackageInfo) -> int: 

47 candidates = [ 

48 _days_since(info.latest_release_date), 

49 _days_since(info.github_stats.pushed_at), 

50 ] 

51 ages = [d for d in candidates if d is not None] 

52 if not ages: 

53 return 0 

54 days = min(ages) 

55 if days <= 30: 

56 return 20 

57 if days <= 90: 

58 return 16 

59 if days <= 180: 

60 return 12 

61 if days <= 365: 

62 return 6 

63 return 2 

64 

65 

66def _score_documentation(info: PackageInfo) -> int: 

67 score = 0 

68 if info.homepage: 

69 score += 5 

70 if info.documentation_url: 

71 score += 8 

72 if info.github_stats.has_readme: 

73 score += 7 

74 return min(score, 20) 

75 

76 

77def _score_testing(info: PackageInfo) -> int: 

78 # Weak proxy: presence of CI workflows. Not test coverage. 

79 return 20 if (info.github_stats.resolved and info.github_stats.has_ci) else 0 

80 

81 

82def _issue_counts_90d(info: PackageInfo) -> tuple[int, int]: 

83 now = _now() 

84 opened = closed = 0 

85 for ev in info.github_issue_events: 

86 created = _aware(ev.created_at) 

87 if created and (now - created).days <= 90: 

88 opened += 1 

89 closed_at = _aware(ev.closed_at) 

90 if closed_at and (now - closed_at).days <= 90: 

91 closed += 1 

92 return opened, closed 

93 

94 

95def _score_community(info: PackageInfo) -> int: 

96 gh = info.github_stats 

97 if not gh.resolved: 

98 return 0 

99 

100 stars = gh.stars 

101 if stars >= 100_000: 

102 star_pts = 10 

103 elif stars >= 10_000: 

104 star_pts = 8 

105 elif stars >= 1_000: 

106 star_pts = 6 

107 elif stars >= 100: 

108 star_pts = 4 

109 elif stars >= 1: 

110 star_pts = 2 

111 else: 

112 star_pts = 0 

113 

114 forks = gh.forks 

115 if forks >= 10_000: 

116 fork_pts = 5 

117 elif forks >= 1_000: 

118 fork_pts = 4 

119 elif forks >= 100: 

120 fork_pts = 3 

121 elif forks >= 10: 

122 fork_pts = 2 

123 else: 

124 fork_pts = 0 

125 

126 opened, closed = _issue_counts_90d(info) 

127 if opened == 0: 

128 resp_pts = 3 # neutral — no signal 

129 elif closed >= opened: 

130 resp_pts = 5 

131 elif closed >= 0.5 * opened: 

132 resp_pts = 3 

133 elif closed > 0: 

134 resp_pts = 1 

135 else: 

136 resp_pts = 0 

137 

138 return min(star_pts + fork_pts + resp_pts, 20) 

139 

140 

141def _score_activity(info: PackageInfo) -> int: 

142 now = _now() 

143 releases = 0 

144 for v in info.versions: 

145 rd = _aware(v.release_date) 

146 if rd and (now - rd).days <= 365: 

147 releases += 1 

148 if releases == 0: 

149 return 0 

150 if releases <= 2: 

151 return 6 

152 if releases <= 5: 

153 return 12 

154 if releases <= 11: 

155 return 16 

156 return 20 

157 

158 

159def _score_security(info: PackageInfo) -> int: 

160 """Best-effort registry security signal from locally fetched metadata. 

161 

162 Secchi does not fetch advisory databases yet, so this category stays honest: 

163 yanked recent releases and a missing source repository reduce confidence, 

164 while the absence of those signals is treated as healthy. 

165 """ 

166 score = 20 

167 recent = info.versions[:5] 

168 if any(v.is_yanked for v in recent): 

169 score -= 8 

170 if recent and recent[0].is_yanked: 

171 score -= 6 

172 if not (info.repository_url or info.github_stats.resolved): 

173 score -= 4 

174 return max(score, 0) 

175 

176 

177def _grade(total: int) -> str: 

178 if total >= 90: 

179 return "A" 

180 if total >= 75: 

181 return "B" 

182 if total >= 60: 

183 return "C" 

184 if total >= 40: 

185 return "D" 

186 return "F" 

187 

188 

189def compute_health_score(info: PackageInfo) -> HealthScore: 

190 def scaled(raw_20: int, max_score: int) -> int: 

191 return round(max(0, min(raw_20, 20)) / 20 * max_score) 

192 

193 subs = [ 

194 HealthSubScore("Maintenance", _score_maintained(info), 20), 

195 HealthSubScore("Community", scaled(_score_community(info), 15), 15), 

196 HealthSubScore("Documentation", scaled(_score_documentation(info), 15), 15), 

197 HealthSubScore("Releases", scaled(_score_activity(info), 15), 15), 

198 HealthSubScore("Security", _score_security(info), 20), 

199 HealthSubScore("Testing", scaled(_score_testing(info), 15), 15), 

200 ] 

201 total = sum(s.score for s in subs) 

202 return HealthScore(sub_scores=subs, total=total, grade=_grade(total)) 

203 

204 

205# ── Release adoption ───────────────────────────────────────────────────────── 

206 

207 

208def compute_release_adoption( 

209 info: PackageInfo, limit: int = 5 

210) -> tuple[dict[str, float], str]: 

211 """Return (version -> percent, caption).""" 

212 versions = info.versions[:limit] 

213 if not versions: 

214 return {}, "" 

215 

216 if info.registry is Registry.CRATES and info.version_downloads_recent: 

217 raw = { 

218 v.version: info.version_downloads_recent.get(v.external_id, 0) 

219 for v in versions 

220 } 

221 caption = "Source: crates.io real per-version downloads (~90d)" 

222 else: 

223 raw = _adoption_from_trend(info, versions) 

224 caption = "Estimated from 30-day download trend, sliced by release date" 

225 

226 total = sum(raw.values()) 

227 if total <= 0: 

228 return {}, caption 

229 return {ver: (count / total) * 100 for ver, count in raw.items()}, caption 

230 

231 

232def _adoption_from_trend(info: PackageInfo, versions) -> dict[str, int]: 

233 """Slice daily download totals into per-version windows by release date.""" 

234 trend = info.download_trend 

235 if not trend: 

236 return {v.version: 0 for v in versions} 

237 

238 def parse(d: str) -> datetime | None: 

239 try: 

240 return datetime.fromisoformat(d).replace(tzinfo=timezone.utc) 

241 except (ValueError, TypeError): 

242 return None 

243 

244 points = [(parse(p.date), p.count) for p in trend] 

245 points = [(d, c) for d, c in points if d is not None] 

246 

247 raw: dict[str, int] = {} 

248 # versions are newest-first; window for index i is [release[i], release[i-1]) 

249 for i, v in enumerate(versions): 

250 start = _aware(v.release_date) 

251 end = _aware(versions[i - 1].release_date) if i > 0 else None 

252 total = 0 

253 for d, c in points: 

254 if start and d < start: 

255 continue 

256 if end and d >= end: 

257 continue 

258 total += c 

259 raw[v.version] = total 

260 return raw 

261 

262 

263# ── Ecosystem distribution ─────────────────────────────────────────────────── 

264 

265 

266def compute_install_breakdown(info: PackageInfo) -> InstallBreakdown: 

267 """Return download share by ecosystem. 

268 

269 The model name is kept for compatibility with existing views/export, but 

270 the Overview dashboard renders this as ecosystem distribution rather than 

271 installation commands. 

272 """ 

273 count = _registry_activity_count(info) 

274 label = info.registry.display_name 

275 caption = "Download share by supported ecosystem." 

276 return InstallBreakdown( 

277 methods=[ 

278 InstallMethod( 

279 label=label, 

280 count=count, 

281 percent=100.0 if count > 0 else 0.0, 

282 ) 

283 ] 

284 if count > 0 

285 else [], 

286 caption=caption, 

287 is_estimate=False, 

288 ) 

289 

290 

291def _registry_activity_count(info: PackageInfo) -> int: 

292 count = info.download_counts.month or sum( 

293 p.count for p in info.download_trend[-30:] 

294 ) 

295 return count or info.total_downloads 

296 

297 

298# ── Reverse dependencies + historical health ──────────────────────────────── 

299 

300 

301def compute_reverse_dependency_summary(info: PackageInfo) -> ReverseDependencySummary: 

302 if info.reverse_dependency_count is None: 

303 caption = f"{info.registry.display_name} reverse-dependency count unavailable." 

304 else: 

305 caption = "Projects depending on this package." 

306 return ReverseDependencySummary( 

307 count=info.reverse_dependency_count, 

308 monthly_growth=info.reverse_dependency_monthly_growth, 

309 caption=caption, 

310 ) 

311 

312 

313# ── Activity timeline ──────────────────────────────────────────────────────── 

314 

315 

316def compute_activity_timeline( 

317 info: PackageInfo, limit: int = 15 

318) -> list[ActivityEvent]: 

319 events: list[ActivityEvent] = [] 

320 for v in info.versions: 

321 rd = _aware(v.release_date) 

322 if rd: 

323 events.append( 

324 ActivityEvent( 

325 kind=ActivityEventKind.RELEASE, 

326 timestamp=rd, 

327 title=f"v{v.version} published", 

328 ref=v.version, 

329 ) 

330 ) 

331 for ev in info.github_issue_events: 

332 created = _aware(ev.created_at) 

333 if created: 

334 events.append( 

335 ActivityEvent( 

336 kind=ActivityEventKind.PR_OPENED 

337 if ev.is_pull_request 

338 else ActivityEventKind.ISSUE_OPENED, 

339 timestamp=created, 

340 title=ev.title, 

341 ref=f"#{ev.number}", 

342 url=ev.url, 

343 ) 

344 ) 

345 closed = _aware(ev.closed_at) 

346 if closed: 

347 events.append( 

348 ActivityEvent( 

349 kind=ActivityEventKind.PR_CLOSED 

350 if ev.is_pull_request 

351 else ActivityEventKind.ISSUE_CLOSED, 

352 timestamp=closed, 

353 title=ev.title, 

354 ref=f"#{ev.number}", 

355 url=ev.url, 

356 ) 

357 ) 

358 events.sort(key=lambda e: e.timestamp, reverse=True) 

359 return events[:limit] 

360 

361 

362# ── Downloads 30d + % change ───────────────────────────────────────────────── 

363 

364 

365def compute_downloads_30d(info: PackageInfo) -> tuple[int, float | None]: 

366 trend = info.download_trend 

367 if not trend: 

368 return info.download_counts.month, None 

369 counts = [p.count for p in trend] 

370 last30 = sum(counts[-30:]) 

371 prior30 = sum(counts[-60:-30]) 

372 if prior30 <= 0: 

373 return last30, None 

374 pct = (last30 - prior30) / prior30 * 100 

375 return last30, pct 

376 

377 

378# ── Aggregate ──────────────────────────────────────────────────────────────── 

379 

380 

381def compute_all(info: PackageInfo) -> DerivedPackageData: 

382 adoption, adoption_caption = compute_release_adoption(info) 

383 total_30d, pct = compute_downloads_30d(info) 

384 return DerivedPackageData( 

385 health_score=compute_health_score(info), 

386 install_breakdown=compute_install_breakdown(info), 

387 reverse_dependency_summary=compute_reverse_dependency_summary(info), 

388 health_timeline=info.health_history, 

389 activity=compute_activity_timeline(info), 

390 release_adoption=adoption, 

391 adoption_caption=adoption_caption, 

392 downloads_30d_total=total_30d, 

393 downloads_30d_pct_change=pct, 

394 )