mirror of
https://github.com/astral-sh/uv.git
synced 2025-07-07 05:15:00 +00:00
Add client networking stack
This commit is contained in:
parent
53607df7c6
commit
1a2f35801b
8 changed files with 308 additions and 4 deletions
|
@ -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",
|
||||
] }
|
||||
|
|
|
@ -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:#?}");
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
16
crates/puffin-client/Cargo.toml
Normal file
16
crates/puffin-client/Cargo.toml
Normal 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" }
|
5
crates/puffin-client/README.md
Normal file
5
crates/puffin-client/README.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
# `pypi-client`
|
||||
|
||||
A general-use client for interacting with PyPI.
|
||||
|
||||
Loosely modeled after Orogene's `oro-client`.
|
182
crates/puffin-client/src/api/mod.rs
Normal file
182
crates/puffin-client/src/api/mod.rs
Normal 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),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
87
crates/puffin-client/src/lib.rs
Normal file
87
crates/puffin-client/src/lib.rs
Normal 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
1
requirements.txt
Normal file
|
@ -0,0 +1 @@
|
|||
flask==2.0
|
Loading…
Add table
Add a link
Reference in a new issue