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
83 changes: 46 additions & 37 deletions crates/uv/src/commands/python/uninstall.rs
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,50 @@ async fn do_uninstall(
.insert(executable);
}

// Pre-compute which minor versions will have no remaining installations
// after all matching installations are removed.
let matching_keys: IndexSet<_> = matching_installations
.iter()
.map(|installation| installation.key().clone())
.collect();
let anticipated_remaining_minor_versions =
PythonInstallationMinorVersionKey::highest_installations_by_minor_version_key(
installed_installations
.iter()
.filter(|installation| !matching_keys.contains(installation.key())),
);

// Remove minor version links (symlinks on Unix, junctions on Windows) for minor
// versions that will have no remaining installations. This must happen before
// removing the installation directories so that the link targets still exist,
// which is required by `junction::get_target` on Windows.
for installation in &matching_installations {
if !anticipated_remaining_minor_versions.contains_key(installation.minor_version_key()) {
if let Some(minor_version_link) =
PythonMinorVersionLink::from_installation(installation)
{
if minor_version_link.exists() {
if cfg!(windows) {
fs_err::remove_dir(minor_version_link.symlink_directory.as_path())?;
} else {
fs_err::remove_file(minor_version_link.symlink_directory.as_path())?;
}
let symlink_term = if cfg!(windows) {
"junction"
} else {
"symlink directory"
};
debug!(
"Removed {}: {}",
symlink_term,
minor_version_link.symlink_directory.to_string_lossy()
);
}
}
}
}

// Remove the installation directories.
let mut tasks = FuturesUnordered::new();
for installation in &matching_installations {
tasks.push(async {
Expand All @@ -217,9 +261,8 @@ async fn do_uninstall(
}
}

// Read all existing managed installations and find the highest installed patch
// for each installed minor version. Ensure the minor version link directory
// is still valid.
// Update minor version links for minor versions that still have remaining
// installations, ensuring the link points to the new highest patch.
let uninstalled_minor_versions: IndexSet<_> = uninstalled
.iter()
.map(PythonInstallationMinorVersionKey::ref_cast)
Expand All @@ -240,40 +283,6 @@ async fn do_uninstall(
{
installation.ensure_minor_version_link()?;
}
// For each uninstalled installation, check if there are no remaining installations
// for its minor version. If there are none remaining, remove the symlink directory
// (or junction on Windows) if it exists.
for installation in &matching_installations {
if !remaining_minor_versions.contains_key(installation.minor_version_key()) {
if let Some(minor_version_link) =
PythonMinorVersionLink::from_installation(installation)
{
if minor_version_link.exists() {
let result = if cfg!(windows) {
fs_err::remove_dir(minor_version_link.symlink_directory.as_path())
} else {
fs_err::remove_file(minor_version_link.symlink_directory.as_path())
};
if result.is_err() {
return Err(anyhow::anyhow!(
"Failed to remove symlink directory {}",
minor_version_link.symlink_directory.display()
));
}
let symlink_term = if cfg!(windows) {
"junction"
} else {
"symlink directory"
};
debug!(
"Removed {}: {}",
symlink_term,
minor_version_link.symlink_directory.to_string_lossy()
);
}
}
}
}

// Report on any uninstalled installations.
if let Some(first_uninstalled) = uninstalled.first() {
Expand Down
171 changes: 171 additions & 0 deletions crates/uv/tests/it/python_install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3526,6 +3526,177 @@ fn uninstall_last_patch() {
);
}

/// After uninstalling the last patch for a minor version, the minor version link
/// (symlink on Unix, junction on Windows) should be removed.
///
/// Regression test for <https://github.com/astral-sh/uv/issues/18793>.
#[test]
fn uninstall_last_patch_removes_minor_version_link() {
let context = uv_test::test_context_with_versions!(&[])
.with_filtered_python_keys()
.with_filtered_exe_suffix()
.with_managed_python_dirs()
.with_python_download_cache()
.with_filtered_python_install_bin();

let managed_dir = context.temp_dir.child("managed");
let platform_key = platform_key_from_env().unwrap();

let minor_version_link = managed_dir.child(format!("cpython-3.12-{platform_key}"));
let patch_dir = managed_dir.child(format!("cpython-3.12.8-{platform_key}"));

// Install a single patch version
uv_snapshot!(context.filters(), context.python_install().arg("3.12.8"), @"
success: true
exit_code: 0
----- stdout -----

----- stderr -----
Installed Python 3.12.8 in [TIME]
+ cpython-3.12.8-[PLATFORM] (python3.12)
");

// The patch directory and the minor version link should both exist
patch_dir.assert(predicate::path::exists());
minor_version_link.assert(predicate::path::exists());

// Uninstall the only patch version for this minor
uv_snapshot!(context.filters(), context.python_uninstall().arg("3.12.8"), @"
success: true
exit_code: 0
----- stdout -----

----- stderr -----
Searching for Python versions matching: Python 3.12.8
Uninstalled Python 3.12.8 in [TIME]
- cpython-3.12.8-[PLATFORM] (python3.12)
");

// The patch directory should be removed
patch_dir.assert(predicate::path::missing());

// The minor version link (symlink/junction) itself should be fully removed,
// not just dangling. We use `symlink_metadata` because `Path::exists` follows
// symlinks/junctions and would return false for a dangling link, hiding the bug.
assert!(
minor_version_link.path().symlink_metadata().is_err(),
"minor version link should be removed after uninstalling the last patch, \
but it still exists at: {}",
minor_version_link.path().display()
);
}

/// After uninstalling the highest patch but with other patches remaining,
/// the minor version link should be updated (not removed).
#[test]
fn uninstall_highest_patch_updates_minor_version_link() {
use uv_python::managed::platform_key_from_env;

let context = uv_test::test_context_with_versions!(&[])
.with_filtered_python_keys()
.with_filtered_exe_suffix()
.with_managed_python_dirs()
.with_python_download_cache()
.with_filtered_python_install_bin();

let managed_dir = context.temp_dir.child("managed");
let platform_key = platform_key_from_env().unwrap();

let minor_version_link = managed_dir.child(format!("cpython-3.12-{platform_key}"));
let patch_dir_8 = managed_dir.child(format!("cpython-3.12.8-{platform_key}"));
let patch_dir_9 = managed_dir.child(format!("cpython-3.12.9-{platform_key}"));

// Install two patch versions
uv_snapshot!(context.filters(), context.python_install().arg("3.12.9").arg("3.12.8"), @"
success: true
exit_code: 0
----- stdout -----

----- stderr -----
Installed 2 versions in [TIME]
+ cpython-3.12.8-[PLATFORM]
+ cpython-3.12.9-[PLATFORM] (python3.12)
");

// All directories should exist
patch_dir_8.assert(predicate::path::exists());
patch_dir_9.assert(predicate::path::exists());
minor_version_link.assert(predicate::path::exists());

// The minor version link should resolve to the highest patch (3.12.9).
// Use `dunce::canonicalize` directly because `canonicalize_link_path` goes
// through `launcher_path` on Windows, which only works for trampoline
// executables, not junction directories.
let link_target = dunce::canonicalize(minor_version_link.path())
.unwrap()
.simplified_display()
.to_string();
insta::with_settings!({
filters => context.filters(),
}, {
insta::assert_snapshot!(
link_target, @"[TEMP_DIR]/managed/cpython-3.12.9-[PLATFORM]"
);
});

// Uninstall the highest patch version
uv_snapshot!(context.filters(), context.python_uninstall().arg("3.12.9"), @"
success: true
exit_code: 0
----- stdout -----

----- stderr -----
Searching for Python versions matching: Python 3.12.9
Uninstalled Python 3.12.9 in [TIME]
- cpython-3.12.9-[PLATFORM] (python3.12)
");

// The highest patch dir should be removed
patch_dir_9.assert(predicate::path::missing());

// The lower patch dir should still exist
patch_dir_8.assert(predicate::path::exists());

// The minor version link should still exist, now pointing to the remaining patch
minor_version_link.assert(predicate::path::exists());
let link_target = dunce::canonicalize(minor_version_link.path())
.unwrap()
.simplified_display()
.to_string();
Comment thread
woodruffw marked this conversation as resolved.
insta::with_settings!({
filters => context.filters(),
}, {
insta::assert_snapshot!(
link_target, @"[TEMP_DIR]/managed/cpython-3.12.8-[PLATFORM]"
);
});

// Uninstall the last remaining patch
uv_snapshot!(context.filters(), context.python_uninstall().arg("3.12.8"), @"
success: true
exit_code: 0
----- stdout -----

----- stderr -----
Searching for Python versions matching: Python 3.12.8
Uninstalled Python 3.12.8 in [TIME]
- cpython-3.12.8-[PLATFORM]
");

// The patch directory should be removed
patch_dir_8.assert(predicate::path::missing());

// The minor version link should be fully removed (see comment in
// `uninstall_last_patch_removes_minor_version_link` for why we use
// `symlink_metadata` instead of `predicate::path::missing`).
assert!(
minor_version_link.path().symlink_metadata().is_err(),
"minor version link should be removed after uninstalling the last patch, \
but it still exists at: {}",
minor_version_link.path().display()
);
}

#[cfg(unix)] // Pyodide cannot be used on Windows
#[test]
fn python_install_pyodide() {
Expand Down
Loading