Skip to content

Commit 39526d2

Browse files
committed
Remove Python executables on uninstall
1 parent 1d23153 commit 39526d2

File tree

2 files changed

+62
-2
lines changed

2 files changed

+62
-2
lines changed

crates/uv-python/src/managed.rs

+7
Original file line numberDiff line numberDiff line change
@@ -520,6 +520,13 @@ impl ManagedPythonInstallation {
520520
}
521521
}
522522

523+
/// Returns `true` if the path is a uv Windows Python launcher, as defined by `uv-trampoline`.
524+
pub fn is_windows_python_shim(path: &Path) -> bool {
525+
uv_trampoline_builder::LauncherKind::try_from_path(path)
526+
.unwrap_or_default()
527+
.is_some_and(|kind| matches!(kind, uv_trampoline_builder::LauncherKind::Python))
528+
}
529+
523530
/// Generate a platform portion of a key from the environment.
524531
fn platform_key_from_env() -> Result<String, Error> {
525532
let os = Os::from_env();

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

+55-2
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,13 @@ use futures::StreamExt;
77
use itertools::Itertools;
88
use owo_colors::OwoColorize;
99

10+
use same_file::is_same_file;
11+
use tracing::{debug, warn};
12+
use uv_fs::Simplified;
1013
use uv_python::downloads::PythonDownloadRequest;
11-
use uv_python::managed::ManagedPythonInstallations;
14+
use uv_python::managed::{
15+
is_windows_python_shim, python_executable_dir, ManagedPythonInstallations,
16+
};
1217
use uv_python::PythonRequest;
1318

1419
use crate::commands::python::{ChangeEvent, ChangeEventKind};
@@ -121,6 +126,48 @@ async fn do_uninstall(
121126
return Ok(ExitStatus::Failure);
122127
}
123128

129+
// Collect files in a directory
130+
let executables = python_executable_dir()?
131+
.read_dir()?
132+
.filter_map(|entry| match entry {
133+
Ok(entry) => Some(entry),
134+
Err(err) => {
135+
warn!("Failed to read executable: {}", err);
136+
None
137+
}
138+
})
139+
.filter(|entry| entry.file_type().is_ok_and(|file_type| !file_type.is_dir()))
140+
.map(|entry| entry.path())
141+
// Only include files that match the expected Python executable names
142+
// TODO(zanieb): This is a minor optimization to avoid opening more files, but we could
143+
// leave broken links behind, i.e., if the user created them.
144+
.filter(|path| {
145+
matching_installations.iter().any(|installation| {
146+
path.file_name().and_then(|name| name.to_str())
147+
== Some(&installation.key().executable_name_minor())
148+
})
149+
})
150+
// Only include Python executables that match the installations
151+
.filter(|path| {
152+
matching_installations.iter().any(|installation| {
153+
if cfg!(unix) {
154+
is_same_file(path, installation.executable()).unwrap_or_default()
155+
} else if cfg!(windows) {
156+
// TODO(zanieb): We need to check if the target path matches the executable
157+
// otherwise we will remove the "default" executables
158+
is_windows_python_shim(path)
159+
} else {
160+
unreachable!("Only Windows and Unix are supported")
161+
}
162+
})
163+
})
164+
.collect::<BTreeSet<_>>();
165+
166+
for executable in &executables {
167+
fs_err::remove_file(executable)?;
168+
debug!("Removed {}", executable.user_display());
169+
}
170+
124171
let mut tasks = FuturesUnordered::new();
125172
for installation in &matching_installations {
126173
tasks.push(async {
@@ -180,7 +227,13 @@ async fn do_uninstall(
180227
{
181228
match event.kind {
182229
ChangeEventKind::Removed => {
183-
writeln!(printer.stderr(), " {} {}", "-".red(), event.key.bold())?;
230+
writeln!(
231+
printer.stderr(),
232+
" {} {} ({})",
233+
"-".red(),
234+
event.key.bold(),
235+
event.key.executable_name_minor()
236+
)?;
184237
}
185238
_ => unreachable!(),
186239
}

0 commit comments

Comments
 (0)