1use std::collections::HashMap;
7
8use serde::Deserialize;
9
10use crate::{Error, Result};
11
12const PYPI_API_URL: &str = "https://pypi.org/pypi";
14
15pub struct PyPIClient {
17 client: reqwest::Client,
18}
19
20impl PyPIClient {
21 pub fn new() -> Self {
23 Self {
24 client: reqwest::Client::builder()
25 .user_agent("Pro/0.1.0")
26 .build()
27 .expect("Failed to create HTTP client"),
28 }
29 }
30
31 pub async fn is_yanked(&self, package: &str, version: &str) -> Result<bool> {
33 let url = format!("{}/{}/json", PYPI_API_URL, package);
34
35 let response = self.client.get(&url).send().await.map_err(Error::Network)?;
36
37 if !response.status().is_success() {
38 return Ok(false);
40 }
41
42 let pkg_info: PyPIPackageInfo = response.json().await.map_err(Error::Network)?;
43
44 if let Some(releases) = pkg_info.releases.get(version) {
46 if releases.is_empty() {
49 return Ok(false);
50 }
51 return Ok(releases.iter().all(|r| r.yanked));
52 }
53
54 Ok(false)
55 }
56
57 pub async fn check_yanked_batch(
59 &self,
60 packages: &[(&str, &str)], ) -> Result<Vec<YankedPackage>> {
62 let mut yanked = Vec::new();
63
64 let semaphore = std::sync::Arc::new(tokio::sync::Semaphore::new(8));
66 let mut handles = Vec::new();
67
68 for (name, version) in packages {
69 let name = name.to_string();
70 let version = version.to_string();
71 let client = self.client.clone();
72 let semaphore = semaphore.clone();
73
74 let handle = tokio::spawn(async move {
75 let _permit = semaphore.acquire().await.unwrap();
76 check_single_package(&client, &name, &version).await
77 });
78 handles.push(handle);
79 }
80
81 for handle in handles {
82 if let Ok(Ok(Some(yanked_pkg))) = handle.await {
83 yanked.push(yanked_pkg);
84 }
85 }
86
87 Ok(yanked)
88 }
89}
90
91impl Default for PyPIClient {
92 fn default() -> Self {
93 Self::new()
94 }
95}
96
97async fn check_single_package(
99 client: &reqwest::Client,
100 package: &str,
101 version: &str,
102) -> Result<Option<YankedPackage>> {
103 let url = format!("{}/{}/json", PYPI_API_URL, package);
104
105 let response = client.get(&url).send().await.map_err(Error::Network)?;
106
107 if !response.status().is_success() {
108 return Ok(None);
109 }
110
111 let pkg_info: PyPIPackageInfo = response.json().await.map_err(Error::Network)?;
112
113 if let Some(releases) = pkg_info.releases.get(version) {
114 if !releases.is_empty() && releases.iter().all(|r| r.yanked) {
115 let reason = releases.first().and_then(|r| r.yanked_reason.clone());
117 return Ok(Some(YankedPackage {
118 name: package.to_string(),
119 version: version.to_string(),
120 reason,
121 }));
122 }
123 }
124
125 Ok(None)
126}
127
128#[derive(Debug, Clone)]
130pub struct YankedPackage {
131 pub name: String,
133 pub version: String,
135 pub reason: Option<String>,
137}
138
139#[derive(Debug, Deserialize)]
142struct PyPIPackageInfo {
143 releases: HashMap<String, Vec<PyPIRelease>>,
144}
145
146#[derive(Debug, Deserialize)]
147struct PyPIRelease {
148 yanked: bool,
149 yanked_reason: Option<String>,
150}
151
152#[cfg(test)]
153mod tests {
154 use super::*;
155
156 #[tokio::test]
157 #[ignore] async fn test_check_not_yanked() {
159 let client = PyPIClient::new();
160 let yanked = client.is_yanked("requests", "2.31.0").await.unwrap();
162 assert!(!yanked);
163 }
164}