Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 4 additions & 4 deletions crates/uv-python/src/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
54 changes: 43 additions & 11 deletions crates/uv-python/src/interpreter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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}`")]
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -1273,6 +1298,13 @@ fn find_base_python(
}
}

/// Parse the `home` key from `pyvenv.cfg`, if any.
fn python_home(interpreter: &Path) -> Option<PathBuf> {
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 {
Expand Down
2 changes: 1 addition & 1 deletion crates/uv-python/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
7 changes: 7 additions & 0 deletions crates/uv-python/src/virtualenv.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PathBuf>,
/// Was the virtual environment created with the `virtualenv` package?
pub(crate) virtualenv: bool,
/// Was the virtual environment created with the `uv` package?
Expand Down Expand Up @@ -231,6 +233,7 @@ pub(crate) fn virtualenv_python_executable(venv: impl AsRef<Path>) -> PathBuf {
impl PyVenvConfiguration {
/// Parse a `pyvenv.cfg` file into a [`PyVenvConfiguration`].
pub fn parse(cfg: impl AsRef<Path>) -> Result<Self, Error> {
let mut home = None;
let mut virtualenv = false;
let mut uv = false;
let mut relocatable = false;
Expand All @@ -248,6 +251,9 @@ impl PyVenvConfiguration {
continue;
};
match key.trim() {
"home" => {
home = Some(PathBuf::from(value.trim()));
}
"virtualenv" => {
virtualenv = true;
}
Expand All @@ -274,6 +280,7 @@ impl PyVenvConfiguration {
}

Ok(Self {
home,
virtualenv,
uv,
relocatable,
Expand Down
1 change: 1 addition & 0 deletions crates/uv-tool/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
30 changes: 20 additions & 10 deletions crates/uv-tool/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand All @@ -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;
Expand Down Expand Up @@ -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)
}
Expand Down
34 changes: 22 additions & 12 deletions crates/uv/src/commands/project/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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()),
}
Expand Down
78 changes: 78 additions & 0 deletions crates/uv/tests/it/tool_install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <https://github.com/astral-sh/uv/issues/16252>.
#[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) {
// <root>/bin/python3.12
python_executable.parent().unwrap().parent().unwrap()
} else {
// <root>/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
");
}
Loading