Coverage for src/secchi/api/pypi.py: 78%

136 statements  

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

1"""PyPI registry adapter using PyPI JSON API.""" 

2 

3from __future__ import annotations 

4 

5import re 

6from contextlib import suppress 

7from datetime import datetime 

8from html import unescape 

9from typing import Any 

10 

11import httpx 

12 

13from secchi.api.base import AdapterBase, RegistryAdapter 

14from secchi.models import ( 

15 Dependency, 

16 DownloadCounts, 

17 DownloadTrendPoint, 

18 PackageInfo, 

19 Registry, 

20 ReleaseFile, 

21 SearchResult, 

22 Version, 

23) 

24 

25PYPI_JSON = "https://pypi.org/pypi" 

26PYPI_STATS = "https://pypistats.org/api" 

27 

28 

29def _derive_kind(classifiers: list[str]) -> str: 

30 """Best-effort package kind from PyPI trove classifiers.""" 

31 text = " ".join(classifiers) 

32 if "Environment :: Console" in text or "Topic :: Utilities" in text: 

33 return "CLI" 

34 return "Library" 

35 

36 

37class PyPIAdapter(AdapterBase, RegistryAdapter): 

38 @property 

39 def registry(self) -> Registry: 

40 return Registry.PYPI 

41 

42 async def _get_json(self, name: str, client: httpx.AsyncClient) -> dict[str, Any]: 

43 resp = await client.get(f"{PYPI_JSON}/{name}/json") 

44 resp.raise_for_status() 

45 return resp.json() 

46 

47 async def fetch_package(self, name: str) -> PackageInfo: 

48 async with self._client_scope() as client: 

49 data = await self._get_json(name, client) 

50 info = data["info"] 

51 

52 latest_version = info.get("version", "") 

53 versions_data = data.get("releases", {}) 

54 latest_files = versions_data.get(latest_version, []) 

55 upload_time = None 

56 if latest_files: 

57 upload_time_raw = latest_files[0].get("upload_time", "") 

58 if upload_time_raw: 

59 with suppress(ValueError, TypeError): 

60 upload_time = datetime.fromisoformat( 

61 upload_time_raw.replace("Z", "+00:00") 

62 ) 

63 

64 release_files = [ 

65 ReleaseFile( 

66 packagetype=f.get("packagetype", "") or "", 

67 size=f.get("size", 0) or 0, 

68 filename=f.get("filename", "") or "", 

69 ) 

70 for f in latest_files 

71 ] 

72 

73 project_urls = info.get("project_urls") or {} 

74 homepage = info.get("home_page", "") 

75 repo_url = project_urls.get("Source", "") 

76 if not repo_url: 

77 repo_url = project_urls.get("Repository", "") 

78 if not repo_url: 

79 repo_url = project_urls.get("Source Code", "") 

80 docs_url = info.get("docs_url", "") 

81 if not docs_url: 

82 docs_url = project_urls.get("Documentation", "") 

83 

84 return PackageInfo( 

85 name=info["name"], 

86 registry=Registry.PYPI, 

87 description=info.get("summary", ""), 

88 author=info.get("author", ""), 

89 license=info.get("license", ""), 

90 homepage=homepage, 

91 repository_url=repo_url, 

92 documentation_url=docs_url, 

93 latest_version=latest_version, 

94 latest_release_date=upload_time, 

95 package_kind=_derive_kind(info.get("classifiers", []) or []), 

96 latest_release_files=release_files, 

97 ) 

98 

99 async def fetch_versions(self, name: str) -> list[Version]: 

100 async with self._client_scope() as client: 

101 data = await self._get_json(name, client) 

102 versions_data = data.get("releases", {}) 

103 

104 versions: list[Version] = [] 

105 for ver_str, files in versions_data.items(): 

106 upload_time = None 

107 for f in files: 

108 raw = f.get("upload_time", "") 

109 if raw: 

110 try: 

111 upload_time = datetime.fromisoformat( 

112 raw.replace("Z", "+00:00") 

113 ) 

114 break 

115 except (ValueError, TypeError): 

116 pass 

117 

118 yanked = any(f.get("yanked", False) for f in files) 

119 size = sum(f.get("size", 0) or 0 for f in files) or None 

120 

121 versions.append( 

122 Version( 

123 version=ver_str, 

124 release_date=upload_time, 

125 is_yanked=yanked, 

126 size_bytes=size, 

127 ) 

128 ) 

129 

130 versions.sort(key=lambda v: v.release_date or datetime.min, reverse=True) 

131 return versions 

132 

133 async def fetch_dependencies(self, name: str, version: str) -> list[Dependency]: 

134 async with self._client_scope() as client: 

135 data = await self._get_json(name, client) 

136 info = data["info"] 

137 requires_dist = info.get("requires_dist") or [] 

138 

139 deps: list[Dependency] = [] 

140 for raw in requires_dist: 

141 if not raw: 

142 continue 

143 if "extra ==" in raw: 

144 continue 

145 raw_clean = raw.split(";")[0].strip() 

146 if not raw_clean: 

147 continue 

148 parts = raw_clean.split() 

149 dep_name = parts[0].strip() 

150 requirement = " ".join(parts[1:]) if len(parts) > 1 else "" 

151 deps.append(Dependency(name=dep_name, requirement=requirement)) 

152 

153 return deps 

154 

155 async def fetch_download_trend( 

156 self, name: str, days: int = 30 

157 ) -> list[DownloadTrendPoint]: 

158 async with self._client_scope() as client: 

159 try: 

160 resp = await client.get( 

161 f"{PYPI_STATS}/packages/{name}/overall", 

162 params={"mirrors": "false"}, 

163 ) 

164 resp.raise_for_status() 

165 stats_data = resp.json() 

166 except httpx.HTTPError: 

167 return [] 

168 

169 raw_data = stats_data.get("data", []) 

170 points = [ 

171 DownloadTrendPoint( 

172 date=entry.get("date", ""), 

173 count=entry.get("downloads", 0), 

174 ) 

175 for entry in raw_data 

176 ] 

177 points.sort(key=lambda p: p.date) 

178 return points[-days:] if len(points) > days else points 

179 

180 async def fetch_download_counts(self, name: str) -> DownloadCounts: 

181 async with self._client_scope() as client: 

182 try: 

183 resp = await client.get( 

184 f"{PYPI_STATS}/packages/{name}/recent", 

185 params={"mirrors": "false"}, 

186 ) 

187 resp.raise_for_status() 

188 data = resp.json() 

189 period_data = data.get("data", {}) 

190 return DownloadCounts( 

191 today=period_data.get("last_day", 0), 

192 week=period_data.get("last_week", 0), 

193 month=period_data.get("last_month", 0), 

194 ) 

195 except httpx.HTTPError: 

196 pass 

197 

198 # Fallback: compute from trend data 

199 trend = await self.fetch_download_trend(name, days=30) 

200 if not trend: 

201 return DownloadCounts() 

202 return DownloadCounts( 

203 today=trend[-1].count if trend else 0, 

204 week=sum(p.count for p in trend[-7:]), 

205 month=sum(p.count for p in trend), 

206 ) 

207 

208 async def fetch_release_notes(self, name: str, version: str) -> str: 

209 return "" # fetched via GitHub in utils 

210 

211 async def search(self, query: str, limit: int = 10) -> list[SearchResult]: 

212 """Search PyPI's public search page (the JSON API has no search route).""" 

213 async with self._client_scope() as client: 

214 # PyPI's JSON API is dependable for exact package resolution even 

215 # when its HTML search page changes markup or is unavailable. 

216 try: 

217 exact_response = await client.get(f"{PYPI_JSON}/{query}/json") 

218 exact_response.raise_for_status() 

219 exact_info = exact_response.json().get("info", {}) 

220 return [ 

221 SearchResult( 

222 name=exact_info.get("name", query), 

223 registry=Registry.PYPI, 

224 version=exact_info.get("version", ""), 

225 description=exact_info.get("summary", "") or "", 

226 url=f"https://pypi.org/project/{exact_info.get('name', query)}/", 

227 score=1.0, 

228 exact=True, 

229 ) 

230 ] 

231 except (httpx.HTTPError, ValueError, KeyError): 

232 pass 

233 try: 

234 response = await client.get( 

235 "https://pypi.org/search/", 

236 params={"q": query, "page": 1}, 

237 headers={"Accept": "text/html"}, 

238 ) 

239 response.raise_for_status() 

240 except httpx.HTTPError: 

241 return [] 

242 

243 pattern = re.compile( 

244 r'data-project-url="([^"]+)"[^>]*>.*?' 

245 r'class="package-snippet__name">\s*([^<]+).*?' 

246 r'class="package-snippet__version">\s*([^<]+).*?' 

247 r'class="package-snippet__description">\s*([^<]*)', 

248 re.DOTALL, 

249 ) 

250 results: list[SearchResult] = [] 

251 for url, name, version, description in pattern.findall(response.text)[:limit]: 

252 clean_name = unescape(name).strip() 

253 results.append( 

254 SearchResult( 

255 name=clean_name, 

256 registry=Registry.PYPI, 

257 version=unescape(version).strip(), 

258 description=" ".join(unescape(description).split()), 

259 url=f"https://pypi.org{url}" if url.startswith("/") else url, 

260 exact=clean_name.lower() == query.lower(), 

261 score=1.0 if clean_name.lower() == query.lower() else 0.0, 

262 ) 

263 ) 

264 return results