Add client networking stack

This commit is contained in:
Charlie Marsh 2023-10-04 19:42:18 -04:00
parent 53607df7c6
commit 1a2f35801b
8 changed files with 308 additions and 4 deletions

View file

@ -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",
] }

View file

@ -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<ExitStatus> {
pub(crate) async fn install(src: &Path) -> Result<ExitStatus> {
// 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:#?}");
}
}

View file

@ -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 {

View file

@ -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" }

View file

@ -0,0 +1,5 @@
# `pypi-client`
A general-use client for interacting with PyPI.
Loosely modeled after Orogene's `oro-client`.

View file

@ -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<str>,
) -> Result<PackageDocument, PypiClientError> {
// 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<str>,
url: &Url,
) -> Result<String, PypiClientError> {
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<ArtifactInfo>,
}
#[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<String>,
pub requires_python: Option<String>,
pub dist_info_metadata: DistInfoMetadata,
pub yanked: Yanked,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(from = "Option<RawDistInfoMetadata>")]
pub struct DistInfoMetadata {
pub available: bool,
pub hash: Option<String>,
}
impl From<Option<RawDistInfoMetadata>> for DistInfoMetadata {
fn from(maybe_raw: Option<RawDistInfoMetadata>) -> 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<String, String>),
}
#[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<String>,
}
impl From<RawYanked> 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),
},
}
}
}

View file

@ -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<PathBuf>,
}
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<Path>) -> 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<Url>,
pub(crate) client: ClientWithMiddleware,
}
impl PypiClient {}

1
requirements.txt Normal file
View file

@ -0,0 +1 @@
flask==2.0