diff --git a/crates/puffin-cli/Cargo.toml b/crates/puffin-cli/Cargo.toml index 81fa52925..c634ad4f8 100644 --- a/crates/puffin-cli/Cargo.toml +++ b/crates/puffin-cli/Cargo.toml @@ -7,9 +7,14 @@ edition = "2021" [dependencies] puffin-requirements = { path = "../puffin-requirements" } +puffin-client = { path = "../puffin-client" } anyhow = "1.0.75" clap = { version = "4.4.6", features = ["derive"] } colored = { version = "2.0.4" } memchr = { version = "2.6.4" } -pep508_rs = { version = "0.2.3" } +async-std = { version = "1.12.0", features = [ + "attributes", + "tokio1", + "unstable", +] } diff --git a/crates/puffin-cli/src/commands/install.rs b/crates/puffin-cli/src/commands/install.rs index fba3081cf..70ebf4848 100644 --- a/crates/puffin-cli/src/commands/install.rs +++ b/crates/puffin-cli/src/commands/install.rs @@ -2,18 +2,25 @@ use std::path::Path; use std::str::FromStr; use anyhow::Result; +use puffin_client::PypiClientBuilder; use crate::commands::ExitStatus; -pub(crate) fn install(src: &Path) -> Result { +pub(crate) async fn install(src: &Path) -> Result { // Read the `requirements.txt` from disk. let requirements_txt = std::fs::read_to_string(src)?; // Parse the `requirements.txt` into a list of requirements. let requirements = puffin_requirements::Requirements::from_str(&requirements_txt)?; + + // Instantiate a client. + let client = PypiClientBuilder::default().build(); + for requirement in requirements.iter() { + let packument = client.simple(&requirement.name).await?; #[allow(clippy::print_stdout)] { + println!("{:#?}", packument); println!("{requirement:#?}"); } } diff --git a/crates/puffin-cli/src/main.rs b/crates/puffin-cli/src/main.rs index 7089dd7bf..798f29994 100644 --- a/crates/puffin-cli/src/main.rs +++ b/crates/puffin-cli/src/main.rs @@ -28,11 +28,12 @@ struct InstallArgs { src: PathBuf, } -fn main() -> ExitCode { +#[async_std::main] +async fn main() -> ExitCode { let cli = Cli::parse(); let result = match &cli.command { - Commands::Install(install) => commands::install(&install.src), + Commands::Install(install) => commands::install(&install.src).await, }; match result { diff --git a/crates/puffin-client/Cargo.toml b/crates/puffin-client/Cargo.toml new file mode 100644 index 000000000..b6c5924a9 --- /dev/null +++ b/crates/puffin-client/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "puffin-client" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +http-cache-reqwest = "0.11.3" +reqwest = { version = "0.11.22", features = ["json", "gzip", "stream"] } +reqwest-middleware = "0.2.3" +reqwest-retry = "0.3.0" +serde = "1.0.188" +serde_json = "1.0.107" +thiserror = { version = "1.0.49" } +url = { version = "2.4.1" } diff --git a/crates/puffin-client/README.md b/crates/puffin-client/README.md new file mode 100644 index 000000000..ff33a5d9d --- /dev/null +++ b/crates/puffin-client/README.md @@ -0,0 +1,5 @@ +# `pypi-client` + +A general-use client for interacting with PyPI. + +Loosely modeled after Orogene's `oro-client`. diff --git a/crates/puffin-client/src/api/mod.rs b/crates/puffin-client/src/api/mod.rs new file mode 100644 index 000000000..7870a5b04 --- /dev/null +++ b/crates/puffin-client/src/api/mod.rs @@ -0,0 +1,182 @@ +use crate::PypiClient; +use reqwest::StatusCode; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use thiserror::Error; +use url::Url; + +#[derive(Debug, Error)] +pub enum PypiClientError { + /// An invalid URL was provided. + #[error(transparent)] + UrlParseError(#[from] url::ParseError), + + /// The package was not found in the registry. + /// + /// Make sure the package name is spelled correctly and that you've + /// configured the right registry to fetch it from. + #[error("Package `{1}` was not found in registry {0}.")] + PackageNotFound(Url, String), + + /// A generic request error happened while making a request. Refer to the + /// error message for more details. + #[error(transparent)] + RequestError(#[from] reqwest::Error), + + /// A generic request middleware error happened while making a request. + /// Refer to the error message for more details. + #[error(transparent)] + RequestMiddlewareError(#[from] reqwest_middleware::Error), + + #[error("Received some unexpected JSON. Unable to parse.")] + BadJson { + source: serde_json::Error, + url: String, + }, +} + +impl PypiClientError { + pub fn from_json_err(err: serde_json::Error, url: String) -> Self { + Self::BadJson { + source: err, + url: url.clone(), + } + } +} + +impl PypiClient { + pub async fn simple( + &self, + package_name: impl AsRef, + ) -> Result { + // Format the URL for PyPI. + let mut url = self.registry.join("simple")?.join(package_name.as_ref())?; + url.set_query(Some("format=application/vnd.pypi.simple.v1+json")); + + // Fetch from the registry. + let text = self.simple_impl(package_name, &url).await?; + + // Parse. + serde_json::from_str(&text) + .map_err(move |e| PypiClientError::from_json_err(e, url.to_string())) + } + + async fn simple_impl( + &self, + package_name: impl AsRef, + url: &Url, + ) -> Result { + Ok(self + .client + .get(url.clone()) + .send() + .await? + .error_for_status() + .map_err(|err| { + if err.status() == Some(StatusCode::NOT_FOUND) { + PypiClientError::PackageNotFound( + (*self.registry).clone(), + package_name.as_ref().to_string(), + ) + } else { + PypiClientError::RequestError(err) + } + })? + .text() + .await?) + } +} + +/// The metadata for a single package, including the pubishing versions and their artifacts. +/// +/// In npm, this is referred to as a "packument", which is a portmanteau of "package" and +/// "document". +#[derive(Debug, Serialize, Deserialize)] +pub struct PackageDocument { + pub meta: Meta, + pub artifacts: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Meta { + pub version: String, +} + +impl Default for Meta { + fn default() -> Self { + Self { + // According to the spec, clients SHOULD introspect each response for the repository + // version; if it doesn't exist, clients MUST assume that it is version 1.0. + version: "1.0".into(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ArtifactInfo { + pub name: String, + pub url: Url, + pub hash: Option, + pub requires_python: Option, + pub dist_info_metadata: DistInfoMetadata, + pub yanked: Yanked, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(from = "Option")] +pub struct DistInfoMetadata { + pub available: bool, + pub hash: Option, +} + +impl From> for DistInfoMetadata { + fn from(maybe_raw: Option) -> Self { + match maybe_raw { + None => Default::default(), + Some(raw) => match raw { + RawDistInfoMetadata::NoHashes(available) => Self { + available, + hash: None, + }, + RawDistInfoMetadata::WithHashes(_) => Self { + available: true, + hash: None, + }, + }, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +enum RawDistInfoMetadata { + NoHashes(bool), + WithHashes(HashMap), +} + +#[derive(Debug, Clone, Deserialize)] +enum RawYanked { + NoReason(bool), + WithReason(String), +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(from = "RawYanked")] +pub struct Yanked { + pub yanked: bool, + pub reason: Option, +} + +impl From for Yanked { + fn from(raw: RawYanked) -> Self { + match raw { + RawYanked::NoReason(yanked) => Self { + yanked, + reason: None, + }, + RawYanked::WithReason(reason) => Self { + yanked: true, + reason: Some(reason), + }, + } + } +} diff --git a/crates/puffin-client/src/lib.rs b/crates/puffin-client/src/lib.rs new file mode 100644 index 000000000..c9b3b8a54 --- /dev/null +++ b/crates/puffin-client/src/lib.rs @@ -0,0 +1,87 @@ +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use http_cache_reqwest::{CACacheManager, Cache, CacheMode, HttpCache, HttpCacheOptions}; +use reqwest::ClientBuilder; +use reqwest_middleware::ClientWithMiddleware; +use reqwest_retry::policies::ExponentialBackoff; +use reqwest_retry::RetryTransientMiddleware; +use url::Url; + +mod api; + +fn main() { + println!("Hello, world!"); +} + +#[derive(Debug, Clone)] +pub struct PypiClientBuilder { + registry: Url, + retries: u32, + cache: Option, +} + +impl Default for PypiClientBuilder { + fn default() -> Self { + Self { + registry: Url::parse("https://pypi.org").unwrap(), + cache: None, + retries: 0, + } + } +} + +impl PypiClientBuilder { + pub fn registry(mut self, registry: Url) -> Self { + self.registry = registry; + self + } + + pub fn retries(mut self, retries: u32) -> Self { + self.retries = retries; + self + } + + pub fn cache(mut self, cache: impl AsRef) -> Self { + self.cache = Some(PathBuf::from(cache.as_ref())); + self + } + + pub fn build(self) -> PypiClient { + let client_raw = { + let mut client_core = ClientBuilder::new() + .user_agent("puffin") + .pool_max_idle_per_host(20) + .timeout(std::time::Duration::from_secs(60 * 5)); + + client_core.build().expect("Fail to build HTTP client.") + }; + + let retry_policy = ExponentialBackoff::builder().build_with_max_retries(self.retries); + let retry_strategy = RetryTransientMiddleware::new_with_policy(retry_policy); + + let mut client_builder = + reqwest_middleware::ClientBuilder::new(client_raw.clone()).with(retry_strategy); + + if let Some(path) = self.cache { + client_builder = client_builder.with(Cache(HttpCache { + mode: CacheMode::Default, + manager: CACacheManager { path }, + options: HttpCacheOptions::default(), + })); + } + + PypiClient { + registry: Arc::new(self.registry), + client: client_builder.build(), + } + } +} + +#[derive(Debug, Clone)] +pub struct PypiClient { + pub(crate) registry: Arc, + pub(crate) client: ClientWithMiddleware, +} + +impl PypiClient {} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 000000000..8f0a64ae0 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +flask==2.0