facto_core/registries/
pypi.rs1use std::future::Future;
2use std::pin::Pin;
3
4use crate::models::*;
5use crate::normalize::pypi_normalize;
6use crate::registries::{Registry, RegistryError, RegistryResult};
7
8pub struct PyPI {
9 client: reqwest::Client,
10}
11
12impl PyPI {
13 pub fn new(client: reqwest::Client) -> Self {
14 Self { client }
15 }
16}
17
18#[derive(serde::Deserialize)]
19struct PyPIJsonResponse {
20 info: PyPIInfo,
21 releases: std::collections::HashMap<String, Vec<PyPIRelease>>,
22}
23
24#[derive(serde::Deserialize)]
25struct PyPIInfo {
26 name: String,
27 version: Option<String>,
28 summary: Option<String>,
29 license: Option<String>,
30 home_page: Option<String>,
31 author: Option<String>,
32 #[expect(
33 dead_code,
34 reason = "deserialized from the API response, not yet surfaced"
35 )]
36 author_email: Option<String>,
37 project_urls: Option<std::collections::HashMap<String, String>>,
38 #[serde(default)]
39 classifiers: Vec<String>,
40 #[serde(default)]
41 keywords: Option<String>,
42 requires_python: Option<String>,
43}
44
45#[derive(serde::Deserialize)]
46struct PyPIRelease {
47 upload_time_iso_8601: Option<String>,
48}
49
50fn is_prerelease(version: &str) -> bool {
53 let v = version.to_lowercase();
54 if v.contains(".dev") || v.contains("alpha") || v.contains("beta") {
56 return true;
57 }
58 if let Some(pos) = v.find("rc")
60 && pos > 0
61 && v.as_bytes()[pos - 1].is_ascii_digit()
62 {
63 return true;
64 }
65 let bytes = v.as_bytes();
67 for i in 1..bytes.len().saturating_sub(1) {
68 if (bytes[i] == b'a' || bytes[i] == b'b')
69 && bytes[i - 1].is_ascii_digit()
70 && bytes[i + 1].is_ascii_digit()
71 {
72 return true;
73 }
74 }
75 false
76}
77
78impl Registry for PyPI {
79 fn id(&self) -> &str {
80 "pypi"
81 }
82
83 fn display_name(&self) -> &str {
84 "PyPI"
85 }
86
87 fn get_package<'a>(
88 &'a self,
89 name: &'a str,
90 ) -> Pin<Box<dyn Future<Output = RegistryResult<PackageInfo>> + Send + 'a>> {
91 Box::pin(async move {
92 let name = pypi_normalize(name);
93 let url = format!("https://pypi.org/pypi/{}/json", urlencoding::encode(&name));
94 let resp = self.client.get(&url).send().await?;
95
96 if resp.status().as_u16() == 404 {
97 return Err(RegistryError::NotFound);
98 }
99
100 let data: PyPIJsonResponse = crate::registries::bounded_json(resp).await?;
101
102 let repository = data
103 .info
104 .project_urls
105 .as_ref()
106 .and_then(|urls| {
107 urls.get("Repository")
108 .or_else(|| urls.get("Source"))
109 .or_else(|| urls.get("Source Code"))
110 .or_else(|| urls.get("GitHub"))
111 })
112 .cloned();
113
114 let mut authors = Vec::new();
115 if let Some(author) = &data.info.author
116 && !author.is_empty()
117 {
118 authors.push(author.clone());
119 }
120
121 let updated_at = data
122 .releases
123 .get(data.info.version.as_deref().unwrap_or(""))
124 .and_then(|files| files.first())
125 .and_then(|f| f.upload_time_iso_8601.as_ref())
126 .and_then(|ts| ts.parse().ok());
127
128 let keywords: Vec<String> = data
129 .info
130 .keywords
131 .as_deref()
132 .unwrap_or("")
133 .split(',')
134 .map(|s| s.trim().to_string())
135 .filter(|s| !s.is_empty())
136 .collect();
137
138 Ok(PackageInfo {
139 name: data.info.name,
140 registry: "pypi".into(),
141 latest_version: data.info.version,
142 description: data.info.summary,
143 license: data.info.license,
144 homepage: data.info.home_page,
145 repository,
146 authors,
147 updated_at,
148 keywords,
149 classifiers: data.info.classifiers,
150 requires_python: data.info.requires_python,
151 })
152 })
153 }
154
155 fn get_versions<'a>(
156 &'a self,
157 name: &'a str,
158 ) -> Pin<Box<dyn Future<Output = RegistryResult<Vec<VersionInfo>>> + Send + 'a>> {
159 Box::pin(async move {
160 let name = pypi_normalize(name);
161 let url = format!("https://pypi.org/pypi/{}/json", urlencoding::encode(&name));
162 let resp = self.client.get(&url).send().await?;
163
164 if resp.status().as_u16() == 404 {
165 return Err(RegistryError::NotFound);
166 }
167
168 let data: PyPIJsonResponse = crate::registries::bounded_json(resp).await?;
169
170 let mut versions: Vec<VersionInfo> = data
171 .releases
172 .iter()
173 .map(|(version, files)| {
174 let released_at = files
175 .first()
176 .and_then(|f| f.upload_time_iso_8601.as_ref())
177 .and_then(|ts| ts.parse().ok());
178
179 let prerelease = is_prerelease(version);
180
181 VersionInfo {
182 version: version.clone(),
183 released_at,
184 prerelease,
185 }
186 })
187 .collect();
188
189 crate::registries::sort_versions_semver(&mut versions);
190 Ok(versions)
191 })
192 }
193
194 fn search<'a>(
195 &'a self,
196 _query: &'a str,
197 _limit: usize,
198 ) -> Pin<Box<dyn Future<Output = RegistryResult<Vec<SearchResult>>> + Send + 'a>> {
199 Box::pin(async { Err(RegistryError::NotSupported) })
205 }
206}
207
208#[cfg(test)]
209mod tests {
210 use super::*;
211
212 #[test]
213 fn test_is_prerelease_detects_dev() {
214 assert!(is_prerelease("1.0.0.dev1"));
215 assert!(is_prerelease("1.0.dev0"));
216 }
217
218 #[test]
219 fn test_is_prerelease_detects_rc() {
220 assert!(is_prerelease("1.0.0rc1"));
221 assert!(is_prerelease("2.0rc2"));
222 }
223
224 #[test]
225 fn test_is_prerelease_detects_alpha_beta() {
226 assert!(is_prerelease("1.0.0alpha1"));
227 assert!(is_prerelease("1.0.0beta2"));
228 assert!(is_prerelease("1.0a1"));
229 assert!(is_prerelease("1.0b2"));
230 }
231
232 #[test]
233 fn test_is_prerelease_rejects_stable() {
234 assert!(!is_prerelease("1.0.0"));
235 assert!(!is_prerelease("2.31.0"));
236 assert!(!is_prerelease("0.1.0"));
237 assert!(!is_prerelease("10.0"));
238 }
239
240 #[test]
241 fn test_is_prerelease_rejects_false_positives() {
242 assert!(!is_prerelease("1.0.0"));
243 assert!(!is_prerelease("3.2.1"));
244 assert!(!is_prerelease("1.0.0.post1"));
245 }
246
247 #[tokio::test]
248 #[ignore] async fn test_get_package() {
250 let pypi = PyPI::new(crate::http::default_client().unwrap());
251 let pkg = pypi.get_package("requests").await.unwrap();
252 assert_eq!(pkg.name, "requests");
253 assert_eq!(pkg.registry, "pypi");
254 assert!(pkg.latest_version.is_some());
255 }
256
257 #[tokio::test]
258 #[ignore] async fn test_get_versions() {
260 let pypi = PyPI::new(crate::http::default_client().unwrap());
261 let versions = pypi.get_versions("requests").await.unwrap();
262 assert!(!versions.is_empty());
263 assert!(!versions[0].version.is_empty());
264 }
265
266 #[tokio::test]
267 async fn test_search_unsupported() {
268 let pypi = PyPI::new(crate::http::default_client().unwrap());
271 let err = pypi.search("http client", 5).await.unwrap_err();
272 assert!(matches!(err, RegistryError::NotSupported));
273 }
274}