diff --git a/crates/huak_python_manager/scripts/generate_python_releases.py b/crates/huak_python_manager/scripts/generate_python_releases.py index 79089ac3..ae43ae08 100644 --- a/crates/huak_python_manager/scripts/generate_python_releases.py +++ b/crates/huak_python_manager/scripts/generate_python_releases.py @@ -74,13 +74,14 @@ def get_checksum(url: str) -> str | None: has_checksum.add(asset["browser_download_url"].removesuffix(".sha256")) -module = f"""\ -//! This file was generated with `{FILE.name}`. +module = ( + f"""//! This file was generated with `{FILE.name}`.""" + """\n\nuse std::{cmp::Ordering, fmt::Display}; #[allow(dead_code)] #[rustfmt::skip] -pub const RELEASES: &[Release] = &[\ -""" # noqa +pub(crate) const RELEASES: &[Release] = &[""" +) for release in release_json: for asset in release["assets"]: # Avoid making requests for releases we've already generated. @@ -126,8 +127,8 @@ def get_checksum(url: str) -> str | None: module += "\n\t" + release.to_rust_string() + "," module += """\n]; -#[derive(Copy, Clone)] -pub struct Release<'a> { +#[derive(Copy, Clone, PartialEq, Eq, Debug)] +pub(crate) struct Release<'a> { pub kind: &'a str, pub version: Version, pub os: &'a str, @@ -160,8 +161,8 @@ def get_checksum(url: str) -> str | None: } } -#[derive(Copy, Clone)] -pub struct Version { +#[derive(Copy, Clone, PartialEq, Eq, Debug)] +pub(crate) struct Version { pub major: u8, pub minor: u8, pub patch: u8, @@ -177,6 +178,42 @@ def get_checksum(url: str) -> str | None: } } } + +impl Display for Version { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}.{}.{}", self.major, self.minor, self.patch) + } +} + +impl PartialOrd for Version { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for Version { + fn cmp(&self, other: &Self) -> Ordering { + match compare_version(*self, *other) { + Ordering::Less => Ordering::Less, + Ordering::Equal => Ordering::Equal, + Ordering::Greater => Ordering::Greater, + } + } +} + +fn compare_version(this: Version, other: Version) -> Ordering { + for (a, b) in [ + (this.major, other.major), + (this.minor, other.minor), + (this.patch, other.patch), + ] { + if a != b { + return a.cmp(&b); + } + } + + Ordering::Equal +} """ path = ROOT / "crates" / CRATE / "src" / "releases.rs" diff --git a/crates/huak_python_manager/src/cli.rs b/crates/huak_python_manager/src/cli.rs index bdf0db00..9669e810 100644 --- a/crates/huak_python_manager/src/cli.rs +++ b/crates/huak_python_manager/src/cli.rs @@ -17,7 +17,7 @@ pub(crate) struct Cli { impl Cli { pub(crate) fn run(self) -> Result<(), Error> { match self.command { - Commands::Install { version } => cmd::install(&version), + Commands::Install { version } => cmd::install(version), } } } @@ -34,12 +34,14 @@ enum Commands { } mod cmd { - use super::Error; - use super::RequestedVersion; - use crate::install; - use crate::resolve::Strategy; + use super::{Error, RequestedVersion}; + use crate::install::install_to_home; + use crate::resolve::{Options, Strategy}; - pub(crate) fn install(version: &RequestedVersion) -> Result<(), Error> { - install::install_to_home(version, &Strategy::Auto) + pub(crate) fn install(version: RequestedVersion) -> Result<(), Error> { + install_to_home(&Strategy::Selection(Options { + version: Some(version), + ..Default::default() + })) } } diff --git a/crates/huak_python_manager/src/install.rs b/crates/huak_python_manager/src/install.rs index 3b276cb2..406d3196 100644 --- a/crates/huak_python_manager/src/install.rs +++ b/crates/huak_python_manager/src/install.rs @@ -1,6 +1,6 @@ use crate::{ - resolve::{get_release, Strategy}, - version::RequestedVersion, + releases::Release, + resolve::{resolve_release, Strategy}, }; use anyhow::{bail, Context, Error, Ok}; // TODO(cnpryer): Use thiserror in library code. use huak_home::huak_home_dir; @@ -9,11 +9,9 @@ use tar::Archive; use tempfile::TempDir; use zstd::decode_all; -pub(crate) fn install_to_home( - version: &RequestedVersion, - strategy: &Strategy, -) -> Result<(), Error> { - let release = get_release(version, strategy).context("requested release data")?; +/// Install a Python release to `~/.huak/bin/`. +pub(crate) fn install_to_home(strategy: &Strategy) -> Result<(), Error> { + let release = resolve_release(strategy).context("requested release data")?; let tmp_dir = TempDir::new()?; let tmp_name = "tmp.tar.zst"; let tmp_path = tmp_dir.path().join(tmp_name); @@ -21,7 +19,7 @@ pub(crate) fn install_to_home( .context("requested huak's home directory")? .join("bin"); - download_file(release.url, &tmp_path)?; + download_release(&release, &tmp_path)?; let mut archive = File::open(tmp_path)?; let decoded = decode_all(&mut archive)?; @@ -30,11 +28,14 @@ pub(crate) fn install_to_home( Ok(archive.unpack(target_dir)?) } -fn download_file(url: &str, to: &PathBuf) -> Result<(), Error> { - let mut response = reqwest::blocking::get(url)?; +/// Download the release to a temporary archive file (`to`). +fn download_release(release: &Release, to: &PathBuf) -> Result<(), Error> { + validate_release(release)?; + + let mut response = reqwest::blocking::get(release.url)?; if !response.status().is_success() { - bail!("failed to download file from {url}"); + bail!("failed to download file from {}", release.url); } let mut file = File::create(to)?; @@ -42,3 +43,9 @@ fn download_file(url: &str, to: &PathBuf) -> Result<(), Error> { Ok(()) } + +/// Validation for release installation. The following is verified prior to installation: +/// - checksum +fn validate_release(_release: &Release) -> Result<(), Error> { + todo!() +} diff --git a/crates/huak_python_manager/src/releases.rs b/crates/huak_python_manager/src/releases.rs index f9e1d24f..5585e5c3 100644 --- a/crates/huak_python_manager/src/releases.rs +++ b/crates/huak_python_manager/src/releases.rs @@ -1,8 +1,11 @@ //! This file was generated with `generate_python_releases.py`. +use std::{cmp::Ordering, fmt::Display}; + #[allow(dead_code)] #[rustfmt::skip] -pub const RELEASES: &[Release] = &[ +// TODO(cnpryer): Perf +pub(crate) const RELEASES: &[Release] = &[ Release::new("cpython", Version::new(3, 10, 13), "apple", "aarch64", "pgo+lto", "a2635841454295c5bc2c18740346fd8308f2a8adcce2407b87c9faf261fed29c", "https://github.com/indygreg/python-build-standalone/releases/download/20231002/cpython-3.10.13%2B20231002-aarch64-apple-darwin-pgo%2Blto-full.tar.zst"), Release::new("cpython", Version::new(3, 10, 13), "apple", "aarch64", "pgo", "67b64174b8d33aa1b2e3bb3a4a3e475ff96d511c540f46e3c0774f8b77be4d91", "https://github.com/indygreg/python-build-standalone/releases/download/20231002/cpython-3.10.13%2B20231002-aarch64-apple-darwin-pgo-full.tar.zst"), Release::new("cpython", Version::new(3, 10, 13), "windows", "i686", "pgo", "1c015e64732d3a18951fcea30d364c80fb83322363fec1a2c85c70840fb75a92", "https://github.com/indygreg/python-build-standalone/releases/download/20231002/cpython-3.10.13%2B20231002-i686-pc-windows-msvc-shared-pgo-full.tar.zst"), @@ -475,8 +478,8 @@ pub const RELEASES: &[Release] = &[ Release::new("cpython", Version::new(3, 9, 10), "linux", "x86_64", "pgo", "d23017bc20b640615af8f5eab0f1bf0c9264526bcb8c2a326f4a13b21725cff1", "https://github.com/indygreg/python-build-standalone/releases/download/20220227/cpython-3.9.10%2B20220227-x86_64-unknown-linux-gnu-pgo-full.tar.zst"), ]; -#[derive(Copy, Clone)] -pub struct Release<'a> { +#[derive(Copy, Clone, PartialEq, Eq, Debug)] +pub(crate) struct Release<'a> { pub kind: &'a str, pub version: Version, pub os: &'a str, @@ -509,8 +512,8 @@ impl Release<'static> { } } -#[derive(Copy, Clone)] -pub struct Version { +#[derive(Copy, Clone, PartialEq, Eq, Debug)] +pub(crate) struct Version { pub major: u8, pub minor: u8, pub patch: u8, @@ -526,3 +529,39 @@ impl Version { } } } + +impl Display for Version { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}.{}.{}", self.major, self.minor, self.patch) + } +} + +impl PartialOrd for Version { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for Version { + fn cmp(&self, other: &Self) -> Ordering { + match compare_version(*self, *other) { + Ordering::Less => Ordering::Less, + Ordering::Equal => Ordering::Equal, + Ordering::Greater => Ordering::Greater, + } + } +} + +fn compare_version(this: Version, other: Version) -> Ordering { + for (a, b) in [ + (this.major, other.major), + (this.minor, other.minor), + (this.patch, other.patch), + ] { + if a != b { + return a.cmp(&b); + } + } + + Ordering::Equal +} diff --git a/crates/huak_python_manager/src/resolve.rs b/crates/huak_python_manager/src/resolve.rs index e39551a8..1c5a7198 100644 --- a/crates/huak_python_manager/src/resolve.rs +++ b/crates/huak_python_manager/src/resolve.rs @@ -1,12 +1,218 @@ -use crate::{releases::Release, version::RequestedVersion}; +use crate::{ + releases::{self, Release, RELEASES}, + version::RequestedVersion, +}; +use std::env::consts::{ARCH, OS}; -pub(crate) fn get_release( - _version: &RequestedVersion, - _strategy: &Strategy, -) -> Option> { - todo!() +/// Resolve a Python Release based on a resolution `Strategy`. +pub(crate) fn resolve_release(strategy: &Strategy) -> Option> { + match strategy { + Strategy::Latest => resolve_release_with_options(&Options::default()), + Strategy::Selection(options) => resolve_release_with_options(options), + } } +fn resolve_release_with_options(options: &Options) -> Option> { + let mut candidates = RELEASES + .iter() + .filter(|it| { + it.kind == options.kind + && it.os == options.os + && it.architecture == options.architecture + && it.build_configuration == options.build_configuration + }) + .collect::>(); + + if candidates.is_empty() { + None + } else { + // Sort releases by version in descending order (latest releases at the beginning of the vector) + candidates.sort_by(|a, b| b.version.cmp(&a.version)); + + if let Some(req) = options.version.as_ref() { + candidates + .into_iter() + .find(|it| is_requested_version(it.version, req)) + .copied() + } else { + candidates.first().map(|it| **it) + } + } +} + +/// Evaluates if some Python release's version is what was requested. +fn is_requested_version( + release_version: releases::Version, + requested_version: &RequestedVersion, +) -> bool { + if let Some(major) = requested_version.major { + if release_version.major != major { + return false; + } + } + + if let Some(minor) = requested_version.minor { + if release_version.minor != minor { + return false; + } + } + + if let Some(patch) = requested_version.patch { + if release_version.patch != patch { + return false; + } + } + + true +} + +#[derive(Default)] +/// The strategy used for resolving a Python releases. pub(crate) enum Strategy { - Auto, + #[default] + /// Resolve with the latest possible Python release version for the current environment. + Latest, + /// `Selection` - Use some selection criteria to determine the Python release. Unused + /// options criteria will resolve to *best possible defaults*. + Selection(Options), +} + +#[derive(Default, Debug)] +/// Options criteria used for resolving Python releases. +pub(crate) struct Options { + pub kind: ReleaseKind, + pub version: Option, // TODO(cnpryer): Can this default to something like *Latest*? + pub os: ReleaseOS, + pub architecture: ReleaseArchitecture, + pub build_configuration: ReleaseBuildConfiguration, +} + +#[derive(Debug)] +pub(crate) struct ReleaseKind(String); + +impl Default for ReleaseKind { + fn default() -> Self { + Self(String::from("cpython")) + } +} + +impl PartialEq for ReleaseKind { + fn eq(&self, other: &str) -> bool { + self.0.as_str() == other + } +} + +impl PartialEq for &str { + fn eq(&self, other: &ReleaseKind) -> bool { + self == &other.0.as_str() + } +} + +#[derive(Debug)] +pub(crate) struct ReleaseOS(String); + +impl Default for ReleaseOS { + fn default() -> Self { + Self(String::from(match OS { + "macos" => "apple", + "windows" => "windows", + _ => "linux", + })) + } +} + +impl PartialEq for ReleaseOS { + fn eq(&self, other: &str) -> bool { + self.0.as_str() == other + } +} + +impl PartialEq for &str { + fn eq(&self, other: &ReleaseOS) -> bool { + self == &other.0.as_str() + } +} + +#[derive(Debug)] +pub(crate) struct ReleaseArchitecture(String); + +impl Default for ReleaseArchitecture { + fn default() -> Self { + Self(String::from(match ARCH { + "x86_64" => "x86_64", + "aarch64" => "aarch64", + "x86" => "i686", // TODO(cnpryer): Need to look at other windows releases. + _ => unimplemented!(), + })) + } +} + +impl PartialEq for ReleaseArchitecture { + fn eq(&self, other: &str) -> bool { + self.0.as_str() == other + } +} + +impl PartialEq for &str { + fn eq(&self, other: &ReleaseArchitecture) -> bool { + self == &other.0.as_str() + } +} + +#[derive(Debug)] +pub(crate) struct ReleaseBuildConfiguration(String); + +impl Default for ReleaseBuildConfiguration { + fn default() -> Self { + Self(String::from(match OS { + "windows" => "pgo", + _ => "pgo+lto", + })) + } +} + +impl PartialEq for ReleaseBuildConfiguration { + fn eq(&self, other: &str) -> bool { + self.0.as_str() == other + } +} + +impl PartialEq for &str { + fn eq(&self, other: &ReleaseBuildConfiguration) -> bool { + self == &other.0.as_str() + } +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use super::*; + + #[test] + fn test_latest() { + let latest_default = resolve_release_with_options(&Options::default()).unwrap(); + let resolved_release = resolve_release(&Strategy::Latest).unwrap(); + + assert_eq!(resolved_release, latest_default); + } + + #[test] + fn test_selection() { + let resolved_release = resolve_release(&Strategy::Selection(Options { + kind: ReleaseKind("cpython".to_string()), + version: Some(RequestedVersion::from_str("3.8").unwrap()), + os: ReleaseOS("apple".to_string()), + architecture: ReleaseArchitecture("aarch64".to_string()), + build_configuration: ReleaseBuildConfiguration("pgo+lto".to_string()), + })) + .unwrap(); + + assert_eq!(resolved_release.kind, "cpython"); + assert_eq!(resolved_release.version.major, 3); + assert_eq!(resolved_release.version.minor, 8); + assert_eq!(resolved_release.os, "apple"); + assert_eq!(resolved_release.architecture, "aarch64"); + assert_eq!(resolved_release.build_configuration, "pgo+lto"); + } } diff --git a/crates/huak_python_manager/src/version.rs b/crates/huak_python_manager/src/version.rs index f1b6e2a9..8c6ac734 100644 --- a/crates/huak_python_manager/src/version.rs +++ b/crates/huak_python_manager/src/version.rs @@ -1,13 +1,25 @@ -use anyhow::Error; -use std::str::FromStr; // TODO(cnpryer): Library code should use thiserror +use anyhow::Error; // TODO(cnpryer): Library code should use thiserror +use std::str::FromStr; #[derive(Debug, Clone)] -pub struct RequestedVersion(String); // TODO(cnpryer) +pub(crate) struct RequestedVersion { + pub(crate) major: Option, + pub(crate) minor: Option, + pub(crate) patch: Option, +} impl FromStr for RequestedVersion { type Err = Error; fn from_str(s: &str) -> Result { - Ok(RequestedVersion(s.to_owned())) + let mut parts = s + .split('.') + .map(|it| it.parse::().expect("parsed requested version part")); + + Ok(RequestedVersion { + major: parts.next(), + minor: parts.next(), + patch: parts.next(), + }) } }