Skip to main content

facto_core/registries/
pypi.rs

1use 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
50/// PEP 440 prerelease detection.
51/// Matches: .devN, .alphaN, .betaN, .rcN, aN, bN (where N is digits after version segment)
52fn is_prerelease(version: &str) -> bool {
53    let v = version.to_lowercase();
54    // PEP 440 long forms
55    if v.contains(".dev") || v.contains("alpha") || v.contains("beta") {
56        return true;
57    }
58    // Release candidate: ensure 'rc' is preceded by a digit (avoids matching words like "source")
59    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    // PEP 440 shorthand: 1.0a1, 1.0b2 (digit + a/b + digit)
66    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        // PyPI exposes no search API. The legacy XML-RPC `search` was
200        // permanently disabled, and the HTML search page is now served behind a
201        // bot challenge (HTTP 200 with no result markup), so scraping yields
202        // nothing. Returning an empty Vec would masquerade as "no matches";
203        // report search as unsupported instead of failing silently.
204        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] // requires network
249    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] // requires network
259    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        // PyPI has no search API; search must report NotSupported rather than
269        // silently returning an empty result set.
270        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}