//! Like `wheel.rs`, but for installing wheels that have already been unzipped, rather than //! reading from a zip file. use std::path::Path; use std::str::FromStr; use std::time::SystemTime; use fs_err as fs; use fs_err::{DirEntry, File}; use reflink_copy as reflink; use serde::{Deserialize, Serialize}; use tempfile::tempdir_in; use tracing::{debug, instrument}; use distribution_filename::WheelFilename; use pep440_rs::Version; use pypi_types::DirectUrl; use uv_normalize::PackageName; use crate::script::{scripts_from_ini, Script}; use crate::wheel::{ extra_dist_info, install_data, parse_metadata, parse_wheel_file, read_record_file, write_script_entrypoints, LibKind, }; use crate::{Error, Layout}; /// 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, wheel: impl AsRef, filename: &WheelFilename, direct_url: Option<&DirectUrl>, installer: Option<&str>, link_mode: LinkMode, ) -> Result<(), Error> { let dist_info_prefix = find_dist_info(&wheel)?; let metadata = dist_info_metadata(&dist_info_prefix, &wheel)?; let (name, version) = parse_metadata(&dist_info_prefix, &metadata)?; // Validate the wheel name and version. { let name = PackageName::from_str(&name)?; if name != filename.name { return Err(Error::MismatchedName(name, filename.name.clone())); } let version = Version::from_str(&version)?; 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). debug!(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)?; debug!(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() { debug!(name, "No entrypoints"); } else { debug!(name, "Writing entrypoints"); fs_err::create_dir_all(&layout.scheme.scripts)?; write_script_entrypoints(layout, site_packages, &console_scripts, &mut record, false)?; write_script_entrypoints(layout, 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() { debug!(name, "Installing data"); install_data( layout, 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 { debug!(name, "No data"); } debug!(name, "Writing extra metadata"); extra_dist_info( site_packages, &dist_info_prefix, true, direct_url, installer, &mut record, )?; debug!(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