Remove some unused code from install-wheel-rs (#2001)

I need to make a bunch of changes to this crate, and I'm finding that
the existing unused interfaces are really getting in the way.
This commit is contained in:
Charlie Marsh 2024-02-26 23:27:25 -05:00 committed by GitHub
parent 6678d545fb
commit 5997d0da3d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 45 additions and 826 deletions

View file

@ -1,75 +1,5 @@
use std::io;
use std::path::{Path, PathBuf};
use fs2::FileExt;
use fs_err::File;
use tracing::{error, warn};
use uv_fs::Normalized;
const INSTALL_LOCKFILE: &str = "install-wheel-rs.lock";
/// I'm not sure that's the right way to normalize here, but it's a single place to change
/// everything.
///
/// For displaying to the user, `-` is better, and it's also what poetry lockfile 2.0 does
///
/// Keep in sync with `find_distributions`
pub fn normalize_name(dep_name: &str) -> String {
dep_name.to_lowercase().replace(['.', '_'], "-")
}
/// A directory for which we acquired a install-wheel-rs.lock lockfile
pub struct LockedDir {
/// The directory to lock
path: PathBuf,
/// handle on the install-wheel-rs.lock that drops the lock
lockfile: File,
}
impl LockedDir {
/// Tries to lock the directory, returns Ok(None) if it is already locked
pub fn try_acquire(path: &Path) -> io::Result<Option<Self>> {
let lockfile = File::create(path.join(INSTALL_LOCKFILE))?;
if lockfile.file().try_lock_exclusive().is_ok() {
Ok(Some(Self {
path: path.to_path_buf(),
lockfile,
}))
} else {
Ok(None)
}
}
/// Locks the directory, if necessary blocking until the lock becomes free
pub fn acquire(path: &Path) -> io::Result<Self> {
let lockfile = File::create(path.join(INSTALL_LOCKFILE))?;
lockfile.file().lock_exclusive()?;
Ok(Self {
path: path.to_path_buf(),
lockfile,
})
}
}
impl Drop for LockedDir {
fn drop(&mut self) {
if let Err(err) = self.lockfile.file().unlock() {
error!(
"Failed to unlock {}: {}",
self.lockfile.path().normalized_display(),
err
);
}
}
}
impl AsRef<Path> for LockedDir {
fn as_ref(&self) -> &Path {
&self.path
}
}
/// A virtual environment into which a wheel can be installed.
///
/// We use a lockfile to prevent multiple instance writing stuff on the same time
@ -109,22 +39,3 @@ impl<T: AsRef<Path>> InstallLocation<T> {
&self.venv_root
}
}
impl InstallLocation<PathBuf> {
pub fn acquire_lock(&self) -> io::Result<InstallLocation<LockedDir>> {
let locked_dir = if let Some(locked_dir) = LockedDir::try_acquire(&self.venv_root)? {
locked_dir
} else {
warn!(
"Could not acquire exclusive lock for installing, is another installation process \
running? Sleeping until lock becomes free"
);
LockedDir::acquire(&self.venv_root)?
};
Ok(InstallLocation {
venv_root: locked_dir,
python_version: self.python_version,
})
}
}

View file

@ -11,22 +11,15 @@ use zip::result::ZipError;
use zip::ZipArchive;
use distribution_filename::WheelFilename;
pub use install_location::{normalize_name, InstallLocation, LockedDir};
pub use install_location::InstallLocation;
use pep440_rs::Version;
use platform_host::{Arch, Os};
pub use record::RecordEntry;
pub use script::Script;
pub use uninstall::{uninstall_wheel, Uninstall};
use uv_fs::Normalized;
use uv_normalize::PackageName;
pub use wheel::{
install_wheel, parse_key_value_file, read_record_file, relative_to, SHEBANG_PYTHON,
};
mod install_location;
pub mod linker;
#[cfg(feature = "python_bindings")]
mod python_bindings;
mod record;
mod script;
mod uninstall;

View file

@ -10,17 +10,18 @@ use reflink_copy as reflink;
use tempfile::tempdir_in;
use tracing::{debug, instrument};
use crate::Error;
use distribution_filename::WheelFilename;
use pep440_rs::Version;
use pypi_types::DirectUrl;
use uv_normalize::PackageName;
use crate::install_location::InstallLocation;
use crate::script::scripts_from_ini;
use crate::script::{scripts_from_ini, Script};
use crate::wheel::{
extra_dist_info, install_data, parse_metadata, parse_wheel_version, write_script_entrypoints,
extra_dist_info, install_data, parse_metadata, parse_wheel_version, read_record_file,
write_script_entrypoints,
};
use crate::{read_record_file, Error, Script};
/// Install the given wheel to the given venv
///

View file

@ -1,79 +0,0 @@
use std::path::PathBuf;
use std::str::FromStr;
use clap::Parser;
use fs_err::File;
#[cfg(feature = "rayon")]
use rayon::iter::{IntoParallelIterator, ParallelIterator};
use distribution_filename::WheelFilename;
use install_wheel_rs::{install_wheel, Error, InstallLocation};
/// Low level install CLI, mainly used for testing
#[derive(Parser)]
struct Args {
wheels: Vec<PathBuf>,
/// The root of the venv to install in
#[clap(long, env = "VIRTUAL_ENV")]
venv: PathBuf,
/// The major version of the current python interpreter
#[clap(long)]
major: u8,
/// The minor version of the current python interpreter
#[clap(long)]
minor: u8,
/// Compile .py files to .pyc (errors are ignored)
#[clap(long)]
compile: bool,
/// Don't check the hashes in RECORD
#[clap(long)]
skip_hashes: bool,
}
fn main() -> Result<(), Error> {
let args = Args::parse();
let venv_base = args.venv.canonicalize()?;
let location = InstallLocation::new(venv_base, (args.major, args.minor));
let locked_dir = location.acquire_lock()?;
let wheels: Vec<(PathBuf, WheelFilename)> = args
.wheels
.into_iter()
.map(|wheel| {
let filename = wheel
.file_name()
.ok_or_else(|| Error::InvalidWheel("Expected a file".to_string()))?
.to_string_lossy();
let filename = WheelFilename::from_str(&filename)?;
Ok((wheel, filename))
})
.collect::<Result<_, Error>>()?;
let wheels = {
#[cfg(feature = "rayon")]
{
wheels.into_par_iter()
}
#[cfg(not(feature = "rayon"))]
{
wheels.into_iter()
}
};
wheels
.map(|(wheel, filename)| {
install_wheel(
&locked_dir,
File::open(wheel)?,
&filename,
None,
None,
args.compile,
!args.skip_hashes,
&[],
location.python(),
)?;
Ok(())
})
.collect::<Result<_, Error>>()?;
Ok(())
}

View file

@ -1,92 +0,0 @@
#![allow(clippy::format_push_string)] // I will not replace clear and infallible with fallible, io looking code
use crate::{install_wheel, Error, InstallLocation, LockedDir};
use distribution_filename::WheelFilename;
use fs_err::File;
use pyo3::create_exception;
use pyo3::types::PyModule;
use pyo3::{pyclass, pymethods, pymodule, PyErr, PyResult, Python};
use std::env;
use std::path::{Path, PathBuf};
use std::str::FromStr;
create_exception!(
install_wheel_rs,
PyWheelInstallerError,
pyo3::exceptions::PyException
);
impl From<Error> for PyErr {
fn from(err: Error) -> Self {
let mut accumulator = format!("Failed to install wheels: {err}");
let mut current_err: &dyn std::error::Error = &err;
while let Some(cause) = current_err.source() {
accumulator.push_str(&format!("\n Caused by: {cause}"));
current_err = cause;
}
PyWheelInstallerError::new_err(accumulator)
}
}
#[pyclass]
struct LockedVenv {
location: InstallLocation<LockedDir>,
}
#[pymethods]
impl LockedVenv {
#[new]
#[allow(clippy::needless_pass_by_value)]
pub(crate) fn new(py: Python, venv: PathBuf) -> PyResult<Self> {
Ok(Self {
location: InstallLocation::new(
LockedDir::acquire(&venv)?,
(py.version_info().major, py.version_info().minor),
),
})
}
pub(crate) fn install_wheel(&self, py: Python, wheel: PathBuf) -> PyResult<()> {
// Would be nicer through https://docs.python.org/3/c-api/init.html#c.Py_GetProgramFullPath
let sys_executable: String = py.import("sys")?.getattr("executable")?.extract()?;
// TODO: Pass those options on to the user
py.allow_threads(|| {
let filename = wheel
.file_name()
.ok_or_else(|| Error::InvalidWheel("Expected a file".to_string()))?
.to_string_lossy();
let filename = WheelFilename::from_str(&filename)?;
install_wheel(
&self.location,
File::open(wheel)?,
&filename,
None,
None,
true,
true,
&[],
Path::new(&sys_executable),
)
})?;
Ok(())
}
}
#[pymodule]
pub(crate) fn install_wheel_rs(_py: Python, m: &PyModule) -> PyResult<()> {
// Good enough for now
if env::var_os("RUST_LOG").is_some() {
tracing_subscriber::fmt::init();
} else {
let format = tracing_subscriber::fmt::format()
.with_level(false)
.with_target(false)
.without_time()
.compact();
tracing_subscriber::fmt().event_format(format).init();
}
m.add_class::<LockedVenv>()?;
Ok(())
}

View file

@ -8,9 +8,9 @@ use serde::{Deserialize, Serialize};
/// tqdm-4.62.3.dist-info/RECORD,,
/// ```
#[derive(Deserialize, Serialize, PartialOrd, PartialEq, Ord, Eq)]
pub struct RecordEntry {
pub path: String,
pub hash: Option<String>,
pub(crate) struct RecordEntry {
pub(crate) path: String,
pub(crate) hash: Option<String>,
#[allow(dead_code)]
pub size: Option<u64>,
pub(crate) size: Option<u64>,
}

View file

@ -8,26 +8,11 @@ use crate::{wheel, Error};
/// A script defining the name of the runnable entrypoint and the module and function that should be
/// run.
#[cfg(feature = "python_bindings")]
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[pyo3::pyclass(dict)]
pub struct Script {
#[pyo3(get)]
pub script_name: String,
#[pyo3(get)]
pub module: String,
#[pyo3(get)]
pub function: String,
}
/// A script defining the name of the runnable entrypoint and the module and function that should be
/// run.
#[cfg(not(feature = "python_bindings"))]
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub struct Script {
pub script_name: String,
pub module: String,
pub function: String,
pub(crate) struct Script {
pub(crate) name: String,
pub(crate) module: String,
pub(crate) function: String,
}
impl Script {
@ -36,7 +21,7 @@ impl Script {
/// <https://packaging.python.org/en/latest/specifications/entry-points/>
///
/// Extras are supposed to be ignored, which happens if you pass None for extras
pub fn from_value(
pub(crate) fn from_value(
script_name: &str,
value: &str,
extras: Option<&[String]>,
@ -66,13 +51,13 @@ impl Script {
}
Ok(Some(Self {
script_name: script_name.to_string(),
name: script_name.to_string(),
module: captures.name("module").unwrap().as_str().to_string(),
function: captures.name("function").unwrap().as_str().to_string(),
}))
}
pub fn import_name(&self) -> &str {
pub(crate) fn import_name(&self) -> &str {
self.function
.split_once('.')
.map_or(&self.function, |(import_name, _)| import_name)
@ -104,13 +89,13 @@ pub(crate) fn scripts_from_ini(
// https://github.com/pypa/pip/blob/3898741e29b7279e7bffe044ecfbe20f6a438b1e/src/pip/_internal/operations/install/wheel.py#L283
// https://github.com/astral-sh/uv/issues/1593
for script in &mut console_scripts {
let Some((left, right)) = script.script_name.split_once('.') else {
let Some((left, right)) = script.name.split_once('.') else {
continue;
};
if left != "pip3" || right.parse::<u8>().is_err() {
continue;
}
script.script_name = format!("pip3.{python_minor}");
script.name = format!("pip3.{python_minor}");
}
Ok((console_scripts, gui_scripts))
@ -118,7 +103,7 @@ pub(crate) fn scripts_from_ini(
#[cfg(test)]
mod test {
use crate::Script;
use crate::script::Script;
#[test]
fn test_valid_script_names() {

View file

@ -4,7 +4,8 @@ use std::path::{Component, Path, PathBuf};
use fs_err as fs;
use tracing::debug;
use crate::{read_record_file, Error};
use crate::wheel::read_record_file;
use crate::Error;
/// Uninstall the wheel represented by the given `dist_info` directory.
pub fn uninstall_wheel(dist_info: &Path) -> Result<Uninstall, Error> {

View file

@ -1,36 +1,27 @@
use std::collections::HashMap;
use std::io::{BufRead, BufReader, BufWriter, Cursor, Read, Seek, Write};
use std::io::{BufRead, BufReader, Cursor, Read, Write};
use std::path::{Path, PathBuf};
use std::process::{Command, ExitStatus, Stdio};
use std::str::FromStr;
use std::{env, io, iter};
use data_encoding::BASE64URL_NOPAD;
use fs_err as fs;
use fs_err::{DirEntry, File};
use mailparse::MailHeaderMap;
use rustc_hash::{FxHashMap, FxHashSet};
use rustc_hash::FxHashMap;
use sha2::{Digest, Sha256};
use tempfile::tempdir;
use tracing::{debug, error, instrument, warn};
use walkdir::WalkDir;
use zip::result::ZipError;
use zip::write::FileOptions;
use zip::{ZipArchive, ZipWriter};
use distribution_filename::WheelFilename;
use pep440_rs::Version;
use tracing::{instrument, warn};
use walkdir::WalkDir;
use zip::write::FileOptions;
use zip::ZipWriter;
use pypi_types::DirectUrl;
use uv_fs::Normalized;
use uv_normalize::PackageName;
use crate::install_location::{InstallLocation, LockedDir};
use crate::install_location::InstallLocation;
use crate::record::RecordEntry;
use crate::script::{scripts_from_ini, Script};
use crate::{find_dist_info, Error};
/// `#!/usr/bin/env python`
pub const SHEBANG_PYTHON: &str = "#!/usr/bin/env python";
use crate::script::Script;
use crate::Error;
const LAUNCHER_MAGIC_NUMBER: [u8; 4] = [b'U', b'V', b'U', b'V'];
@ -97,27 +88,6 @@ pub(crate) fn read_scripts_from_section(
Ok(scripts)
}
/// 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<R: Read + Seek>(
archive: &mut ZipArchive<R>,
dist_info_dir: &str,
extras: Option<&[String]>,
python_minor: u8,
) -> Result<(Vec<Script>, Vec<Script>), Error> {
let entry_points_path = format!("{dist_info_dir}/entry_points.txt");
let ini = match archive.by_name(&entry_points_path) {
Ok(file) => std::io::read_to_string(file)?,
Err(ZipError::FileNotFound) => return Ok((Vec::new(), Vec::new())),
Err(err) => return Err(Error::Zip(entry_points_path, err)),
};
scripts_from_ini(extras, python_minor, ini)
}
/// Shamelessly stolen (and updated for recent sha2)
/// <https://github.com/richo/hashing-copy/blob/d8dd2fdb63c6faf198de0c9e5713d6249cbb5323/src/lib.rs#L10-L52>
/// which in turn got it from std
@ -146,125 +116,6 @@ fn copy_and_hash(reader: &mut impl Read, writer: &mut impl Write) -> io::Result<
))
}
/// Extract all files from the wheel into the site packages
///
/// Matches with the RECORD entries
///
/// Returns paths relative to site packages
fn unpack_wheel_files<R: Read + Seek>(
site_packages: &Path,
record_path: &str,
archive: &mut ZipArchive<R>,
record: &[RecordEntry],
check_hashes: bool,
) -> Result<Vec<PathBuf>, Error> {
let mut extracted_paths = Vec::new();
// Cache the created parent dirs to avoid io calls
// When deactivating bytecode compilation and sha2 those were 5% of total runtime, with
// cache it 2.3%
let mut created_dirs = FxHashSet::default();
// https://github.com/zip-rs/zip/blob/7edf2489d5cff8b80f02ee6fc5febf3efd0a9442/examples/extract.rs
for i in 0..archive.len() {
let mut file = archive.by_index(i).map_err(|err| {
let file1 = format!("(index {i})");
Error::Zip(file1, err)
})?;
// enclosed_name takes care of evil zip paths
let relative = match file.enclosed_name() {
Some(path) => path.to_owned(),
None => continue,
};
let out_path = site_packages.join(&relative);
if file.name().ends_with('/') {
// pip seems to do ignore those folders, so do we
// fs::create_dir_all(&out_path)?;
continue;
}
if let Some(parent) = out_path.parent() {
if created_dirs.insert(parent.to_path_buf()) {
fs::create_dir_all(parent)?;
}
}
let mut outfile = BufWriter::new(File::create(&out_path)?);
let encoded_hash = if check_hashes {
let (_size, encoded_hash) = copy_and_hash(&mut file, &mut outfile)?;
Some(encoded_hash)
} else {
io::copy(&mut file, &mut outfile)?;
None
};
extracted_paths.push(relative.clone());
// Get and Set permissions
#[cfg(unix)]
{
use std::fs::Permissions;
use std::os::unix::fs::PermissionsExt;
if let Some(mode) = file.unix_mode() {
fs::set_permissions(&out_path, Permissions::from_mode(mode))?;
}
}
// This is the RECORD file that contains the hashes so naturally it can't contain it's own
// hash and size (but it does contain an entry with two empty fields)
// > 6. RECORD.jws is used for digital signatures. It is not mentioned in RECORD.
// > 7. RECORD.p7s is allowed as a courtesy to anyone who would prefer to use S/MIME
// > signatures to secure their wheel files. It is not mentioned in RECORD.
let record_path = PathBuf::from(&record_path);
if [
record_path.clone(),
record_path.with_extension("jws"),
record_path.with_extension("p7s"),
]
.contains(&relative)
{
continue;
}
if let Some(encoded_hash) = encoded_hash {
// `relative == Path::new(entry.path)` was really slow
let relative_str = relative.display().to_string();
let recorded_hash = record
.iter()
.find(|entry| relative_str == entry.path)
.and_then(|entry| entry.hash.as_ref())
.ok_or_else(|| {
Error::RecordFile(format!(
"Missing hash for {} (expected {})",
relative.normalized_display(),
encoded_hash
))
})?;
if recorded_hash != &encoded_hash {
if relative.as_os_str().to_string_lossy().starts_with("torch-") {
error!(
"Hash mismatch for {}. Recorded: {}, Actual: {}",
relative.normalized_display(),
recorded_hash,
encoded_hash,
);
error!(
"Torch isn't capable of producing correct hashes 🙄 Ignoring. \
https://github.com/pytorch/pytorch/issues/47916"
);
continue;
}
return Err(Error::RecordFile(format!(
"Hash mismatch for {}. Recorded: {}, Actual: {}",
relative.normalized_display(),
recorded_hash,
encoded_hash,
)));
}
}
}
Ok(extracted_paths)
}
fn get_shebang(location: &InstallLocation<impl AsRef<Path>>) -> String {
format!("#!{}", location.python().normalized().display())
}
@ -359,15 +210,15 @@ pub(crate) fn write_script_entrypoints(
let entrypoint_relative = if cfg!(windows) {
// On windows we actually build an .exe wrapper
let script_name = entrypoint
.script_name
.name
// FIXME: What are the in-reality rules here for names?
.strip_suffix(".py")
.unwrap_or(&entrypoint.script_name)
.unwrap_or(&entrypoint.name)
.to_string()
+ ".exe";
bin_rel().join(script_name)
} else {
bin_rel().join(&entrypoint.script_name)
bin_rel().join(&entrypoint.name)
};
// Generate the launcher script.
@ -456,156 +307,12 @@ pub(crate) fn parse_wheel_version(wheel_text: &str) -> Result<(), Error> {
Ok(())
}
/// Call `python -m compileall` to generate pyc file for the installed code
///
/// 2.f Compile any installed .py to .pyc. (Uninstallers should be smart enough to remove .pyc
/// even if it is not mentioned in RECORD.)
#[instrument(skip_all)]
fn bytecode_compile(
site_packages: &Path,
unpacked_paths: Vec<PathBuf>,
python_version: (u8, u8),
sys_executable: &Path,
// Only for logging
name: &str,
record: &mut Vec<RecordEntry>,
) -> Result<(), Error> {
// https://github.com/pypa/pip/blob/b5457dfee47dd9e9f6ec45159d9d410ba44e5ea1/src/pip/_internal/operations/install/wheel.py#L592-L603
let py_source_paths: Vec<_> = unpacked_paths
.into_iter()
.filter(|path| {
path.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("py"))
&& site_packages.join(path).is_file()
})
.collect();
// bytecode compiling crashes non-deterministically with various errors, from syntax errors
// to cpython segmentation faults, so we add a simple retry loop
let mut retries = 3;
let (status, lines) = loop {
let (status, lines) =
bytecode_compile_inner(site_packages, &py_source_paths, sys_executable)?;
retries -= 1;
if status.success() || retries == 0 {
break (status, lines);
}
warn!("Failed to compile {name} with python compileall, retrying",);
};
if !status.success() {
// lossy because we want the error reporting to survive c̴̞̏ü̸̜̹̈́ŕ̴͉̈ś̷̤ė̵̤͋d̷͙̄ filenames in the zip
return Err(Error::PythonSubcommand(io::Error::new(
io::ErrorKind::Other,
format!("Failed to run python compileall, log above: {status}"),
)));
}
// like pip, we just ignored all that failed to compile
// Add each that succeeded to the RECORD
for py_path in lines {
let py_path = py_path.trim();
if py_path.is_empty() {
continue;
}
let py_path = Path::new(py_path);
let pyc_path = py_path
.parent()
.unwrap_or_else(|| Path::new(""))
.join("__pycache__")
// Unwrap is save because we checked for an extension before
.join(py_path.file_name().unwrap())
.with_extension(format!(
"cpython-{}{}.pyc",
python_version.0, python_version.1
));
if !site_packages.join(&pyc_path).is_file() {
return Err(Error::PythonSubcommand(io::Error::new(
io::ErrorKind::NotFound,
format!(
"Didn't find pyc generated by compileall: {}",
site_packages.join(&pyc_path).normalized_display()
),
)));
}
// 2.d Update distribution-1.0.dist-info/RECORD with the installed paths.
// https://www.python.org/dev/peps/pep-0376/#record
// > [..] a hash of the file's contents. Notice that pyc and pyo generated files don't have
// > any hash because they are automatically produced from py files. So checking the hash of
// > the corresponding py file is enough to decide if the file and its associated pyc or pyo
// > files have changed.
record.push(RecordEntry {
path: pyc_path.display().to_string(),
hash: None,
size: None,
});
}
Ok(())
}
/// The actual command part which we repeat if it fails
fn bytecode_compile_inner(
site_packages: &Path,
py_source_paths: &[PathBuf],
sys_executable: &Path,
) -> Result<(ExitStatus, Vec<String>), Error> {
let temp_dir = tempdir()?;
// Running python with an actual file will produce better error messages
let pip_compileall_py = temp_dir.path().join("pip_compileall.py");
fs::write(&pip_compileall_py, include_str!("pip_compileall.py"))?;
// We input the paths through stdin and get the successful paths returned through stdout
let mut bytecode_compiler = Command::new(sys_executable)
.arg(&pip_compileall_py)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.current_dir(site_packages.normalized())
.spawn()
.map_err(Error::PythonSubcommand)?;
// https://stackoverflow.com/questions/49218599/write-to-child-process-stdin-in-rust/49597789#comment120223107_49597789
let mut child_stdin = bytecode_compiler
.stdin
.take()
.expect("Child must have stdin");
// Pass paths newline terminated to compileall
for path in py_source_paths {
debug!("bytecode compiling {}", path.display());
// There is no OsStr -> Bytes conversion on windows :o
// https://stackoverflow.com/questions/43083544/how-can-i-convert-osstr-to-u8-vecu8-on-windows
writeln!(&mut child_stdin, "{}", path.display()).map_err(Error::PythonSubcommand)?;
}
// Close stdin to finish and avoid indefinite blocking
drop(child_stdin);
// Already read stdout here to avoid it running full (pipes are limited)
let stdout = bytecode_compiler.stdout.take().unwrap();
let mut lines: Vec<String> = Vec::new();
for line in BufReader::new(stdout).lines() {
let line = line.map_err(|err| {
Error::PythonSubcommand(io::Error::new(
io::ErrorKind::Other,
format!("Invalid utf-8 returned by python compileall: {err}"),
))
})?;
lines.push(line);
}
let output = bytecode_compiler
.wait_with_output()
.map_err(Error::PythonSubcommand)?;
Ok((output.status, lines))
}
/// Give the path relative to the base directory
///
/// lib/python/site-packages/foo/__init__.py and lib/python/site-packages -> foo/__init__.py
/// lib/marker.txt and lib/python/site-packages -> ../../marker.txt
/// `bin/foo_launcher` and lib/python/site-packages -> ../../../`bin/foo_launcher`
pub fn relative_to(path: &Path, base: &Path) -> Result<PathBuf, Error> {
pub(crate) fn relative_to(path: &Path, base: &Path) -> Result<PathBuf, Error> {
// Find the longest common prefix, and also return the path stripped from that prefix
let (stripped, common_prefix) = base
.ancestors()
@ -787,7 +494,7 @@ pub(crate) fn install_data(
if console_scripts
.iter()
.chain(gui_scripts)
.any(|script| script.script_name == match_name)
.any(|script| script.name == match_name)
{
continue;
}
@ -884,7 +591,7 @@ pub(crate) fn extra_dist_info(
/// Reads the record file
/// <https://www.python.org/dev/peps/pep-0376/#record>
pub fn read_record_file(record: &mut impl Read) -> Result<Vec<RecordEntry>, Error> {
pub(crate) fn read_record_file(record: &mut impl Read) -> Result<Vec<RecordEntry>, Error> {
csv::ReaderBuilder::new()
.has_headers(false)
.escape(Some(b'"'))
@ -902,7 +609,7 @@ pub fn read_record_file(record: &mut impl Read) -> Result<Vec<RecordEntry>, Erro
}
/// Parse a file with `Key: value` entries such as WHEEL and METADATA
pub fn parse_key_value_file(
fn parse_key_value_file(
file: &mut impl Read,
debug_filename: &str,
) -> Result<FxHashMap<String, Vec<String>>, Error> {
@ -926,197 +633,6 @@ pub fn parse_key_value_file(
Ok(data)
}
/// Install the given wheel to the given venv
///
/// The caller must ensure that the wheel is compatible to the environment.
///
/// <https://packaging.python.org/en/latest/specifications/binary-distribution-format/#installing-a-wheel-distribution-1-0-py32-none-any-whl>
///
/// Wheel 1.0: <https://www.python.org/dev/peps/pep-0427/>
#[allow(clippy::too_many_arguments)]
#[instrument(skip_all, fields(name = % filename.name))]
pub fn install_wheel(
location: &InstallLocation<LockedDir>,
reader: impl Read + Seek,
filename: &WheelFilename,
direct_url: Option<&DirectUrl>,
installer: Option<&str>,
compile: bool,
check_hashes: bool,
// initially used to the console scripts, currently unused. Keeping it because we likely need
// it for validation later.
_extras: &[String],
sys_executable: impl AsRef<Path>,
) -> Result<String, Error> {
let name = &filename.name;
let base_location = location.venv_root();
let site_packages_python = format!(
"python{}.{}",
location.python_version().0,
location.python_version().1
);
let site_packages = if cfg!(target_os = "windows") {
base_location.as_ref().join("Lib").join("site-packages")
} else {
base_location
.as_ref()
.join("lib")
.join(site_packages_python)
.join("site-packages")
};
debug!(name = name.as_ref(), "Opening zip");
// No BufReader: https://github.com/zip-rs/zip/issues/381
let mut archive = ZipArchive::new(reader).map_err(|err| {
let file = "(index)".to_string();
Error::Zip(file, err)
})?;
debug!(name = name.as_ref(), "Getting wheel metadata");
let dist_info_prefix = find_dist_info(filename, archive.file_names().map(|name| (name, name)))?
.1
.to_string();
let metadata = dist_info_metadata(&dist_info_prefix, &mut archive)?;
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 {
return Err(Error::MismatchedVersion(version, filename.version.clone()));
}
}
let record_path = format!("{dist_info_prefix}.dist-info/RECORD");
let mut record = read_record_file(&mut archive.by_name(&record_path).map_err(|err| {
let file = record_path.clone();
Error::Zip(file, err)
})?)?;
// 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 = format!("{dist_info_prefix}.dist-info/WHEEL");
let wheel_file = archive
.by_name(&wheel_file_path)
.map_err(|err| Error::Zip(wheel_file_path, err))?;
let wheel_text = io::read_to_string(wheel_file)?;
parse_wheel_version(&wheel_text)?;
// > 1.c If Root-Is-Purelib == true, unpack archive into purelib (site-packages).
// > 1.d Else unpack archive into platlib (site-packages).
// We always install in the same virtualenv site packages
debug!(name = name.as_str(), "Extracting file");
let unpacked_paths = unpack_wheel_files(
&site_packages,
&record_path,
&mut archive,
&record,
check_hashes,
)?;
debug!(
name = name.as_str(),
"Extracted {} files",
unpacked_paths.len()
);
debug!(name = name.as_str(), "Writing entrypoints");
let (console_scripts, gui_scripts) = parse_scripts(
&mut archive,
&dist_info_prefix,
None,
location.python_version().1,
)?;
write_script_entrypoints(
&site_packages,
location,
&console_scripts,
&mut record,
false,
)?;
write_script_entrypoints(&site_packages, location, &gui_scripts, &mut record, true)?;
let data_dir = site_packages.join(format!("{dist_info_prefix}.data"));
// 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.
if data_dir.is_dir() {
debug!(name = name.as_str(), "Installing data");
install_data(
base_location.as_ref(),
&site_packages,
&data_dir,
&name,
location,
&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 = name.as_str(), "No data");
}
// 2.f Compile any installed .py to .pyc. (Uninstallers should be smart enough to remove .pyc even if it is not mentioned in RECORD.)
if compile {
debug!(name = name.as_str(), "Bytecode compiling");
bytecode_compile(
&site_packages,
unpacked_paths,
location.python_version(),
sys_executable.as_ref(),
name.as_str(),
&mut record,
)?;
}
debug!(name = name.as_str(), "Writing extra metadata");
extra_dist_info(
&site_packages,
&dist_info_prefix,
true,
direct_url,
installer,
&mut record,
)?;
debug!(name = name.as_str(), "Writing record");
let mut record_writer = csv::WriterBuilder::new()
.has_headers(false)
.escape(b'"')
.from_path(site_packages.join(record_path))?;
record.sort();
for entry in record {
record_writer.serialize(entry)?;
}
Ok(filename.get_tag())
}
/// Read the `dist-info` metadata from a wheel archive.
fn dist_info_metadata(
dist_info_prefix: &str,
archive: &mut ZipArchive<impl Read + Seek + Sized>,
) -> Result<Vec<u8>, Error> {
let mut content = Vec::new();
let dist_info_file = format!("{dist_info_prefix}.dist-info/METADATA");
archive
.by_name(&dist_info_file)
.map_err(|err| Error::Zip(dist_info_file.clone(), err))?
.read_to_end(&mut content)?;
Ok(content)
}
/// Parse the distribution name and version from a wheel's `dist-info` metadata.
///
/// See: <https://github.com/PyO3/python-pkginfo-rs>
@ -1256,7 +772,7 @@ mod test {
assert_eq!(
Script::from_value("launcher", "foo.bar:main", None).unwrap(),
Some(Script {
script_name: "launcher".to_string(),
name: "launcher".to_string(),
module: "foo.bar".to_string(),
function: "main".to_string(),
})
@ -1269,7 +785,7 @@ mod test {
)
.unwrap(),
Some(Script {
script_name: "launcher".to_string(),
name: "launcher".to_string(),
module: "foo.bar".to_string(),
function: "main".to_string(),
})
@ -1286,7 +802,7 @@ mod test {
)
.unwrap(),
Some(Script {
script_name: "launcher".to_string(),
name: "launcher".to_string(),
module: "foomod".to_string(),
function: "main_bar".to_string(),
})