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