diff --git a/Cargo.lock b/Cargo.lock index 70b0c2cc504e5..469edd61e7cca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7109,6 +7109,7 @@ name = "uv-tool" version = "0.0.24" dependencies = [ "fs-err", + "owo-colors", "pathdiff", "serde", "thiserror 2.0.18", diff --git a/crates/uv-python/src/discovery.rs b/crates/uv-python/src/discovery.rs index 67a3c2bac5950..d8a5eb13b4246 100644 --- a/crates/uv-python/src/discovery.rs +++ b/crates/uv-python/src/discovery.rs @@ -41,7 +41,7 @@ use crate::virtualenv::{ }; #[cfg(windows)] use crate::windows_registry::{WindowsPython, registry_pythons}; -use crate::{BrokenSymlink, Interpreter, PythonVersion}; +use crate::{BrokenLink, Interpreter, PythonVersion}; /// A request to find a Python installation. /// @@ -1054,7 +1054,7 @@ impl Error { false } InterpreterError::NotFound(path) - | InterpreterError::BrokenSymlink(BrokenSymlink { path, .. }) => { + | InterpreterError::BrokenLink(BrokenLink { path, .. }) => { // If the interpreter is from an active, valid virtual environment, we should // fail because it's broken if matches!(source, PythonSource::ActiveEnvironment) @@ -1129,7 +1129,7 @@ pub fn find_python_installations<'a>( debug!("Checking for Python interpreter at {request}"); match python_installation_from_executable(path, cache) { Ok(installation) => Ok(Ok(installation)), - Err(InterpreterError::NotFound(_) | InterpreterError::BrokenSymlink(_)) => { + Err(InterpreterError::NotFound(_) | InterpreterError::BrokenLink(_)) => { Ok(Err(PythonNotFound { request: request.clone(), python_preference: preference, @@ -1155,7 +1155,7 @@ pub fn find_python_installations<'a>( debug!("Checking for Python interpreter in {request}"); match python_installation_from_directory(path, cache) { Ok(installation) => Ok(Ok(installation)), - Err(InterpreterError::NotFound(_) | InterpreterError::BrokenSymlink(_)) => { + Err(InterpreterError::NotFound(_) | InterpreterError::BrokenLink(_)) => { Ok(Err(PythonNotFound { request: request.clone(), python_preference: preference, diff --git a/crates/uv-python/src/interpreter.rs b/crates/uv-python/src/interpreter.rs index a1f0814f11b3f..6b3f193a0108d 100644 --- a/crates/uv-python/src/interpreter.rs +++ b/crates/uv-python/src/interpreter.rs @@ -32,8 +32,8 @@ use crate::implementation::LenientImplementationName; use crate::managed::ManagedPythonInstallations; use crate::pointer_size::PointerSize; use crate::{ - Prefix, PythonInstallationKey, PythonVariant, PythonVersion, Target, VersionRequest, - VirtualEnvironment, + Prefix, PyVenvConfiguration, PythonInstallationKey, PythonVariant, PythonVersion, Target, + VersionRequest, VirtualEnvironment, }; #[cfg(windows)] @@ -824,7 +824,7 @@ pub enum Error { #[error("Failed to query Python interpreter")] Io(#[from] io::Error), #[error(transparent)] - BrokenSymlink(BrokenSymlink), + BrokenLink(BrokenLink), #[error("Python interpreter not found at `{0}`")] NotFound(PathBuf), #[error("Failed to query Python interpreter at `{path}`")] @@ -861,19 +861,30 @@ pub enum Error { } #[derive(Debug, Error)] -pub struct BrokenSymlink { +pub struct BrokenLink { pub path: PathBuf, + /// Whether we have a broken symlink (Unix) or whether the shim returned that the underlying + /// Python went away (Windows). + pub unix: bool, /// Whether the interpreter path looks like a virtual environment. pub venv: bool, } -impl Display for BrokenSymlink { +impl Display for BrokenLink { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - write!( - f, - "Broken symlink at `{}`, was the underlying Python interpreter removed?", - self.path.user_display() - )?; + if self.unix { + write!( + f, + "Broken symlink at `{}`, was the underlying Python interpreter removed?", + self.path.user_display() + )?; + } else { + write!( + f, + "Broken Python trampoline at `{}`, was the underlying Python interpreter removed?", + self.path.user_display() + )?; + } if self.venv { write!( f, @@ -994,6 +1005,19 @@ impl InterpreterInfo { if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + // Handle uninstalled CPython interpreters on Windows. + // + // The IO error from the CPython trampoline is unstructured and localized, so we check + // whether the `home` from `pyvenv.cfg` still exists, it's missing if the Python + // interpreter was uninstalled. + if python_home(interpreter).is_some_and(|home| !home.exists()) { + return Err(Error::BrokenLink(BrokenLink { + path: interpreter.to_path_buf(), + unix: false, + venv: uv_fs::is_virtualenv_executable(interpreter), + })); + } + // If the Python version is too old, we may not even be able to invoke the query script if stderr.contains("Unknown option: -I") { return Err(Error::QueryScript { @@ -1092,8 +1116,9 @@ impl InterpreterInfo { .symlink_metadata() .is_ok_and(|metadata| metadata.is_symlink()) { - Error::BrokenSymlink(BrokenSymlink { + Error::BrokenLink(BrokenLink { path: executable.to_path_buf(), + unix: true, venv: uv_fs::is_virtualenv_executable(executable), }) } else { @@ -1273,6 +1298,13 @@ fn find_base_python( } } +/// Parse the `home` key from `pyvenv.cfg`, if any. +fn python_home(interpreter: &Path) -> Option { + let venv_root = interpreter.parent()?.parent()?; + let pyvenv_cfg = PyVenvConfiguration::parse(venv_root.join("pyvenv.cfg")).ok()?; + pyvenv_cfg.home +} + #[cfg(unix)] #[cfg(test)] mod tests { diff --git a/crates/uv-python/src/lib.rs b/crates/uv-python/src/lib.rs index e2776063aca40..54f6fd79f9419 100644 --- a/crates/uv-python/src/lib.rs +++ b/crates/uv-python/src/lib.rs @@ -17,7 +17,7 @@ pub use crate::installation::{ PythonInstallation, PythonInstallationKey, PythonInstallationMinorVersionKey, }; pub use crate::interpreter::{ - BrokenSymlink, Error as InterpreterError, Interpreter, canonicalize_executable, + BrokenLink, Error as InterpreterError, Interpreter, canonicalize_executable, }; pub use crate::pointer_size::PointerSize; pub use crate::prefix::Prefix; diff --git a/crates/uv-python/src/virtualenv.rs b/crates/uv-python/src/virtualenv.rs index d9c6c4ec97d60..92f4660d5ad03 100644 --- a/crates/uv-python/src/virtualenv.rs +++ b/crates/uv-python/src/virtualenv.rs @@ -34,6 +34,8 @@ pub struct VirtualEnvironment { /// A parsed `pyvenv.cfg` #[derive(Debug, Clone)] pub struct PyVenvConfiguration { + /// The `PYTHONHOME` directory containing the base Python executable. + pub(crate) home: Option, /// Was the virtual environment created with the `virtualenv` package? pub(crate) virtualenv: bool, /// Was the virtual environment created with the `uv` package? @@ -231,6 +233,7 @@ pub(crate) fn virtualenv_python_executable(venv: impl AsRef) -> PathBuf { impl PyVenvConfiguration { /// Parse a `pyvenv.cfg` file into a [`PyVenvConfiguration`]. pub fn parse(cfg: impl AsRef) -> Result { + let mut home = None; let mut virtualenv = false; let mut uv = false; let mut relocatable = false; @@ -248,6 +251,9 @@ impl PyVenvConfiguration { continue; }; match key.trim() { + "home" => { + home = Some(PathBuf::from(value.trim())); + } "virtualenv" => { virtualenv = true; } @@ -274,6 +280,7 @@ impl PyVenvConfiguration { } Ok(Self { + home, virtualenv, uv, relocatable, diff --git a/crates/uv-tool/Cargo.toml b/crates/uv-tool/Cargo.toml index 6be1e67f072d6..43601b86a0310 100644 --- a/crates/uv-tool/Cargo.toml +++ b/crates/uv-tool/Cargo.toml @@ -33,6 +33,7 @@ uv-static = { workspace = true } uv-virtualenv = { workspace = true } fs-err = { workspace = true } +owo-colors = { workspace = true } pathdiff = { workspace = true } serde = { workspace = true } thiserror = { workspace = true } diff --git a/crates/uv-tool/src/lib.rs b/crates/uv-tool/src/lib.rs index b649fbad2c1a2..610402ec7c483 100644 --- a/crates/uv-tool/src/lib.rs +++ b/crates/uv-tool/src/lib.rs @@ -4,6 +4,7 @@ use std::str::FromStr; use fs_err as fs; use fs_err::File; +use owo_colors::OwoColorize; use thiserror::Error; use tracing::{debug, warn}; @@ -14,7 +15,7 @@ use uv_install_wheel::read_record_file; use uv_installer::SitePackages; use uv_normalize::{InvalidNameError, PackageName}; use uv_pep440::Version; -use uv_python::{Interpreter, PythonEnvironment}; +use uv_python::{BrokenLink, Interpreter, PythonEnvironment}; use uv_state::{StateBucket, StateStore}; use uv_static::EnvVars; use uv_virtualenv::remove_virtualenv; @@ -287,15 +288,24 @@ impl InstalledTools { Ok(None) } - Err(uv_python::Error::Query(uv_python::InterpreterError::BrokenSymlink( - broken_symlink, - ))) => { - let target_path = fs_err::read_link(&broken_symlink.path)?; - warn!( - "Ignoring existing virtual environment linked to non-existent Python interpreter: {} -> {}", - broken_symlink.path.user_display(), - target_path.user_display() - ); + Err(uv_python::Error::Query(uv_python::InterpreterError::BrokenLink(BrokenLink { + path, + unix, + venv: _, + }))) => { + if unix { + let target_path = fs_err::read_link(&path)?; + warn!( + "Ignoring existing virtual environment linked to non-existent Python interpreter: {} -> {}", + path.user_display().cyan(), + target_path.user_display().cyan(), + ); + } else { + warn!( + "Ignoring existing virtual environment linked to non-existent Python interpreter: {}", + path.user_display().cyan(), + ); + } Ok(None) } diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index 5f79fefe5eb06..b7e9ee8c6fd28 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -30,9 +30,10 @@ use uv_pep508::MarkerTreeContents; use uv_preview::{Preview, PreviewFeature}; use uv_pypi_types::{ConflictItem, ConflictKind, ConflictSet, Conflicts}; use uv_python::{ - EnvironmentPreference, Interpreter, InvalidEnvironmentKind, PythonDownloads, PythonEnvironment, - PythonInstallation, PythonPreference, PythonRequest, PythonSource, PythonVariant, - PythonVersionFile, VersionFileDiscoveryOptions, VersionRequest, satisfies_python_preference, + BrokenLink, EnvironmentPreference, Interpreter, InvalidEnvironmentKind, PythonDownloads, + PythonEnvironment, PythonInstallation, PythonPreference, PythonRequest, PythonSource, + PythonVariant, PythonVersionFile, VersionFileDiscoveryOptions, VersionRequest, + satisfies_python_preference, }; use uv_requirements::upgrade::{LockedRequirements, read_lock_requirements}; use uv_requirements::{NamedRequirementsResolver, RequirementsSpecification}; @@ -1034,15 +1035,24 @@ impl ProjectInterpreter { } } Err(uv_python::Error::Query(uv_python::InterpreterError::NotFound(_))) => {} - Err(uv_python::Error::Query(uv_python::InterpreterError::BrokenSymlink( - broken_symlink, - ))) => { - let target_path = fs_err::read_link(&broken_symlink.path)?; - warn_user!( - "Ignoring existing virtual environment linked to non-existent Python interpreter: {} -> {}", - broken_symlink.path.user_display().cyan(), - target_path.user_display().cyan(), - ); + Err(uv_python::Error::Query(uv_python::InterpreterError::BrokenLink(BrokenLink { + path, + unix, + venv: _, + }))) => { + if unix { + let target_path = fs_err::read_link(&path)?; + warn_user!( + "Ignoring existing virtual environment linked to non-existent Python interpreter: {} -> {}", + path.user_display().cyan(), + target_path.user_display().cyan(), + ); + } else { + warn_user!( + "Ignoring existing virtual environment linked to non-existent Python interpreter: {}", + path.user_display().cyan(), + ); + } } Err(err) => return Err(err.into()), } diff --git a/crates/uv/tests/it/tool_install.rs b/crates/uv/tests/it/tool_install.rs index 9a13c16974c8c..ba9cc4cfce7ed 100644 --- a/crates/uv/tests/it/tool_install.rs +++ b/crates/uv/tests/it/tool_install.rs @@ -4678,3 +4678,81 @@ fn tool_install_python_platform() { Installed 2 executables: black, blackd "); } + +/// Reinstalling a tool after the underlying Python has been removed. +/// +/// Regression test for . +#[test] +fn tool_install_removed_python() { + let context = uv_test::test_context!("3.12") + .with_filtered_counts() + .with_filtered_exe_suffix(); + let tool_dir = context.temp_dir.child("tools"); + let bin_dir = context.temp_dir.child("bin"); + + let (_, python_executable) = context.python_versions.first().unwrap(); + let install_root = if cfg!(unix) { + // /bin/python3.12 + python_executable.parent().unwrap().parent().unwrap() + } else { + // /python.exe + python_executable.parent().unwrap() + }; + + let temp_python_dir = context.temp_dir.child("temp-python"); + copy_dir_all(install_root, &temp_python_dir).unwrap(); + + let relative_path = python_executable.strip_prefix(install_root).unwrap(); + let temp_python = temp_python_dir.join(relative_path); + + // Install `black` using the temporary Python. + uv_snapshot!(context.filters(), context.tool_install() + .arg("black") + .arg("--python") + .arg(&temp_python) + .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()) + .env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()) + .env(EnvVars::PATH, bin_dir.as_os_str()), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved [N] packages in [TIME] + Prepared [N] packages in [TIME] + Installed [N] packages in [TIME] + + black==24.3.0 + + click==8.1.7 + + mypy-extensions==1.0.0 + + packaging==24.0 + + pathspec==0.12.1 + + platformdirs==4.2.0 + Installed 2 executables: black, blackd + "); + + fs_err::remove_dir_all(&temp_python_dir).unwrap(); + + // Reinstalling should skip the broken Python install. + uv_snapshot!(context.filters(), context.tool_install() + .arg("black") + .arg("--reinstall") + .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()) + .env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()) + .env(EnvVars::PATH, bin_dir.as_os_str()), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved [N] packages in [TIME] + Prepared [N] packages in [TIME] + Installed [N] packages in [TIME] + + black==24.3.0 + + click==8.1.7 + + mypy-extensions==1.0.0 + + packaging==24.0 + + pathspec==0.12.1 + + platformdirs==4.2.0 + Installed 2 executables: black, blackd + "); +}