From 0f10595ac368efc5aece9cff5e9bf5f8b0a453d0 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Wed, 4 Oct 2023 20:44:59 -0400 Subject: [PATCH] Add version selection --- crates/puffin-cli/Cargo.toml | 1 + crates/puffin-cli/src/commands/install.rs | 34 +++++++++-- crates/puffin-client/src/api/mod.rs | 42 ++++++------- crates/puffin-client/src/lib.rs | 2 + crates/puffin-requirements/Cargo.toml | 3 + crates/puffin-requirements/src/lib.rs | 2 + crates/puffin-requirements/src/wheel.rs | 73 +++++++++++++++++++++++ requirements.txt | 3 - 8 files changed, 131 insertions(+), 29 deletions(-) create mode 100644 crates/puffin-requirements/src/wheel.rs diff --git a/crates/puffin-cli/Cargo.toml b/crates/puffin-cli/Cargo.toml index 47d6ac0c1..e4df72004 100644 --- a/crates/puffin-cli/Cargo.toml +++ b/crates/puffin-cli/Cargo.toml @@ -19,3 +19,4 @@ async-std = { version = "1.12.0", features = [ "unstable", ] } futures = "0.3.28" +pep508_rs = "0.2.3" diff --git a/crates/puffin-cli/src/commands/install.rs b/crates/puffin-cli/src/commands/install.rs index fdd8a2980..897279569 100644 --- a/crates/puffin-cli/src/commands/install.rs +++ b/crates/puffin-cli/src/commands/install.rs @@ -3,8 +3,10 @@ use std::str::FromStr; use anyhow::Result; use futures::{StreamExt, TryFutureExt}; +use pep508_rs::VersionOrUrl; -use puffin_client::PypiClientBuilder; +use puffin_client::{PypiClientBuilder, SimpleJson}; +use puffin_requirements::wheel::WheelName; use puffin_requirements::Requirement; use crate::commands::ExitStatus; @@ -26,7 +28,7 @@ pub(crate) async fn install(src: &Path) -> Result { let mut package_stream = package_stream .map(|requirement: Requirement| { client - .simple(requirement.clone().name) + .simple(requirement.name.clone()) .map_ok(move |metadata| (metadata, requirement)) }) .buffer_unordered(32) @@ -42,11 +44,33 @@ pub(crate) async fn install(src: &Path) -> Result { while let Some(chunk) = package_stream.next().await { in_flight -= chunk.len(); for result in chunk { - let (metadata, requirement) = result?; + let (metadata, requirement): (SimpleJson, Requirement) = result?; + + // TODO(charlie): Support URLs. Right now, we treat a URL as an unpinned dependency. + let specifiers = requirement.version_or_url.and_then(|version_or_url| { + match version_or_url { + VersionOrUrl::VersionSpecifier(specifiers) => Some(specifiers), + VersionOrUrl::Url(_) => None, + } + }); + + // Pick a version that satisfies the requirement. + let Some(file) = metadata.files.iter().rev().find(|file| { + // We only support wheels for now. + let Ok(name) = WheelName::from_str(file.filename.as_str()) else { + return false; + }; + + specifiers + .iter() + .all(|specifier| specifier.contains(&name.version)) + }) else { + continue; + }; + #[allow(clippy::print_stdout)] { - println!("{metadata:#?}"); - println!("{requirement:#?}"); + println!("{}: {:?}", requirement.name, file); } } diff --git a/crates/puffin-client/src/api/mod.rs b/crates/puffin-client/src/api/mod.rs index 4f6a5ac82..eced665d6 100644 --- a/crates/puffin-client/src/api/mod.rs +++ b/crates/puffin-client/src/api/mod.rs @@ -50,49 +50,49 @@ impl PypiClient { #[derive(Debug, Serialize, Deserialize)] pub struct SimpleJson { - files: Vec, - meta: Meta, - name: String, - versions: Vec, + pub files: Vec, + pub meta: Meta, + pub name: String, + pub versions: Vec, } #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] -pub(crate) struct File { - core_metadata: Metadata, - data_dist_info_metadata: Metadata, - filename: String, - hashes: Hashes, - requires_python: Option, - size: i64, - upload_time: String, - url: String, - yanked: Yanked, +pub struct File { + pub core_metadata: Metadata, + pub data_dist_info_metadata: Metadata, + pub filename: String, + pub hashes: Hashes, + pub requires_python: Option, + pub size: i64, + pub upload_time: String, + pub url: String, + pub yanked: Yanked, } #[derive(Debug, Serialize, Deserialize)] #[serde(untagged)] -pub(crate) enum Metadata { +pub enum Metadata { Bool(bool), Hashes(Hashes), } #[derive(Debug, Serialize, Deserialize)] #[serde(untagged)] -pub(crate) enum Yanked { +pub enum Yanked { Bool(bool), Reason(String), } #[derive(Debug, Serialize, Deserialize)] -pub(crate) struct Hashes { - sha256: String, +pub struct Hashes { + pub sha256: String, } #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] -pub(crate) struct Meta { +pub struct Meta { #[serde(rename = "_last-serial")] - last_serial: i64, - api_version: String, + pub last_serial: i64, + pub api_version: String, } diff --git a/crates/puffin-client/src/lib.rs b/crates/puffin-client/src/lib.rs index 95806a290..d6c17252e 100644 --- a/crates/puffin-client/src/lib.rs +++ b/crates/puffin-client/src/lib.rs @@ -11,6 +11,8 @@ use url::Url; mod api; mod error; +pub use api::SimpleJson; + #[derive(Debug, Clone)] pub struct PypiClientBuilder { registry: Url, diff --git a/crates/puffin-requirements/Cargo.toml b/crates/puffin-requirements/Cargo.toml index 01fbaec4d..37b76b1b2 100644 --- a/crates/puffin-requirements/Cargo.toml +++ b/crates/puffin-requirements/Cargo.toml @@ -11,7 +11,10 @@ clap = { version = "4.4.6", features = ["derive"] } colored = { version = "2.0.4" } insta = "1.33.0" memchr = { version = "2.6.4" } +once_cell = "1.18.0" +pep440_rs = "0.3.12" pep508_rs = { version = "0.2.3" } +regex = "1.9.6" [dev-dependencies] criterion = "0.5.1" diff --git a/crates/puffin-requirements/src/lib.rs b/crates/puffin-requirements/src/lib.rs index 094387822..6235d74f5 100644 --- a/crates/puffin-requirements/src/lib.rs +++ b/crates/puffin-requirements/src/lib.rs @@ -1,4 +1,6 @@ +pub mod wheel; use std::borrow::Cow; + use std::ops::Deref; use std::str::FromStr; diff --git a/crates/puffin-requirements/src/wheel.rs b/crates/puffin-requirements/src/wheel.rs new file mode 100644 index 000000000..db3d5cacb --- /dev/null +++ b/crates/puffin-requirements/src/wheel.rs @@ -0,0 +1,73 @@ +use std::str::FromStr; + +use anyhow::{anyhow, bail}; +use once_cell::sync::Lazy; +use pep440_rs::Version; +use regex::Regex; + +#[derive(Debug, Clone)] +pub struct WheelName { + // TODO(charlie): Normalized package name. + pub distribution: String, + pub version: Version, + pub build_number: Option, + pub build_name: String, + pub py_tags: Vec, + pub abi_tags: Vec, + pub arch_tags: Vec, +} + +static BUILD_TAG_SPLIT: Lazy = Lazy::new(|| Regex::new(r"(^[0-9]*)(.*)$").unwrap()); + +impl FromStr for WheelName { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + let suffix = ".whl"; + + let stem = s + .strip_suffix(suffix) + .ok_or_else(|| anyhow!("expected wheel name to end with {:?}: {:?}", suffix, s))?; + + let mut pieces: Vec<&str> = stem.split('-').collect(); + + let build_number: Option; + let build_name: String; + if pieces.len() == 6 { + let build_tag = pieces.remove(2); + if build_tag.is_empty() { + bail!("found empty build tag: {s:?}"); + } + // unwrap safe because: the regex cannot fail + let captures = BUILD_TAG_SPLIT.captures(build_tag).unwrap(); + build_number = captures.get(1).and_then(|m| m.as_str().parse().ok()); + // unwrap safe because: this group will always match something, even + // if only the empty string + build_name = captures.get(2).unwrap().as_str().into(); + } else { + build_number = None; + build_name = "".to_owned(); + } + + let [distribution, version, py_tags, abi_tags, arch_tags] = pieces.as_slice() else { + bail!("can't parse binary name {s:?}"); + }; + + let distribution = distribution.to_string(); + let version = Version::from_str(version) + .map_err(|e| anyhow!("failed to parse version {:?} from {:?}: {}", version, s, e))?; + let py_tags = py_tags.split('.').map(|tag| tag.into()).collect(); + let abi_tags = abi_tags.split('.').map(|tag| tag.into()).collect(); + let arch_tags = arch_tags.split('.').map(|tag| tag.into()).collect(); + + Ok(Self { + distribution, + version, + build_number, + build_name, + py_tags, + abi_tags, + arch_tags, + }) + } +} diff --git a/requirements.txt b/requirements.txt index e5def3e2b..7e1060246 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1 @@ flask -black -jinja2 -pyyaml