From 8c363e38844354ba7f50531c0aea5bc53affa6c8 Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Sat, 7 Dec 2024 10:48:30 -0600 Subject: [PATCH] Replace executables with broken symlinks during `uv python install` --- crates/uv/src/commands/python/install.rs | 63 ++++++++++++++++-------- crates/uv/tests/it/python_install.rs | 37 ++++++++++++++ 2 files changed, 79 insertions(+), 21 deletions(-) diff --git a/crates/uv/src/commands/python/install.rs b/crates/uv/src/commands/python/install.rs index 9905903de8e9f..4051d4a1daf11 100644 --- a/crates/uv/src/commands/python/install.rs +++ b/crates/uv/src/commands/python/install.rs @@ -354,32 +354,53 @@ pub(crate) async fn install( target.simplified_display() ); - // Figure out what installation it references, if any - let existing = find_matching_bin_link( - installations - .iter() - .copied() - .chain(existing_installations.iter()), - &target, - ); + // Check if the existing link is valid + let valid_link = match target.read_link().and_then(|target| target.try_exists()) + { + Ok(valid) => valid, + Err(err) => { + debug!("Failed to inspect executable with error: {err}"); + true // Treat this as a valid link, we won't remove it without `--force` + } + }; + + // Figure out what installation it references, if anyf + let existing = valid_link + .then(|| { + find_matching_bin_link( + installations + .iter() + .copied() + .chain(existing_installations.iter()), + &target, + ) + }) + .flatten(); match existing { None => { // There's an existing executable we don't manage, require `--force` - if !force { - errors.push(( - installation.key(), - anyhow::anyhow!( - "Executable already exists at `{}` but is not managed by uv; use `--force` to replace it", - to.simplified_display() - ), - )); - continue; + if valid_link { + if !force { + errors.push(( + installation.key(), + anyhow::anyhow!( + "Executable already exists at `{}` but is not managed by uv; use `--force` to replace it", + to.simplified_display() + ), + )); + continue; + } + debug!( + "Replacing existing executable at `{}` due to `--force`", + target.simplified_display() + ); + } else { + debug!( + "Replacing existing executable at `{}` due to an invalid symlink", + target.simplified_display() + ); } - debug!( - "Replacing existing executable at `{}` due to `--force`", - target.simplified_display() - ); } Some(existing) if existing == *installation => { // The existing link points to the same installation, so we're done unless diff --git a/crates/uv/tests/it/python_install.rs b/crates/uv/tests/it/python_install.rs index 5094a0b4209f4..082ae65f6b21b 100644 --- a/crates/uv/tests/it/python_install.rs +++ b/crates/uv/tests/it/python_install.rs @@ -812,3 +812,40 @@ fn read_link_path(path: &Path) -> String { unreachable!() } } + +#[test] +#[cfg(unix)] +fn python_install_preview_broken_link() { + let context: TestContext = TestContext::new_with_versions(&[]) + .with_filtered_python_keys() + .with_filtered_exe_suffix(); + + let bin_python = context.temp_dir.child("bin").child(format!("python3.13")); + + use assert_fs::prelude::PathCreateDir; + use fs_err::os::unix::fs::symlink; + + // Create a broken symlink + context.temp_dir.child("bin").create_dir_all().unwrap(); + symlink(&context.temp_dir.join("does-not-exist"), &bin_python).unwrap(); + + // Install + uv_snapshot!(context.filters(), context.python_install().arg("--preview").arg("3.13"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Installed Python 3.13.1 in [TIME] + + cpython-3.13.1-[PLATFORM] (python3.13) + "###); + + // We should replace the broken symlink + insta::with_settings!({ + filters => context.filters(), + }, { + insta::assert_snapshot!( + read_link_path(&bin_python), @"[TEMP_DIR]/managed/cpython-3.13.1-[PLATFORM]/bin/python3.13" + ); + }); +}