Coverage for src/secchi/cache.py: 81%

134 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-08-14 23:28 +0000

1"""Daily package-response cache for registry/GitHub API data.""" 

2 

3from __future__ import annotations 

4 

5import json 

6import os 

7from collections.abc import Callable 

8from dataclasses import asdict 

9from datetime import datetime 

10from pathlib import Path 

11from typing import Any 

12from urllib.parse import quote 

13 

14from secchi.models import ( 

15 AdvisoryReference, 

16 Dependency, 

17 DownloadCounts, 

18 DownloadTrendPoint, 

19 GitHubIssueEvent, 

20 GitHubStats, 

21 MetricTimelinePoint, 

22 PackageInfo, 

23 Registry, 

24 ReleaseFile, 

25 ReverseDependency, 

26 SecurityAdvisory, 

27 Version, 

28) 

29from secchi.schema import CACHE_SCHEMA_VERSION, SECURITY_CACHE_SCHEMA_VERSION 

30from secchi.schemas import CacheEnvelope 

31 

32 

33def cache_root() -> Path: 

34 """XDG_CACHE_HOME/secchi, else ~/.cache/secchi.""" 

35 if base := os.environ.get("XDG_CACHE_HOME", ""): 

36 return Path(base) / "secchi" 

37 return Path.home() / ".cache" / "secchi" 

38 

39 

40def _safe_cache_key(key: str) -> str: 

41 """Percent-encode a cache key into a single, collision-free filename. 

42 

43 ``quote(key, safe="")`` escapes every byte outside the URL-unreserved 

44 set, so ``/`` and ``\\`` (path separators on POSIX/Windows) and ``:`` 

45 (invalid on Windows) can never reach the filesystem — the result is 

46 always exactly one path component, ruling out traversal outside the 

47 cache root. Because the encoding is injective, distinct keys can no 

48 longer collide onto the same file (unlike a lossy character replace, 

49 where e.g. ``a/b_c`` and ``a_b/c`` previously mapped to one file). 

50 """ 

51 return quote(key, safe="") 

52 

53 

54def package_cache_path(key: str, *, root: Path | None = None) -> Path: 

55 return (root or cache_root()) / "packages" / f"{_safe_cache_key(key)}.json" 

56 

57 

58def security_cache_path(key: str, *, root: Path | None = None) -> Path: 

59 return (root or cache_root()) / "security" / f"{_safe_cache_key(key)}.json" 

60 

61 

62def load_security_cache( 

63 key: str, 

64 package_version: str, 

65 *, 

66 root: Path | None = None, 

67 now: Callable[[], datetime] | None = None, 

68) -> tuple[list[SecurityAdvisory], datetime] | None: 

69 path = security_cache_path(key, root=root) 

70 if not path.exists(): 

71 return None 

72 try: 

73 raw = json.loads(path.read_text()) 

74 if not isinstance(raw, dict): 

75 return None 

76 if raw.get("schema_version") != SECURITY_CACHE_SCHEMA_VERSION: 

77 return None 

78 if raw.get("package_version", "") != package_version: 

79 return None 

80 fetched_at = _parse_datetime(raw.get("fetched_at")) 

81 if fetched_at is None: 

82 return None 

83 today = (now or (lambda: datetime.now().astimezone()))().date() 

84 if fetched_at.astimezone().date() != today: 

85 return None 

86 advisories = raw.get("advisories", []) 

87 if not isinstance(advisories, list): 

88 return None 

89 if not all(isinstance(item, dict) for item in advisories): 

90 return None 

91 return [_decode_advisory(item) for item in advisories], fetched_at 

92 except (KeyError, TypeError, ValueError, json.JSONDecodeError, OSError): 

93 return None 

94 

95 

96def save_security_cache( 

97 key: str, 

98 package_version: str, 

99 advisories: list[SecurityAdvisory], 

100 fetched_at: datetime, 

101 *, 

102 root: Path | None = None, 

103) -> None: 

104 path = security_cache_path(key, root=root) 

105 payload = { 

106 "schema_version": SECURITY_CACHE_SCHEMA_VERSION, 

107 "fetched_at": fetched_at.isoformat(), 

108 "package_version": package_version, 

109 "advisories": _encode([asdict(advisory) for advisory in advisories]), 

110 } 

111 try: 

112 path.parent.mkdir(parents=True, exist_ok=True) 

113 path.write_text(json.dumps(payload, indent=2, sort_keys=True)) 

114 except OSError: 

115 pass 

116 

117 

118def load_package_cache( 

119 key: str, 

120 *, 

121 root: Path | None = None, 

122 now: Callable[[], datetime] | None = None, 

123) -> tuple[PackageInfo, datetime] | None: 

124 path = package_cache_path(key, root=root) 

125 if not path.exists(): 

126 return None 

127 try: 

128 raw = json.loads(path.read_text()) 

129 if not isinstance(raw, dict): 

130 return None 

131 schema_version = raw.get("schema_version", 0) 

132 if not isinstance(schema_version, int) or schema_version > CACHE_SCHEMA_VERSION: 

133 return None 

134 envelope = CacheEnvelope.model_validate( 

135 {**raw, "schema_version": schema_version} 

136 ) 

137 fetched_at = envelope.fetched_at 

138 today = (now or (lambda: datetime.now().astimezone()))().date() 

139 if fetched_at.astimezone().date() != today: 

140 return None 

141 return _decode_package_info(envelope.package), fetched_at 

142 except (KeyError, TypeError, ValueError, json.JSONDecodeError, OSError): 

143 return None 

144 

145 

146def save_package_cache( 

147 key: str, 

148 info: PackageInfo, 

149 fetched_at: datetime, 

150 *, 

151 root: Path | None = None, 

152) -> None: 

153 path = package_cache_path(key, root=root) 

154 payload = CacheEnvelope( 

155 schema_version=CACHE_SCHEMA_VERSION, 

156 fetched_at=fetched_at, 

157 package=_encode(asdict(info)), 

158 ).model_dump(mode="json") 

159 try: 

160 path.parent.mkdir(parents=True, exist_ok=True) 

161 path.write_text(json.dumps(payload, indent=2, sort_keys=True)) 

162 except OSError: 

163 pass 

164 

165 

166def _encode(value: Any) -> Any: 

167 if isinstance(value, datetime): 

168 return value.isoformat() 

169 if isinstance(value, Registry): 

170 return value.value 

171 if isinstance(value, dict): 

172 return {str(k): _encode(v) for k, v in value.items()} 

173 if isinstance(value, list): 

174 return [_encode(v) for v in value] 

175 return value 

176 

177 

178def _decode_package_info(raw: dict[str, Any]) -> PackageInfo: 

179 info = PackageInfo( 

180 name=raw.get("name", ""), 

181 registry=Registry(raw.get("registry", "pypi")), 

182 source_registries=[Registry(r) for r in raw.get("source_registries", []) if r], 

183 description=raw.get("description", ""), 

184 author=raw.get("author", ""), 

185 license=raw.get("license", ""), 

186 homepage=raw.get("homepage", ""), 

187 repository_url=raw.get("repository_url", ""), 

188 documentation_url=raw.get("documentation_url", ""), 

189 latest_version=raw.get("latest_version", ""), 

190 latest_release_date=_parse_datetime(raw.get("latest_release_date")), 

191 total_downloads=raw.get("total_downloads", 0), 

192 package_kind=raw.get("package_kind", ""), 

193 ) 

194 info.download_counts = _decode_download_counts(raw.get("download_counts", {})) 

195 info.github_stats = _decode_github_stats(raw.get("github_stats", {})) 

196 info.versions = [_decode_version(v) for v in raw.get("versions", [])] 

197 info.dependencies = [_decode_dependency(d) for d in raw.get("dependencies", [])] 

198 info.download_trend = [ 

199 DownloadTrendPoint(date=p.get("date", ""), count=p.get("count", 0)) 

200 for p in raw.get("download_trend", []) 

201 ] 

202 info.release_notes = raw.get("release_notes", "") 

203 info.latest_release_files = [ 

204 ReleaseFile( 

205 packagetype=f.get("packagetype", ""), 

206 size=f.get("size", 0), 

207 filename=f.get("filename", ""), 

208 ) 

209 for f in raw.get("latest_release_files", []) 

210 ] 

211 info.version_downloads_recent = { 

212 _decode_key(k): v for k, v in raw.get("version_downloads_recent", {}).items() 

213 } 

214 info.reverse_dependencies = [ 

215 ReverseDependency(name=d.get("name", ""), downloads=d.get("downloads", 0)) 

216 for d in raw.get("reverse_dependencies", []) 

217 ] 

218 info.reverse_dependency_count = raw.get("reverse_dependency_count") 

219 info.reverse_dependency_monthly_growth = raw.get( 

220 "reverse_dependency_monthly_growth" 

221 ) 

222 info.health_history = [ 

223 MetricTimelinePoint(label=p.get("label", ""), value=p.get("value", 0)) 

224 for p in raw.get("health_history", []) 

225 ] 

226 info.github_issue_events = [ 

227 _decode_issue_event(e) for e in raw.get("github_issue_events", []) 

228 ] 

229 info.security_advisories = [ 

230 _decode_advisory(a) for a in raw.get("security_advisories", []) 

231 ] 

232 return info 

233 

234 

235def _decode_download_counts(raw: dict[str, Any]) -> DownloadCounts: 

236 return DownloadCounts( 

237 today=raw.get("today", 0), 

238 week=raw.get("week", 0), 

239 month=raw.get("month", 0), 

240 ) 

241 

242 

243def _decode_github_stats(raw: dict[str, Any]) -> GitHubStats: 

244 return GitHubStats( 

245 stars=raw.get("stars", 0), 

246 forks=raw.get("forks", 0), 

247 open_issues=raw.get("open_issues", 0), 

248 created_at=_parse_datetime(raw.get("created_at")), 

249 pushed_at=_parse_datetime(raw.get("pushed_at")), 

250 has_ci=raw.get("has_ci", False), 

251 has_readme=raw.get("has_readme", False), 

252 resolved=raw.get("resolved", False), 

253 stars_delta_7d=raw.get("stars_delta_7d"), 

254 open_issues_delta_7d=raw.get("open_issues_delta_7d"), 

255 ) 

256 

257 

258def _decode_version(raw: dict[str, Any]) -> Version: 

259 return Version( 

260 version=raw.get("version", ""), 

261 release_date=_parse_datetime(raw.get("release_date")), 

262 downloads=raw.get("downloads", 0), 

263 is_yanked=raw.get("is_yanked", False), 

264 external_id=raw.get("external_id"), 

265 size_bytes=raw.get("size_bytes"), 

266 ) 

267 

268 

269def _decode_dependency(raw: dict[str, Any]) -> Dependency: 

270 return Dependency( 

271 name=raw.get("name", ""), 

272 requirement=raw.get("requirement", ""), 

273 optional=raw.get("optional", False), 

274 ) 

275 

276 

277def _decode_issue_event(raw: dict[str, Any]) -> GitHubIssueEvent: 

278 return GitHubIssueEvent( 

279 number=raw.get("number", 0), 

280 title=raw.get("title", ""), 

281 is_pull_request=raw.get("is_pull_request", False), 

282 created_at=_parse_datetime(raw.get("created_at")) or datetime.min, 

283 closed_at=_parse_datetime(raw.get("closed_at")), 

284 url=raw.get("url", ""), 

285 ) 

286 

287 

288def _decode_advisory(raw: dict[str, Any]) -> SecurityAdvisory: 

289 return SecurityAdvisory( 

290 id=raw.get("id", ""), 

291 summary=raw.get("summary", ""), 

292 details=raw.get("details", ""), 

293 aliases=raw.get("aliases", []), 

294 severity=raw.get("severity", ""), 

295 published=_parse_datetime(raw.get("published")), 

296 modified=_parse_datetime(raw.get("modified")), 

297 fixed_versions=raw.get("fixed_versions", []), 

298 references=[ 

299 AdvisoryReference(type=item.get("type", ""), url=item.get("url", "")) 

300 for item in raw.get("references", []) 

301 if isinstance(item, dict) 

302 ], 

303 url=raw.get("url", ""), 

304 ) 

305 

306 

307def _parse_datetime(raw: Any) -> datetime | None: 

308 if not raw: 

309 return None 

310 try: 

311 return datetime.fromisoformat(raw) 

312 except (TypeError, ValueError): 

313 return None 

314 

315 

316def _decode_key(raw: str) -> int | str: 

317 try: 

318 return int(raw) 

319 except ValueError: 

320 return raw