1use std::fmt;
2use chrono::prelude::*;
3use std::convert::From;
4use lazy_static::lazy_static;
5use scraper::{Html, Selector, ElementRef};
6use tokio::sync::mpsc;
7use tokio::process::Command;
8use anyhow::Result;
9use tabled::Tabled;
10use serde::{Serialize, Deserialize};
11use thousands::Separable;
12use backoff::ExponentialBackoff;
13use backoff::future::retry;
14use std::cmp::Ordering;
15
16lazy_static! {
17 static ref NAME_SELECTOR: Selector = Selector::parse("span.package-snippet__name").unwrap();
18 static ref VERSION_SELECTOR: Selector = Selector::parse("span.package-snippet__version").unwrap();
19 static ref RELEASE_SELECTOR: Selector = Selector::parse("span.package-snippet__released").unwrap();
20 static ref DESCRIPTION_SELECTOR: Selector = Selector::parse("p.package-snippet__description").unwrap();
21 static ref DATETIME_SELECTOR: Selector = Selector::parse("time").unwrap();
22}
23
24fn unwrap_selector(input: &ElementRef, selector: &Selector) -> String {
25 input.select(selector).next().map(|e| e.inner_html()).unwrap_or("".into())
26}
27
28fn unwrap_time_selector(input: &ElementRef) -> Option<DateTime<Utc>> {
29 input.select(&RELEASE_SELECTOR).next()
30 .and_then(|release| release.select(&DATETIME_SELECTOR).next())
31 .and_then(|time| time.value().attr("datetime"))
32 .and_then(|dt| dt.parse::<DateTime<Utc>>().ok())
33}
34
35fn format_date(release: &DateTime<Utc>) -> String {
36 release.format("%Y-%m-%d").to_string()
37}
38
39#[derive(Serialize,Deserialize,Debug,PartialEq,Eq)]
40pub struct Downloads {
41 last_day: u64,
42 last_week: u64,
43 last_month: u64,
44}
45
46impl fmt::Display for Downloads {
47 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
48 write!(f, "{} / {} / {}",
49 self.last_day.separate_with_commas(),
50 self.last_week.separate_with_commas(),
51 self.last_month.separate_with_commas()
52 )
53 }
54}
55
56impl Ord for Downloads {
57 fn cmp(&self, other: &Self) -> Ordering {
58 self.last_month.cmp(&other.last_month)
59 }
60}
61
62impl PartialOrd for Downloads {
63 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
64 Some(self.cmp(other))
65 }
66}
67
68
69#[derive(Serialize,Deserialize)]
70struct DownloadsResponse {
71 data: Downloads,
72 package: String,
73 #[serde(rename(deserialize = "type", serialize = "type"))]
74 response_type: String,
75}
76
77
78#[derive(Debug, PartialEq)]
79pub struct LocalPackage {
80 pub name: String,
81 pub version: String,
82}
83
84impl LocalPackage {
85 fn from_str(input: &str) -> Result<LocalPackage> {
86 let fields: Vec<&str> = input.split_whitespace().collect();
87 Ok(LocalPackage{
88 name: fields[0].into(),
89 version: fields[1].into()
90 })
91 }
92}
93
94fn display_installed(possible_version: &Option<String>) -> String {
95 possible_version
96 .as_ref()
97 .unwrap_or(&String::from(""))
98 .into()
99}
100
101fn display_downloads(possible_downloads: &Option<Downloads>) -> String {
102 match possible_downloads {
103 Some(d) => format!("{}", d),
104 None => "".into()
105 }
106}
107
108#[derive(Debug,Tabled)]
109pub struct Package {
110 #[header("Name")]
111 pub name: String,
112 #[header("Installed")]
113 #[field(display_with="display_installed")]
114 pub installed: Option<String>,
115 #[header("Version")]
116 pub version: String,
117 #[header("Released")]
118 #[field(display_with="format_date")]
119 pub release: DateTime<Utc>,
120 #[header("Description")]
121 pub description: String,
122 #[header("Downloads")]
123 #[field(display_with="display_downloads")]
124 pub downloads: Option<Downloads>,
125}
126
127impl From<&ElementRef<'_>> for Package {
128 fn from(input: &ElementRef) -> Self {
129 let release = unwrap_time_selector(input);
130 Package{
131 name: unwrap_selector(input, &NAME_SELECTOR),
132 installed: None,
133 version: unwrap_selector(input, &VERSION_SELECTOR),
134 release: release.unwrap(),
135 description: unwrap_selector(input, &DESCRIPTION_SELECTOR),
136 downloads: None,
137 }
138 }
139
140}
141
142async fn get_with_retry(url: &str) -> Result<String> {
143 Ok(retry(ExponentialBackoff::default(), || async {
144 let body = reqwest::get(url)
145 .await?
146 .error_for_status()?
147 .text()
148 .await?;
149 Ok(body)
150 }).await?)
151}
152
153impl Package {
154 pub fn local(&mut self, version: &str) {
155 self.installed = Some(version.into())
156 }
157
158 pub async fn update_downloads(&mut self) {
159
160 let url = format!("https://pypistats.org/api/packages/{}/recent", self.name.to_lowercase());
161
162 let body: String = get_with_retry(&url).await.unwrap();
163 let data: DownloadsResponse = serde_json::from_str(&body).unwrap();
164 self.downloads = Some(data.data);
165 }
166}
167
168
169pub async fn query_pypi(name: String, pages: usize) -> Result<Vec<Package>>{
170 let client = reqwest::Client::new();
171 let (tx, mut rx) = mpsc::channel(32);
172
173 let package_snippet = Selector::parse("a.package-snippet").unwrap();
174
175 tokio::spawn(async move {
176 for page_idx in (1..=pages).map(|i| i.to_string()) {
177 let query_params = vec![("q", &name), ("page", &page_idx)];
178
179 let page_body = client.get("https://pypi.org/search/")
180 .query(&query_params)
181 .send()
182 .await;
183 tx.send(page_body).await.expect("can send on package channel");
184 }
185 });
186
187 let mut packages = vec![];
188
189 while let Some(response) = rx.recv().await {
190 let page_body = response?.text().await?;
191 let page_result = Html::parse_document(&page_body);
192 for element in page_result.select(&package_snippet) {
193 let package = Package::from(&element);
194 packages.push(package);
195 }
196 }
197
198 Ok(packages)
199}
200
201pub async fn get_installed_packages() -> Result<Vec<LocalPackage>> {
202 let output = Command::new("pip")
203 .arg("list")
204 .arg("installed")
205 .output()
206 .await?;
207
208 let stdout: String = String::from_utf8(output.stdout)?;
209
210 let mut out = vec![];
211 for line in stdout.lines().skip(2) {
212 out.push(LocalPackage::from_str(line)?)
213 }
214 Ok(out)
215}
216
217pub async fn get_downloads(package_name: &str) -> Result<Downloads> {
218 let url = format!("https://pypistats.org/api/packages/{}/recent", package_name);
219 let data: DownloadsResponse = reqwest::get(&url)
220 .await?
221 .json()
222 .await?;
223 Ok(data.data)
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229
230 #[test]
231 fn parse_package_data_test() {
232 let input = r#"
233 <a class="package-snippet" href="/project/gitlab3/">
234 <h3 class="package-snippet__title">
235 <span class="package-snippet__name">gitlab3</span>
236 <span class="package-snippet__version">0.5.8</span>
237 <span class="package-snippet__released"><time datetime="2017-03-18T19:38:52+0000" data-controller="localized-time" data-localized-time-relative="true" data-localized-time-show-time="false" title="2017-03-18 20:38:52" aria-label="2017-03-18 20:38:52">Mar 18, 2017</time></span>
238 </h3>
239 <p class="package-snippet__description">GitLab API v3 Python Wrapper.</p>
240 </a>"#;
241 let page = Html::parse_fragment(input);
242 let snippet = page.root_element();
243 let package = Package::from(&snippet);
244
245 assert_eq!(package.name, "gitlab3");
246 assert_eq!(package.version, "0.5.8");
247 assert_eq!(package.release, "2017-03-18T19:38:52+0000".parse::<DateTime<Utc>>().unwrap());
248 assert_eq!(package.description, "GitLab API v3 Python Wrapper.");
249 }
250
251 #[tokio::test]
252 async fn get_installed_packages_test() {
253 let installed_packages = get_installed_packages().await.unwrap();
254 let pip_package = LocalPackage{ name: "pip".into(), version: "21.1.2".into() };
255 assert!(installed_packages.contains(&pip_package));
256 }
257
258 #[tokio::test]
259 async fn get_downloads_test() {
260 let package_name = "python-gitlab";
261 let downloads = get_downloads(package_name).await.unwrap();
262 assert!(downloads.last_month > 0);
263 }
264}