From 94895de46d42c215e555c78727b8adaa01e303b2 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Thu, 5 Oct 2023 20:59:58 -0400 Subject: [PATCH] Add support for wheel tag parsing (#15) Closes https://github.com/astral-sh/puffin/issues/12. --- crates/puffin-cli/Cargo.toml | 1 + crates/puffin-cli/src/commands/install.rs | 21 +- crates/puffin-interpreter/Cargo.toml | 2 + crates/puffin-interpreter/src/lib.rs | 25 +- crates/puffin-interpreter/src/platform.rs | 105 ---- .../puffin-interpreter/src/python_platform.rs | 47 ++ crates/puffin-interpreter/src/virtual_env.rs | 6 +- crates/puffin-platform/Cargo.toml | 23 + crates/puffin-platform/src/lib.rs | 549 ++++++++++++++++++ crates/puffin-requirements/src/wheel.rs | 122 ++-- 10 files changed, 720 insertions(+), 181 deletions(-) delete mode 100644 crates/puffin-interpreter/src/platform.rs create mode 100644 crates/puffin-interpreter/src/python_platform.rs create mode 100644 crates/puffin-platform/Cargo.toml create mode 100644 crates/puffin-platform/src/lib.rs diff --git a/crates/puffin-cli/Cargo.toml b/crates/puffin-cli/Cargo.toml index f9d68d54e..3fb923ade 100644 --- a/crates/puffin-cli/Cargo.toml +++ b/crates/puffin-cli/Cargo.toml @@ -6,6 +6,7 @@ edition = "2021" [dependencies] puffin-client = { path = "../puffin-client" } puffin-interpreter = { path = "../puffin-interpreter" } +puffin-platform = { path = "../puffin-platform" } puffin-requirements = { path = "../puffin-requirements" } anyhow = { version = "1.0.75" } diff --git a/crates/puffin-cli/src/commands/install.rs b/crates/puffin-cli/src/commands/install.rs index 91f921ac7..d7068e07b 100644 --- a/crates/puffin-cli/src/commands/install.rs +++ b/crates/puffin-cli/src/commands/install.rs @@ -11,9 +11,10 @@ use tracing::debug; use puffin_client::{File, PypiClientBuilder, SimpleJson}; use puffin_interpreter::PythonExecutable; +use puffin_platform::Platform; use puffin_requirements::metadata::Metadata21; use puffin_requirements::package_name::PackageName; -use puffin_requirements::wheel::WheelName; +use puffin_requirements::wheel::WheelFilename; use crate::commands::ExitStatus; @@ -37,12 +38,16 @@ pub(crate) async fn install(src: &Path, cache: Option<&Path>) -> Result) -> Result Result { - let target = Platform::from_host(); - let venv = virtual_env::detect_virtual_env(&target)?; - let executable = target.get_venv_python(venv); + pub fn from_env(platform: &Platform) -> Result { + let platform = PythonPlatform::from(platform); + let venv = virtual_env::detect_virtual_env(&platform)?; + let executable = platform.venv_python(venv); let markers = markers::detect_markers(&executable)?; Ok(Self { @@ -30,11 +31,23 @@ impl PythonExecutable { }) } + /// Returns the path to the Python executable. pub fn executable(&self) -> &Path { self.executable.as_path() } + /// Returns the [`MarkerEnvironment`] for this Python executable. pub fn markers(&self) -> &MarkerEnvironment { &self.markers } + + /// Returns the Python version as a tuple of (major, minor). + pub fn version(&self) -> (u8, u8) { + // TODO(charlie): Use `Version`. + let python_version = &self.markers.python_version; + ( + u8::try_from(python_version.release[0]).expect("Python major version is too large"), + u8::try_from(python_version.release[1]).expect("Python minor version is too large"), + ) + } } diff --git a/crates/puffin-interpreter/src/platform.rs b/crates/puffin-interpreter/src/platform.rs deleted file mode 100644 index 328f139b8..000000000 --- a/crates/puffin-interpreter/src/platform.rs +++ /dev/null @@ -1,105 +0,0 @@ -use std::env; -use std::fmt; -use std::path::Path; -use std::path::PathBuf; - -#[derive(Debug, Clone, Eq, PartialEq)] -pub(crate) struct Platform { - os: Option, -} - -impl Platform { - /// Infer the target based on the current version used for compilation. - pub(crate) fn from_host() -> Self { - Self { - os: if cfg!(windows) { - Some(Os::Windows) - } else if cfg!(unix) { - Some(Os::Linux) - } else if cfg!(macos) { - Some(Os::Macos) - } else { - None - }, - } - } - - /// Returns `true` if the current platform is Linux. - #[allow(unused)] - #[inline] - pub(crate) fn is_linux(&self) -> bool { - self.os == Some(Os::Linux) - } - - /// Returns `true` if the current platform is macOS. - #[allow(unused)] - #[inline] - pub(crate) fn is_macos(&self) -> bool { - self.os == Some(Os::Macos) - } - - /// Returns `true` if the current platform is Windows. - #[allow(unused)] - #[inline] - pub(crate) fn is_windows(&self) -> bool { - self.os == Some(Os::Windows) - } - - /// Returns the path to the `python` executable inside a virtual environment. - pub(crate) fn get_venv_python(&self, venv_base: impl AsRef) -> PathBuf { - self.get_venv_bin_dir(venv_base).join(self.get_python()) - } - - /// Returns the directory in which the binaries are stored inside a virtual environment. - pub(crate) fn get_venv_bin_dir(&self, venv_base: impl AsRef) -> PathBuf { - let venv = venv_base.as_ref(); - if self.is_windows() { - let bin_dir = venv.join("Scripts"); - if bin_dir.join("python.exe").exists() { - return bin_dir; - } - // Python installed via msys2 on Windows might produce a POSIX-like venv - // See https://github.com/PyO3/maturin/issues/1108 - let bin_dir = venv.join("bin"); - if bin_dir.join("python.exe").exists() { - return bin_dir; - } - // for conda environment - venv.to_path_buf() - } else { - venv.join("bin") - } - } - - /// Returns the path to the `python` executable. - /// - /// For Windows, it's always `python.exe`. For UNIX, it's the `python` in the virtual - /// environment; or, if there is no virtual environment, `python3`. - pub(crate) fn get_python(&self) -> PathBuf { - if self.is_windows() { - PathBuf::from("python.exe") - } else if env::var_os("VIRTUAL_ENV").is_some() { - PathBuf::from("python") - } else { - PathBuf::from("python3") - } - } -} - -/// All supported operating systems. -#[derive(Debug, Clone, Copy, Eq, PartialEq)] -enum Os { - Linux, - Windows, - Macos, -} - -impl fmt::Display for Os { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - match *self { - Os::Linux => write!(f, "Linux"), - Os::Windows => write!(f, "Windows"), - Os::Macos => write!(f, "macOS"), - } - } -} diff --git a/crates/puffin-interpreter/src/python_platform.rs b/crates/puffin-interpreter/src/python_platform.rs new file mode 100644 index 000000000..e2f6f51e2 --- /dev/null +++ b/crates/puffin-interpreter/src/python_platform.rs @@ -0,0 +1,47 @@ +use std::path::Path; +use std::path::PathBuf; + +use puffin_platform::Platform; + +/// A Python-aware wrapper around [`Platform`]. +#[derive(Debug, Clone, Eq, PartialEq)] +pub(crate) struct PythonPlatform<'a>(&'a Platform); + +impl PythonPlatform<'_> { + /// Returns the path to the `python` executable inside a virtual environment. + pub(crate) fn venv_python(&self, venv_base: impl AsRef) -> PathBuf { + let python = if self.0.is_windows() { + "python.exe" + } else { + "python" + }; + self.venv_bin_dir(venv_base).join(python) + } + + /// Returns the directory in which the binaries are stored inside a virtual environment. + pub(crate) fn venv_bin_dir(&self, venv_base: impl AsRef) -> PathBuf { + let venv = venv_base.as_ref(); + if self.0.is_windows() { + let bin_dir = venv.join("Scripts"); + if bin_dir.join("python.exe").exists() { + return bin_dir; + } + // Python installed via msys2 on Windows might produce a POSIX-like venv + // See https://github.com/PyO3/maturin/issues/1108 + let bin_dir = venv.join("bin"); + if bin_dir.join("python.exe").exists() { + return bin_dir; + } + // for conda environment + venv.to_path_buf() + } else { + venv.join("bin") + } + } +} + +impl<'a> From<&'a Platform> for PythonPlatform<'a> { + fn from(platform: &'a Platform) -> Self { + Self(platform) + } +} diff --git a/crates/puffin-interpreter/src/virtual_env.rs b/crates/puffin-interpreter/src/virtual_env.rs index 9fbe84bd1..4e842461d 100644 --- a/crates/puffin-interpreter/src/virtual_env.rs +++ b/crates/puffin-interpreter/src/virtual_env.rs @@ -4,10 +4,10 @@ use std::path::PathBuf; use anyhow::{bail, Result}; use tracing::debug; -use crate::platform::Platform; +use crate::python_platform::PythonPlatform; /// Locate the current virtual environment. -pub(crate) fn detect_virtual_env(target: &Platform) -> Result { +pub(crate) fn detect_virtual_env(target: &PythonPlatform) -> Result { match (env::var_os("VIRTUAL_ENV"), env::var_os("CONDA_PREFIX")) { (Some(dir), None) => return Ok(PathBuf::from(dir)), (None, Some(dir)) => return Ok(PathBuf::from(dir)), @@ -30,7 +30,7 @@ pub(crate) fn detect_virtual_env(target: &Platform) -> Result { dot_venv.display() ); } - let python = target.get_venv_python(&dot_venv); + let python = target.venv_python(&dot_venv); if !python.is_file() { bail!( "Your virtualenv at {} is broken. It contains a pyvenv.cfg but no python at {}", diff --git a/crates/puffin-platform/Cargo.toml b/crates/puffin-platform/Cargo.toml new file mode 100644 index 000000000..bda775869 --- /dev/null +++ b/crates/puffin-platform/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "puffin-platform" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +homepage.workspace = true +documentation.workspace = true +repository.workspace = true +authors.workspace = true +license.workspace = true + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +glibc_version = "0.1.2" +goblin = "0.6.0" +platform-info = "2.0.2" +plist = "1.5.0" +regex = "1.9.6" +serde = "1.0.188" +target-lexicon = "0.12.11" +thiserror = "1.0.49" +tracing = "0.1.37" diff --git a/crates/puffin-platform/src/lib.rs b/crates/puffin-platform/src/lib.rs new file mode 100644 index 000000000..bd95a3a04 --- /dev/null +++ b/crates/puffin-platform/src/lib.rs @@ -0,0 +1,549 @@ +//! Abstractions for understanding the current platform (operating system and architecture). + +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::{fmt, fs, io}; + +use goblin::elf::Elf; +use platform_info::{PlatformInfo, PlatformInfoAPI, UNameAPI}; +use regex::Regex; +use serde::Deserialize; +use thiserror::Error; +use tracing::trace; + +#[derive(Error, Debug)] +pub enum PlatformError { + #[error(transparent)] + IOError(#[from] io::Error), + #[error("Failed to detect the operating system version: {0}")] + OsVersionDetectionError(String), +} + +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct Platform { + os: Os, + arch: Arch, +} + +impl Platform { + pub fn current() -> Result { + let os = Os::current()?; + let arch = Arch::current()?; + Ok(Self { os, arch }) + } + + pub fn is_windows(&self) -> bool { + matches!(self.os, Os::Windows) + } + + pub fn compatible_tags( + &self, + python_version: (u8, u8), + ) -> Result, PlatformError> { + compatible_tags(python_version, &self.os, self.arch) + } +} + +/// All supported operating systems. +#[derive(Debug, Clone, Eq, PartialEq)] +pub enum Os { + Manylinux { major: u16, minor: u16 }, + Musllinux { major: u16, minor: u16 }, + Windows, + Macos { major: u16, minor: u16 }, + FreeBsd { release: String }, + NetBsd { release: String }, + OpenBsd { release: String }, + Dragonfly { release: String }, + Illumos { release: String, arch: String }, + Haiku { release: String }, +} + +impl Os { + pub fn current() -> Result { + let target_triple = target_lexicon::HOST; + + let os = match target_triple.operating_system { + target_lexicon::OperatingSystem::Linux => Self::detect_linux_libc()?, + target_lexicon::OperatingSystem::Windows => Os::Windows, + target_lexicon::OperatingSystem::MacOSX { major, minor, .. } => { + Os::Macos { major, minor } + } + target_lexicon::OperatingSystem::Darwin => { + let (major, minor) = get_mac_os_version()?; + Os::Macos { major, minor } + } + target_lexicon::OperatingSystem::Netbsd => Os::NetBsd { + release: Os::platform_info()?.release().to_string_lossy().to_string(), + }, + target_lexicon::OperatingSystem::Freebsd => Os::FreeBsd { + release: Os::platform_info()?.release().to_string_lossy().to_string(), + }, + target_lexicon::OperatingSystem::Openbsd => Os::OpenBsd { + release: Os::platform_info()?.release().to_string_lossy().to_string(), + }, + target_lexicon::OperatingSystem::Dragonfly => Os::Dragonfly { + release: Os::platform_info()?.release().to_string_lossy().to_string(), + }, + target_lexicon::OperatingSystem::Illumos => { + let platform_info = Os::platform_info()?; + Os::Illumos { + release: platform_info.release().to_string_lossy().to_string(), + arch: platform_info.machine().to_string_lossy().to_string(), + } + } + target_lexicon::OperatingSystem::Haiku => Os::Haiku { + release: Os::platform_info()?.release().to_string_lossy().to_string(), + }, + unsupported => { + return Err(PlatformError::OsVersionDetectionError(format!( + "The operating system {unsupported:?} is not supported" + ))); + } + }; + Ok(os) + } + + fn platform_info() -> Result { + PlatformInfo::new().map_err(|err| PlatformError::OsVersionDetectionError(err.to_string())) + } + + fn detect_linux_libc() -> Result { + let libc = find_libc()?; + let linux = if let Ok(Some((major, minor))) = get_musl_version(&libc) { + Os::Musllinux { major, minor } + } else if let Ok(glibc_ld) = fs::read_link(&libc) { + // Try reading the link first as it's faster + let filename = glibc_ld + .file_name() + .ok_or_else(|| { + PlatformError::OsVersionDetectionError( + "Expected the glibc ld to be a file".to_string(), + ) + })? + .to_string_lossy(); + let expr = Regex::new(r"ld-(\d{1,3})\.(\d{1,3})\.so").unwrap(); + + if let Some(capture) = expr.captures(&filename) { + let major = capture.get(1).unwrap().as_str().parse::().unwrap(); + let minor = capture.get(2).unwrap().as_str().parse::().unwrap(); + Os::Manylinux { major, minor } + } else { + trace!("Couldn't use ld filename, using `ldd --version`"); + // runs `ldd --version` + let version = glibc_version::get_version().map_err(|err| { + PlatformError::OsVersionDetectionError(format!( + "Failed to determine glibc version with `ldd --version`: {err}" + )) + })?; + Os::Manylinux { + major: u16::try_from(version.major).map_err(|_| { + PlatformError::OsVersionDetectionError(format!( + "Invalid glibc major version {}", + version.major + )) + })?, + minor: u16::try_from(version.minor).map_err(|_| { + PlatformError::OsVersionDetectionError(format!( + "Invalid glibc minor version {}", + version.minor + )) + })?, + } + } + } else { + return Err(PlatformError::OsVersionDetectionError("Couldn't detect neither glibc version nor musl libc version, at least one of which is required".to_string())); + }; + trace!("libc: {}", linux); + Ok(linux) + } +} + +impl fmt::Display for Os { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match *self { + Os::Manylinux { .. } => write!(f, "Manylinux"), + Os::Musllinux { .. } => write!(f, "Musllinux"), + Os::Windows => write!(f, "Windows"), + Os::Macos { .. } => write!(f, "MacOS"), + Os::FreeBsd { .. } => write!(f, "FreeBSD"), + Os::NetBsd { .. } => write!(f, "NetBSD"), + Os::OpenBsd { .. } => write!(f, "OpenBSD"), + Os::Dragonfly { .. } => write!(f, "DragonFly"), + Os::Illumos { .. } => write!(f, "Illumos"), + Os::Haiku { .. } => write!(f, "Haiku"), + } + } +} + +/// All supported CPU architectures +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum Arch { + Aarch64, + Armv7L, + Powerpc64Le, + Powerpc64, + X86, + X86_64, + S390X, +} + +impl fmt::Display for Arch { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match *self { + Arch::Aarch64 => write!(f, "aarch64"), + Arch::Armv7L => write!(f, "armv7l"), + Arch::Powerpc64Le => write!(f, "ppc64le"), + Arch::Powerpc64 => write!(f, "ppc64"), + Arch::X86 => write!(f, "i686"), + Arch::X86_64 => write!(f, "x86_64"), + Arch::S390X => write!(f, "s390x"), + } + } +} + +impl Arch { + pub fn current() -> Result { + let target_triple = target_lexicon::HOST; + let arch = match target_triple.architecture { + target_lexicon::Architecture::X86_64 => Arch::X86_64, + target_lexicon::Architecture::X86_32(_) => Arch::X86, + target_lexicon::Architecture::Arm(_) => Arch::Armv7L, + target_lexicon::Architecture::Aarch64(_) => Arch::Aarch64, + target_lexicon::Architecture::Powerpc64 => Arch::Powerpc64, + target_lexicon::Architecture::Powerpc64le => Arch::Powerpc64Le, + target_lexicon::Architecture::S390x => Arch::S390X, + unsupported => { + return Err(PlatformError::OsVersionDetectionError(format!( + "The architecture {unsupported} is not supported" + ))); + } + }; + Ok(arch) + } + + /// Returns the oldest possible Manylinux tag for this architecture + pub fn get_minimum_manylinux_minor(&self) -> u16 { + match self { + // manylinux 2014 + Arch::Aarch64 | Arch::Armv7L | Arch::Powerpc64 | Arch::Powerpc64Le | Arch::S390X => 17, + // manylinux 1 + Arch::X86 | Arch::X86_64 => 5, + } + } +} + +fn get_mac_os_version() -> Result<(u16, u16), PlatformError> { + // This is actually what python does + // https://github.com/python/cpython/blob/cb2b3c8d3566ae46b3b8d0718019e1c98484589e/Lib/platform.py#L409-L428 + #[derive(Deserialize)] + #[serde(rename_all = "PascalCase")] + struct SystemVersion { + product_version: String, + } + let system_version: SystemVersion = + plist::from_file("/System/Library/CoreServices/SystemVersion.plist") + .map_err(|err| PlatformError::OsVersionDetectionError(err.to_string()))?; + + let invalid_mac_os_version = || { + PlatformError::OsVersionDetectionError(format!( + "Invalid macOS version {}", + system_version.product_version + )) + }; + match system_version + .product_version + .split('.') + .collect::>() + .as_slice() + { + [major, minor] | [major, minor, _] => { + let major = major.parse::().map_err(|_| invalid_mac_os_version())?; + let minor = minor.parse::().map_err(|_| invalid_mac_os_version())?; + Ok((major, minor)) + } + _ => Err(invalid_mac_os_version()), + } +} + +/// Determine the appropriate binary formats for a macOS version. +/// Source: +fn get_mac_binary_formats(major: u16, minor: u16, arch: Arch) -> Vec { + let mut formats = vec![match arch { + Arch::Aarch64 => "arm64".to_string(), + _ => arch.to_string(), + }]; + + if matches!(arch, Arch::X86_64) { + if (major, minor) < (10, 4) { + return vec![]; + } + formats.extend([ + "intel".to_string(), + "fat64".to_string(), + "fat32".to_string(), + ]); + } + + if matches!(arch, Arch::X86_64 | Arch::Aarch64) { + formats.push("universal2".to_string()); + } + + if matches!(arch, Arch::X86_64) { + formats.push("universal".to_string()); + } + + formats +} + +/// Find musl libc path from executable's ELF header +fn find_libc() -> Result { + let buffer = fs::read("/bin/ls")?; + let error_str = "Couldn't parse /bin/ls for detecting the ld version"; + let elf = Elf::parse(&buffer) + .map_err(|err| PlatformError::OsVersionDetectionError(format!("{error_str}: {err}")))?; + if let Some(elf_interpreter) = elf.interpreter { + Ok(PathBuf::from(elf_interpreter)) + } else { + Err(PlatformError::OsVersionDetectionError( + error_str.to_string(), + )) + } +} + +/// Read the musl version from libc library's output. Taken from maturin +/// +/// The libc library should output something like this to `stderr::` +/// +/// musl libc (`x86_64`) +/// Version 1.2.2 +/// Dynamic Program Loader +fn get_musl_version(ld_path: impl AsRef) -> std::io::Result> { + let output = Command::new(ld_path.as_ref()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .output()?; + let stderr = String::from_utf8_lossy(&output.stderr); + let expr = Regex::new(r"Version (\d{2,4})\.(\d{2,4})").unwrap(); + if let Some(capture) = expr.captures(&stderr) { + let major = capture.get(1).unwrap().as_str().parse::().unwrap(); + let minor = capture.get(2).unwrap().as_str().parse::().unwrap(); + return Ok(Some((major, minor))); + } + Ok(None) +} + +/// Returns the compatible platform tags, e.g. `manylinux_2_17`, `macosx_11_0_arm64` or `win_amd64` +/// +/// We have two cases: Actual platform specific tags (including "merged" tags such as universal2) +/// and "any". +/// +/// Bit of a mess, needs to be cleaned up +pub fn compatible_platform_tags(os: &Os, arch: Arch) -> Result, PlatformError> { + let platform_tags = match (&os, arch) { + (Os::Manylinux { major, minor }, _) => { + let mut platform_tags = vec![format!("linux_{}", arch)]; + platform_tags.extend( + (arch.get_minimum_manylinux_minor()..=*minor) + .map(|minor| format!("manylinux_{major}_{minor}_{arch}")), + ); + if (arch.get_minimum_manylinux_minor()..=*minor).contains(&12) { + platform_tags.push(format!("manylinux2010_{arch}")); + } + if (arch.get_minimum_manylinux_minor()..=*minor).contains(&17) { + platform_tags.push(format!("manylinux2014_{arch}")); + } + if (arch.get_minimum_manylinux_minor()..=*minor).contains(&5) { + platform_tags.push(format!("manylinux1_{arch}")); + } + platform_tags + } + (Os::Musllinux { major, minor }, _) => { + let mut platform_tags = vec![format!("linux_{}", arch)]; + // musl 1.1 is the lowest supported version in musllinux + platform_tags + .extend((1..=*minor).map(|minor| format!("musllinux_{major}_{minor}_{arch}"))); + platform_tags + } + (Os::Macos { major, minor }, Arch::X86_64) => { + // Source: https://github.com/pypa/packaging/blob/fd4f11139d1c884a637be8aa26bb60a31fbc9411/packaging/tags.py#L346 + let mut platform_tags = vec![]; + match major { + 10 => { + // Prior to Mac OS 11, each yearly release of Mac OS bumped the "minor" version + // number. The major version was always 10. + for minor in (0..=*minor).rev() { + for binary_format in get_mac_binary_formats(*major, minor, arch) { + platform_tags.push(format!("macosx_{major}_{minor}_{binary_format}")); + } + } + } + value if *value >= 11 => { + // Starting with Mac OS 11, each yearly release bumps the major version number. + // The minor versions are now the midyear updates. + for major in (10..=*major).rev() { + for binary_format in get_mac_binary_formats(major, 0, arch) { + platform_tags.push(format!("macosx_{}_{}_{}", major, 0, binary_format)); + } + } + // The "universal2" binary format can have a macOS version earlier than 11.0 + // when the x86_64 part of the binary supports that version of macOS. + for minor in (4..=16).rev() { + for binary_format in get_mac_binary_formats(10, minor, arch) { + platform_tags + .push(format!("macosx_{}_{}_{}", 10, minor, binary_format)); + } + } + } + _ => { + return Err(PlatformError::OsVersionDetectionError(format!( + "Unsupported macOS version: {major}", + ))); + } + } + platform_tags + } + (Os::Macos { major, .. }, Arch::Aarch64) => { + // Source: https://github.com/pypa/packaging/blob/fd4f11139d1c884a637be8aa26bb60a31fbc9411/packaging/tags.py#L346 + let mut platform_tags = vec![]; + // Starting with Mac OS 11, each yearly release bumps the major version number. + // The minor versions are now the midyear updates. + for major in (10..=*major).rev() { + for binary_format in get_mac_binary_formats(major, 0, arch) { + platform_tags.push(format!("macosx_{}_{}_{}", major, 0, binary_format)); + } + } + // The "universal2" binary format can have a macOS version earlier than 11.0 + // when the x86_64 part of the binary supports that version of macOS. + platform_tags.extend( + (4..=16) + .rev() + .map(|minor| format!("macosx_{}_{}_universal2", 10, minor)), + ); + platform_tags + } + (Os::Windows, Arch::X86) => { + vec!["win32".to_string()] + } + (Os::Windows, Arch::X86_64) => { + vec!["win_amd64".to_string()] + } + (Os::Windows, Arch::Aarch64) => vec!["win_arm64".to_string()], + ( + Os::FreeBsd { release } + | Os::NetBsd { release } + | Os::OpenBsd { release } + | Os::Dragonfly { release } + | Os::Haiku { release }, + _, + ) => { + let release = release.replace(['.', '-'], "_"); + vec![format!( + "{}_{}_{}", + os.to_string().to_lowercase(), + release, + arch + )] + } + (Os::Illumos { release, arch }, _) => { + // See https://github.com/python/cpython/blob/46c8d915715aa2bd4d697482aa051fe974d440e1/Lib/sysconfig.py#L722-L730 + if let Some((major, other)) = release.split_once('_') { + let major_ver: u64 = major.parse().map_err(|err| { + PlatformError::OsVersionDetectionError(format!( + "illumos major version is not a number: {err}" + )) + })?; + if major_ver >= 5 { + // SunOS 5 == Solaris 2 + let os = "solaris".to_string(); + let release = format!("{}_{}", major_ver - 3, other); + let arch = format!("{arch}_64bit"); + return Ok(vec![format!("{}_{}_{}", os, release, arch)]); + } + } + + let os = os.to_string().to_lowercase(); + vec![format!("{}_{}_{}", os, release, arch)] + } + _ => { + return Err(PlatformError::OsVersionDetectionError(format!( + "Unsupported operating system and architecture combination: {os} {arch}" + ))); + } + }; + Ok(platform_tags) +} + +/// Returns the compatible tags in a (`python_tag`, `abi_tag`, `platform_tag`) format +pub fn compatible_tags( + python_version: (u8, u8), + os: &Os, + arch: Arch, +) -> Result, PlatformError> { + assert_eq!(python_version.0, 3); + let mut tags = Vec::new(); + let platform_tags = compatible_platform_tags(os, arch)?; + // 1. This exact c api version + for platform_tag in &platform_tags { + tags.push(( + format!("cp{}{}", python_version.0, python_version.1), + format!( + "cp{}{}{}", + python_version.0, + python_version.1, + // hacky but that's legacy anyways + if python_version.1 <= 7 { "m" } else { "" } + ), + platform_tag.clone(), + )); + tags.push(( + format!("cp{}{}", python_version.0, python_version.1), + "none".to_string(), + platform_tag.clone(), + )); + } + // 2. abi3 and no abi (e.g. executable binary) + // For some reason 3.2 is the minimum python for the cp abi + for minor in 2..=python_version.1 { + for platform_tag in &platform_tags { + tags.push(( + format!("cp{}{}", python_version.0, minor), + "abi3".to_string(), + platform_tag.clone(), + )); + } + } + // 3. no abi (e.g. executable binary) + for minor in 0..=python_version.1 { + for platform_tag in &platform_tags { + tags.push(( + format!("py{}{}", python_version.0, minor), + "none".to_string(), + platform_tag.clone(), + )); + } + } + // 4. major only + for platform_tag in platform_tags { + tags.push(( + format!("py{}", python_version.0), + "none".to_string(), + platform_tag, + )); + } + // 5. no binary + for minor in 0..=python_version.1 { + tags.push(( + format!("py{}{}", python_version.0, minor), + "none".to_string(), + "any".to_string(), + )); + } + tags.push(( + format!("py{}", python_version.0), + "none".to_string(), + "any".to_string(), + )); + tags.sort(); + Ok(tags) +} diff --git a/crates/puffin-requirements/src/wheel.rs b/crates/puffin-requirements/src/wheel.rs index abeffafee..0b3731356 100644 --- a/crates/puffin-requirements/src/wheel.rs +++ b/crates/puffin-requirements/src/wheel.rs @@ -1,73 +1,69 @@ use std::str::FromStr; -use anyhow::{anyhow, bail}; -use once_cell::sync::Lazy; -use pep440_rs::Version; -use regex::Regex; +use thiserror::Error; -#[derive(Debug, Clone)] -pub struct WheelName { - // TODO(charlie): Normalized package name. +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct WheelFilename { 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, + pub version: String, + pub python_tag: Vec, + pub abi_tag: Vec, + pub platform_tag: Vec, } -static BUILD_TAG_SPLIT: Lazy = Lazy::new(|| Regex::new(r"(^[0-9]*)(.*)$").unwrap()); +impl FromStr for WheelFilename { + type Err = Error; -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 = String::new(); + fn from_str(filename: &str) -> Result { + let basename = filename.strip_suffix(".whl").ok_or_else(|| { + Error::InvalidWheelFileName(filename.to_string(), "Must end with .whl".to_string()) + })?; + // https://www.python.org/dev/peps/pep-0427/#file-name-convention + match basename.split('-').collect::>().as_slice() { + // TODO(charlie): Build tag precedence + &[distribution, version, _, python_tag, abi_tag, platform_tag] + | &[distribution, version, python_tag, abi_tag, platform_tag] => Ok(WheelFilename { + distribution: distribution.to_string(), + version: version.to_string(), + python_tag: python_tag.split('.').map(String::from).collect(), + abi_tag: abi_tag.split('.').map(String::from).collect(), + platform_tag: platform_tag.split('.').map(String::from).collect(), + }), + _ => Err(Error::InvalidWheelFileName( + filename.to_string(), + "Expected four \"-\" in the filename".to_string(), + )), } - - 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(std::convert::Into::into).collect(); - let abi_tags = abi_tags.split('.').map(std::convert::Into::into).collect(); - let arch_tags = arch_tags.split('.').map(std::convert::Into::into).collect(); - - Ok(Self { - distribution, - version, - build_number, - build_name, - py_tags, - abi_tags, - arch_tags, - }) } } + +impl WheelFilename { + /// Returns `true` if the wheel is compatible with the given tags. + pub fn is_compatible(&self, compatible_tags: &[(String, String, String)]) -> bool { + for tag in compatible_tags { + if self.python_tag.contains(&tag.0) + && self.abi_tag.contains(&tag.1) + && self.platform_tag.contains(&tag.2) + { + return true; + } + } + false + } + + /// Get the tag for this wheel. + pub fn get_tag(&self) -> String { + format!( + "{}-{}-{}", + self.python_tag.join("."), + self.abi_tag.join("."), + self.platform_tag.join(".") + ) + } +} + +#[derive(Error, Debug)] +pub enum Error { + #[error("The wheel filename \"{0}\" is invalid: {1}")] + InvalidWheelFileName(String, String), +}