Skip to content

Commit a26d6c8

Browse files
committed
Replace executables with broken symlinks during uv python install
1 parent 57a7f04 commit a26d6c8

File tree

2 files changed

+75
-20
lines changed

2 files changed

+75
-20
lines changed

crates/uv/src/commands/python/install.rs

+38-20
Original file line numberDiff line numberDiff line change
@@ -358,32 +358,50 @@ pub(crate) async fn install(
358358
target.simplified_display()
359359
);
360360

361+
// Check if the existing link is valid
362+
let valid_link = target
363+
.read_link()
364+
.and_then(|target| target.try_exists())
365+
.inspect_err(|err| debug!("Failed to inspect executable with error: {err}"))
366+
.unwrap_or(true);
367+
361368
// Figure out what installation it references, if any
362-
let existing = find_matching_bin_link(
363-
installations
364-
.iter()
365-
.copied()
366-
.chain(existing_installations.iter()),
367-
&target,
368-
);
369+
let existing = valid_link
370+
.then(|| {
371+
find_matching_bin_link(
372+
installations
373+
.iter()
374+
.copied()
375+
.chain(existing_installations.iter()),
376+
&target,
377+
)
378+
})
379+
.flatten();
369380

370381
match existing {
371382
None => {
372383
// There's an existing executable we don't manage, require `--force`
373-
if !force {
374-
errors.push((
375-
installation.key(),
376-
anyhow::anyhow!(
377-
"Executable already exists at `{}` but is not managed by uv; use `--force` to replace it",
378-
to.simplified_display()
379-
),
380-
));
381-
continue;
384+
if valid_link {
385+
if !force {
386+
errors.push((
387+
installation.key(),
388+
anyhow::anyhow!(
389+
"Executable already exists at `{}` but is not managed by uv; use `--force` to replace it",
390+
to.simplified_display()
391+
),
392+
));
393+
continue;
394+
}
395+
debug!(
396+
"Replacing existing executable at `{}` due to `--force`",
397+
target.simplified_display()
398+
);
399+
} else {
400+
debug!(
401+
"Replacing broken symlink at `{}`",
402+
target.simplified_display()
403+
);
382404
}
383-
debug!(
384-
"Replacing existing executable at `{}` due to `--force`",
385-
target.simplified_display()
386-
);
387405
}
388406
Some(existing) if existing == *installation => {
389407
// The existing link points to the same installation, so we're done unless

crates/uv/tests/it/python_install.rs

+37
Original file line numberDiff line numberDiff line change
@@ -839,3 +839,40 @@ fn python_install_unknown() {
839839
error: `./foo` is not a valid Python download request; see `uv python help` for supported formats and `uv python list --only-downloads` for available versions
840840
"###);
841841
}
842+
843+
#[test]
844+
#[cfg(unix)]
845+
fn python_install_preview_broken_link() {
846+
use assert_fs::prelude::PathCreateDir;
847+
use fs_err::os::unix::fs::symlink;
848+
849+
let context: TestContext = TestContext::new_with_versions(&[])
850+
.with_filtered_python_keys()
851+
.with_filtered_exe_suffix();
852+
853+
let bin_python = context.temp_dir.child("bin").child("python3.13");
854+
855+
// Create a broken symlink
856+
context.temp_dir.child("bin").create_dir_all().unwrap();
857+
symlink(context.temp_dir.join("does-not-exist"), &bin_python).unwrap();
858+
859+
// Install
860+
uv_snapshot!(context.filters(), context.python_install().arg("--preview").arg("3.13"), @r###"
861+
success: true
862+
exit_code: 0
863+
----- stdout -----
864+
865+
----- stderr -----
866+
Installed Python 3.13.1 in [TIME]
867+
+ cpython-3.13.1-[PLATFORM] (python3.13)
868+
"###);
869+
870+
// We should replace the broken symlink
871+
insta::with_settings!({
872+
filters => context.filters(),
873+
}, {
874+
insta::assert_snapshot!(
875+
read_link_path(&bin_python), @"[TEMP_DIR]/managed/cpython-3.13.1-[PLATFORM]/bin/python3.13"
876+
);
877+
});
878+
}

0 commit comments

Comments
 (0)