From 1cfe5be355ba337f9b785b8526788fae419f266b Mon Sep 17 00:00:00 2001 From: konsti Date: Sun, 2 Feb 2025 16:02:13 +0100 Subject: [PATCH] uv-install-wheel: Split installation logic and link logic (#11166) uv-install-wheel had the logic for laying out the installation and for linking a directory in the same module. We split them up to isolate each module's logic and tighten the crate's interface to only expose top level members. No logic changes, only moving code around. --- crates/uv-bench/benches/uv.rs | 2 +- crates/uv-cli/src/lib.rs | 10 +- crates/uv-dispatch/src/lib.rs | 4 +- crates/uv-install-wheel/src/install.rs | 224 ++++++++++++++++++++++ crates/uv-install-wheel/src/lib.rs | 8 +- crates/uv-install-wheel/src/linker.rs | 228 +---------------------- crates/uv-installer/src/installer.rs | 8 +- crates/uv-settings/src/combine.rs | 2 +- crates/uv-settings/src/settings.rs | 2 +- crates/uv/src/commands/build_frontend.rs | 2 +- crates/uv/src/commands/pip/compile.rs | 2 +- crates/uv/src/commands/pip/install.rs | 2 +- crates/uv/src/commands/pip/operations.rs | 2 +- crates/uv/src/commands/pip/sync.rs | 2 +- crates/uv/src/commands/venv.rs | 2 +- crates/uv/src/settings.rs | 2 +- 16 files changed, 256 insertions(+), 246 deletions(-) create mode 100644 crates/uv-install-wheel/src/install.rs diff --git a/crates/uv-bench/benches/uv.rs b/crates/uv-bench/benches/uv.rs index 204c341e3..285ed9960 100644 --- a/crates/uv-bench/benches/uv.rs +++ b/crates/uv-bench/benches/uv.rs @@ -92,7 +92,7 @@ mod resolver { use uv_dispatch::{BuildDispatch, SharedState}; use uv_distribution::DistributionDatabase; use uv_distribution_types::{DependencyMetadata, IndexLocations}; - use uv_install_wheel::linker::LinkMode; + use uv_install_wheel::LinkMode; use uv_pep440::Version; use uv_pep508::{MarkerEnvironment, MarkerEnvironmentBuilder}; use uv_platform_tags::{Arch, Os, Platform, Tags}; diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 4ab524af2..27a0edfc8 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -2445,7 +2445,7 @@ pub struct VenvArgs { /// Defaults to `clone` (also known as Copy-on-Write) on macOS, and `hardlink` on Linux and /// Windows. #[arg(long, value_enum, env = EnvVars::UV_LINK_MODE)] - pub link_mode: Option, + pub link_mode: Option, #[command(flatten)] pub refresh: RefreshArgs, @@ -4170,7 +4170,7 @@ pub struct ToolUpgradeArgs { env = EnvVars::UV_LINK_MODE, help_heading = "Installer options" )] - pub link_mode: Option, + pub link_mode: Option, /// Compile Python files to bytecode after installation. /// @@ -4790,7 +4790,7 @@ pub struct InstallerArgs { env = EnvVars::UV_LINK_MODE, help_heading = "Installer options" )] - pub link_mode: Option, + pub link_mode: Option, /// Compile Python files to bytecode after installation. /// @@ -4986,7 +4986,7 @@ pub struct ResolverArgs { env = EnvVars::UV_LINK_MODE, help_heading = "Installer options" )] - pub link_mode: Option, + pub link_mode: Option, /// Ignore the `tool.uv.sources` table when resolving dependencies. Used to lock against the /// standards-compliant, publishable package metadata, as opposed to using any workspace, Git, @@ -5174,7 +5174,7 @@ pub struct ResolverInstallerArgs { env = EnvVars::UV_LINK_MODE, help_heading = "Installer options" )] - pub link_mode: Option, + pub link_mode: Option, /// Compile Python files to bytecode after installation. /// diff --git a/crates/uv-dispatch/src/lib.rs b/crates/uv-dispatch/src/lib.rs index 812cb870a..43f6fe029 100644 --- a/crates/uv-dispatch/src/lib.rs +++ b/crates/uv-dispatch/src/lib.rs @@ -86,7 +86,7 @@ pub struct BuildDispatch<'a> { shared_state: SharedState, dependency_metadata: &'a DependencyMetadata, build_isolation: BuildIsolation<'a>, - link_mode: uv_install_wheel::linker::LinkMode, + link_mode: uv_install_wheel::LinkMode, build_options: &'a BuildOptions, config_settings: &'a ConfigSettings, hasher: &'a HashStrategy, @@ -112,7 +112,7 @@ impl<'a> BuildDispatch<'a> { index_strategy: IndexStrategy, config_settings: &'a ConfigSettings, build_isolation: BuildIsolation<'a>, - link_mode: uv_install_wheel::linker::LinkMode, + link_mode: uv_install_wheel::LinkMode, build_options: &'a BuildOptions, hasher: &'a HashStrategy, exclude_newer: Option, diff --git a/crates/uv-install-wheel/src/install.rs b/crates/uv-install-wheel/src/install.rs new file mode 100644 index 000000000..d2f6774fe --- /dev/null +++ b/crates/uv-install-wheel/src/install.rs @@ -0,0 +1,224 @@ +//! Like `wheel.rs`, but for installing wheels that have already been unzipped, rather than +//! reading from a zip file. + +use std::path::Path; + +use crate::linker::{LinkMode, Locks}; +use crate::script::{scripts_from_ini, Script}; +use crate::wheel::{ + install_data, parse_wheel_file, read_record_file, write_installer_metadata, + write_script_entrypoints, LibKind, +}; +use crate::{Error, Layout}; +use fs_err as fs; +use fs_err::File; +use tracing::{instrument, trace}; +use uv_cache_info::CacheInfo; +use uv_distribution_filename::WheelFilename; +use uv_pypi_types::{DirectUrl, Metadata12}; + +/// Install the given wheel to the given venv +/// +/// The caller must ensure that the wheel is compatible to the environment. +/// +/// +/// +/// Wheel 1.0: +#[instrument(skip_all, fields(wheel = %filename))] +pub fn install_wheel( + layout: &Layout, + relocatable: bool, + wheel: impl AsRef, + filename: &WheelFilename, + direct_url: Option<&DirectUrl>, + cache_info: Option<&CacheInfo>, + installer: Option<&str>, + installer_metadata: bool, + link_mode: LinkMode, + locks: &Locks, +) -> Result<(), Error> { + let dist_info_prefix = find_dist_info(&wheel)?; + let metadata = dist_info_metadata(&dist_info_prefix, &wheel)?; + let Metadata12 { name, version, .. } = Metadata12::parse_metadata(&metadata) + .map_err(|err| Error::InvalidWheel(err.to_string()))?; + + // Validate the wheel name and version. + { + if name != filename.name { + return Err(Error::MismatchedName(name, filename.name.clone())); + } + + if version != filename.version && version != filename.version.clone().without_local() { + return Err(Error::MismatchedVersion(version, filename.version.clone())); + } + } + + // We're going step by step though + // https://packaging.python.org/en/latest/specifications/binary-distribution-format/#installing-a-wheel-distribution-1-0-py32-none-any-whl + // > 1.a Parse distribution-1.0.dist-info/WHEEL. + // > 1.b Check that installer is compatible with Wheel-Version. Warn if minor version is greater, abort if major version is greater. + let wheel_file_path = wheel + .as_ref() + .join(format!("{dist_info_prefix}.dist-info/WHEEL")); + let wheel_text = fs::read_to_string(wheel_file_path)?; + let lib_kind = parse_wheel_file(&wheel_text)?; + + // > 1.c If Root-Is-Purelib == ‘true’, unpack archive into purelib (site-packages). + // > 1.d Else unpack archive into platlib (site-packages). + trace!(?name, "Extracting file"); + let site_packages = match lib_kind { + LibKind::Pure => &layout.scheme.purelib, + LibKind::Plat => &layout.scheme.platlib, + }; + let num_unpacked = link_mode.link_wheel_files(site_packages, &wheel, locks)?; + trace!(?name, "Extracted {num_unpacked} files"); + + // Read the RECORD file. + let mut record_file = File::open( + wheel + .as_ref() + .join(format!("{dist_info_prefix}.dist-info/RECORD")), + )?; + let mut record = read_record_file(&mut record_file)?; + + let (console_scripts, gui_scripts) = + parse_scripts(&wheel, &dist_info_prefix, None, layout.python_version.1)?; + + if console_scripts.is_empty() && gui_scripts.is_empty() { + trace!(?name, "No entrypoints"); + } else { + trace!(?name, "Writing entrypoints"); + + fs_err::create_dir_all(&layout.scheme.scripts)?; + write_script_entrypoints( + layout, + relocatable, + site_packages, + &console_scripts, + &mut record, + false, + )?; + write_script_entrypoints( + layout, + relocatable, + site_packages, + &gui_scripts, + &mut record, + true, + )?; + } + + // 2.a Unpacked archive includes distribution-1.0.dist-info/ and (if there is data) distribution-1.0.data/. + // 2.b Move each subtree of distribution-1.0.data/ onto its destination path. Each subdirectory of distribution-1.0.data/ is a key into a dict of destination directories, such as distribution-1.0.data/(purelib|platlib|headers|scripts|data). The initially supported paths are taken from distutils.command.install. + let data_dir = site_packages.join(format!("{dist_info_prefix}.data")); + if data_dir.is_dir() { + trace!(?name, "Installing data"); + install_data( + layout, + relocatable, + site_packages, + &data_dir, + &name, + &console_scripts, + &gui_scripts, + &mut record, + )?; + // 2.c If applicable, update scripts starting with #!python to point to the correct interpreter. + // Script are unsupported through data + // 2.e Remove empty distribution-1.0.data directory. + fs::remove_dir_all(data_dir)?; + } else { + trace!(?name, "No data"); + } + + if installer_metadata { + trace!(?name, "Writing installer metadata"); + write_installer_metadata( + site_packages, + &dist_info_prefix, + true, + direct_url, + cache_info, + installer, + &mut record, + )?; + } + + trace!(?name, "Writing record"); + let mut record_writer = csv::WriterBuilder::new() + .has_headers(false) + .escape(b'"') + .from_path(site_packages.join(format!("{dist_info_prefix}.dist-info/RECORD")))?; + record.sort(); + for entry in record { + record_writer.serialize(entry)?; + } + + Ok(()) +} + +/// Find the `dist-info` directory in an unzipped wheel. +/// +/// See: +/// +/// See: +fn find_dist_info(path: impl AsRef) -> Result { + // Iterate over `path` to find the `.dist-info` directory. It should be at the top-level. + let Some(dist_info) = fs::read_dir(path.as_ref())?.find_map(|entry| { + let entry = entry.ok()?; + let file_type = entry.file_type().ok()?; + if file_type.is_dir() { + let path = entry.path(); + if path.extension().is_some_and(|ext| ext == "dist-info") { + Some(path) + } else { + None + } + } else { + None + } + }) else { + return Err(Error::InvalidWheel( + "Missing .dist-info directory".to_string(), + )); + }; + + let Some(dist_info_prefix) = dist_info.file_stem() else { + return Err(Error::InvalidWheel( + "Missing .dist-info directory".to_string(), + )); + }; + + Ok(dist_info_prefix.to_string_lossy().to_string()) +} + +/// Read the `dist-info` metadata from a directory. +fn dist_info_metadata(dist_info_prefix: &str, wheel: impl AsRef) -> Result, Error> { + let metadata_file = wheel + .as_ref() + .join(format!("{dist_info_prefix}.dist-info/METADATA")); + Ok(fs::read(metadata_file)?) +} + +/// Parses the `entry_points.txt` entry in the wheel for console scripts +/// +/// Returns (`script_name`, module, function) +/// +/// Extras are supposed to be ignored, which happens if you pass None for extras. +fn parse_scripts( + wheel: impl AsRef, + dist_info_prefix: &str, + extras: Option<&[String]>, + python_minor: u8, +) -> Result<(Vec