mirror of
https://github.com/astral-sh/uv.git
synced 2025-07-07 21:35:00 +00:00

Mocks out the PyPI client using some checked-in fixtures. The test is very basic, and I'm not very happy with all the ceremony around the mocks and such, but it's an interesting experiment at least.
187 lines
5.3 KiB
Rust
187 lines
5.3 KiB
Rust
use std::fmt::Debug;
|
|
|
|
use futures::{AsyncRead, StreamExt, TryStreamExt};
|
|
use reqwest::StatusCode;
|
|
use serde::{Deserialize, Serialize};
|
|
use tracing::trace;
|
|
use url::Url;
|
|
|
|
use puffin_package::metadata::Metadata21;
|
|
use puffin_package::package_name::PackageName;
|
|
|
|
use crate::client::PypiClient;
|
|
use crate::error::PypiClientError;
|
|
|
|
impl PypiClient {
|
|
/// Fetch a package from the `PyPI` simple API.
|
|
pub async fn simple(
|
|
&self,
|
|
package_name: impl AsRef<str>,
|
|
) -> Result<SimpleJson, PypiClientError> {
|
|
// Format the URL for PyPI.
|
|
let mut url = self.registry.join("simple")?;
|
|
url.path_segments_mut()
|
|
.unwrap()
|
|
.push(PackageName::normalize(&package_name).as_ref());
|
|
url.path_segments_mut().unwrap().push("");
|
|
url.set_query(Some("format=application/vnd.pypi.simple.v1+json"));
|
|
|
|
trace!(
|
|
"fetching metadata for {} from {}",
|
|
package_name.as_ref(),
|
|
url
|
|
);
|
|
|
|
// Fetch from the registry.
|
|
let text = self.simple_impl(&package_name, &url).await?;
|
|
serde_json::from_str(&text)
|
|
.map_err(move |e| PypiClientError::from_json_err(e, String::new()))
|
|
}
|
|
|
|
async fn simple_impl(
|
|
&self,
|
|
package_name: impl AsRef<str>,
|
|
url: &Url,
|
|
) -> Result<String, PypiClientError> {
|
|
Ok(self
|
|
.client
|
|
.get(url.clone())
|
|
.header("Accept-Encoding", "gzip")
|
|
.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?)
|
|
}
|
|
|
|
/// Fetch the metadata from a wheel file.
|
|
pub async fn file(&self, file: File) -> Result<Metadata21, PypiClientError> {
|
|
// Per PEP 658, if `data-dist-info-metadata` is available, we can request it directly;
|
|
// otherwise, send to our dedicated caching proxy.
|
|
let url = if file.data_dist_info_metadata.is_available() {
|
|
Url::parse(&format!("{}.metadata", file.url))?
|
|
} else {
|
|
self.proxy.join(file.url.parse::<Url>()?.path())?
|
|
};
|
|
|
|
trace!("fetching file {} from {}", file.filename, url);
|
|
|
|
// Fetch from the registry.
|
|
let text = self.file_impl(&file.filename, &url).await?;
|
|
Metadata21::parse(text.as_bytes()).map_err(std::convert::Into::into)
|
|
}
|
|
|
|
async fn file_impl(
|
|
&self,
|
|
filename: 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::FileNotFound(
|
|
(*self.registry).clone(),
|
|
filename.as_ref().to_string(),
|
|
)
|
|
} else {
|
|
PypiClientError::RequestError(err)
|
|
}
|
|
})?
|
|
.text()
|
|
.await?)
|
|
}
|
|
|
|
/// Stream a file from an external URL.
|
|
pub async fn stream_external(
|
|
&self,
|
|
url: &Url,
|
|
) -> Result<Box<dyn AsyncRead + Unpin + Send + Sync>, PypiClientError> {
|
|
Ok(Box::new(
|
|
self.uncached_client
|
|
.get(url.to_string())
|
|
.send()
|
|
.await?
|
|
.error_for_status()?
|
|
.bytes_stream()
|
|
.map(|r| match r {
|
|
Ok(bytes) => Ok(bytes),
|
|
Err(err) => Err(std::io::Error::new(std::io::ErrorKind::Other, err)),
|
|
})
|
|
.into_async_read(),
|
|
))
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct SimpleJson {
|
|
pub files: Vec<File>,
|
|
pub meta: Meta,
|
|
pub name: String,
|
|
pub versions: Vec<String>,
|
|
}
|
|
|
|
// TODO(charlie): Can we rename this? What does this look like for source distributions?
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "kebab-case")]
|
|
pub struct File {
|
|
pub core_metadata: Metadata,
|
|
pub data_dist_info_metadata: Metadata,
|
|
pub filename: String,
|
|
pub hashes: Hashes,
|
|
pub requires_python: Option<String>,
|
|
pub size: usize,
|
|
pub upload_time: String,
|
|
pub url: String,
|
|
pub yanked: Yanked,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(untagged)]
|
|
pub enum Metadata {
|
|
Bool(bool),
|
|
Hashes(Hashes),
|
|
}
|
|
|
|
impl Metadata {
|
|
pub fn is_available(&self) -> bool {
|
|
match self {
|
|
Self::Bool(is_available) => *is_available,
|
|
Self::Hashes(_) => true,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(untagged)]
|
|
pub enum Yanked {
|
|
Bool(bool),
|
|
Reason(String),
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct Hashes {
|
|
pub sha256: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "kebab-case")]
|
|
pub struct Meta {
|
|
#[serde(rename = "_last-serial")]
|
|
pub last_serial: i64,
|
|
pub api_version: String,
|
|
}
|