diff --git a/crates/uv-python/src/discovery.rs b/crates/uv-python/src/discovery.rs index a77ae6c94..6add5eeae 100644 --- a/crates/uv-python/src/discovery.rs +++ b/crates/uv-python/src/discovery.rs @@ -35,7 +35,7 @@ use crate::virtualenv::{ }; #[cfg(windows)] use crate::windows_registry::{registry_pythons, WindowsPython}; -use crate::{Interpreter, PythonVersion}; +use crate::{BrokenSymlink, Interpreter, PythonVersion}; /// A request to find a Python installation. /// @@ -815,7 +815,8 @@ impl Error { ); false } - InterpreterError::NotFound(path) => { + InterpreterError::NotFound(path) + | InterpreterError::BrokenSymlink(BrokenSymlink { path, .. }) => { // If the interpreter is from an active, valid virtual environment, we should // fail because it's broken if let Some(Ok(true)) = matches!(source, PythonSource::ActiveEnvironment) @@ -894,11 +895,13 @@ 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(_)) => Ok(Err(PythonNotFound { - request: request.clone(), - python_preference: preference, - environment_preference: environments, - })), + Err(InterpreterError::NotFound(_) | InterpreterError::BrokenSymlink(_)) => { + Ok(Err(PythonNotFound { + request: request.clone(), + python_preference: preference, + environment_preference: environments, + })) + } Err(err) => Err(Error::Query( Box::new(err), path.clone(), @@ -918,11 +921,13 @@ 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(_)) => Ok(Err(PythonNotFound { - request: request.clone(), - python_preference: preference, - environment_preference: environments, - })), + Err(InterpreterError::NotFound(_) | InterpreterError::BrokenSymlink(_)) => { + Ok(Err(PythonNotFound { + request: request.clone(), + python_preference: preference, + environment_preference: environments, + })) + } Err(err) => Err(Error::Query( Box::new(err), path.clone(), diff --git a/crates/uv-python/src/interpreter.rs b/crates/uv-python/src/interpreter.rs index 42e3c8db7..bbc559512 100644 --- a/crates/uv-python/src/interpreter.rs +++ b/crates/uv-python/src/interpreter.rs @@ -677,6 +677,8 @@ impl Display for StatusCodeError { pub enum Error { #[error("Failed to query Python interpreter")] Io(#[from] io::Error), + #[error(transparent)] + BrokenSymlink(BrokenSymlink), #[error("Python interpreter not found at `{0}`")] NotFound(PathBuf), #[error("Failed to query Python interpreter at `{path}`")] @@ -699,6 +701,33 @@ pub enum Error { Encode(#[from] rmp_serde::encode::Error), } +#[derive(Debug, Error)] +pub struct BrokenSymlink { + pub path: PathBuf, + /// Whether the interpreter path looks like a virtual environment. + pub venv: bool, +} + +impl Display for BrokenSymlink { + 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.venv { + writeln!( + f, + "\n\n{}{} Consider recreating the environment (e.g., with `{}`)", + "hint".bold().cyan(), + ":".bold(), + "uv venv".green() + )?; + } + Ok(()) + } +} + #[derive(Debug, Deserialize, Serialize)] #[serde(tag = "result", rename_all = "lowercase")] enum InterpreterInfoResult { @@ -879,7 +908,24 @@ impl InterpreterInfo { .and_then(Timestamp::from_path) .map_err(|err| { if err.kind() == io::ErrorKind::NotFound { - Error::NotFound(executable.to_path_buf()) + // Check if it looks like a venv interpreter where the underlying Python + // installation was removed. + if absolute + .symlink_metadata() + .is_ok_and(|metadata| metadata.is_symlink()) + { + let venv = executable + .parent() + .and_then(Path::parent) + .map(|path| path.join("pyvenv.cfg").is_file()) + .unwrap_or(false); + Error::BrokenSymlink(BrokenSymlink { + path: executable.to_path_buf(), + venv, + }) + } else { + Error::NotFound(executable.to_path_buf()) + } } else { err.into() } diff --git a/crates/uv-python/src/lib.rs b/crates/uv-python/src/lib.rs index ca723db6d..07612a589 100644 --- a/crates/uv-python/src/lib.rs +++ b/crates/uv-python/src/lib.rs @@ -11,7 +11,7 @@ pub use crate::discovery::{ pub use crate::environment::{InvalidEnvironmentKind, PythonEnvironment}; pub use crate::implementation::ImplementationName; pub use crate::installation::{PythonInstallation, PythonInstallationKey}; -pub use crate::interpreter::{Error as InterpreterError, Interpreter}; +pub use crate::interpreter::{BrokenSymlink, Error as InterpreterError, Interpreter}; pub use crate::pointer_size::PointerSize; pub use crate::prefix::Prefix; pub use crate::python_version::PythonVersion; diff --git a/crates/uv-tool/src/lib.rs b/crates/uv-tool/src/lib.rs index fbaca618e..a7a7cfb1b 100644 --- a/crates/uv-tool/src/lib.rs +++ b/crates/uv-tool/src/lib.rs @@ -227,19 +227,22 @@ impl InstalledTools { Err(uv_python::Error::Query(uv_python::InterpreterError::NotFound( interpreter_path, ))) => { - if interpreter_path.is_symlink() { - let target_path = fs_err::read_link(&interpreter_path)?; - warn!( - "Ignoring existing virtual environment linked to non-existent Python interpreter: {} -> {}", - interpreter_path.user_display(), - target_path.user_display() - ); - } else { - warn!( - "Ignoring existing virtual environment with missing Python interpreter: {}", - interpreter_path.user_display() - ); - } + warn!( + "Ignoring existing virtual environment with missing Python interpreter: {}", + interpreter_path.user_display() + ); + + 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() + ); Ok(None) } diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index 4c290d5d2..70eedb0f6 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -914,15 +914,16 @@ impl ProjectInterpreter { InvalidEnvironmentKind::Empty => {} } } - Err(uv_python::Error::Query(uv_python::InterpreterError::NotFound(path))) => { - if path.is_symlink() { - 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(), - ); - } + 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(err) => return Err(err.into()), } diff --git a/crates/uv/tests/it/pip_compile.rs b/crates/uv/tests/it/pip_compile.rs index f3655b58f..cc0c95f90 100644 --- a/crates/uv/tests/it/pip_compile.rs +++ b/crates/uv/tests/it/pip_compile.rs @@ -17360,3 +17360,46 @@ fn incompatible_cuda() -> Result<()> { Ok(()) } + +#[cfg(unix)] +#[test] +fn compile_broken_active_venv() -> Result<()> { + let context = TestContext::new("3.12"); + let requirements_in = context.temp_dir.child("requirements.in"); + requirements_in.write_str("anyio==3.7.0")?; + + // A broken system Python + let broken_system_python = context.temp_dir.join("python3.14159"); + fs_err::os::unix::fs::symlink("/does/not/exist", &broken_system_python)?; + uv_snapshot!(context + .venv() + .arg("--python") + .arg(&broken_system_python) + .arg("venv2"), @r" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + × No interpreter found for path `python3.14159` in managed installations or search path + "); + + // Simulate a removed Python interpreter + fs_err::remove_file(context.interpreter())?; + fs_err::os::unix::fs::symlink("/removed/python/interpreter", context.interpreter())?; + uv_snapshot!(context + .pip_compile() + .arg("requirements.in"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Failed to inspect Python interpreter from active virtual environment at `.venv/bin/python3` + Caused by: Broken symlink at `.venv/bin/python3`, was the underlying Python interpreter removed? + + hint: Consider recreating the environment (e.g., with `uv venv`) + "); + + Ok(()) +}