Skip to main content

uv_install_wheel/
uninstall.rs

1use std::collections::BTreeSet;
2use std::path::{Component, Path, PathBuf};
3
4use std::sync::{LazyLock, Mutex};
5use tracing::trace;
6use uv_fs::write_atomic_sync;
7
8use crate::Error;
9use crate::wheel::read_record_file;
10
11/// Uninstall the wheel represented by the given `.dist-info` directory.
12pub fn uninstall_wheel(dist_info: &Path) -> Result<Uninstall, Error> {
13    let Some(site_packages) = dist_info.parent() else {
14        return Err(Error::BrokenVenv(
15            "dist-info directory is not in a site-packages directory".to_string(),
16        ));
17    };
18
19    // Read the RECORD file.
20    let record = {
21        let record_path = dist_info.join("RECORD");
22        let mut record_file = match fs_err::File::open(&record_path) {
23            Ok(record_file) => record_file,
24            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
25                return Err(Error::MissingRecord(record_path));
26            }
27            Err(err) => return Err(err.into()),
28        };
29        read_record_file(&mut record_file)?
30    };
31
32    let mut file_count = 0usize;
33    let mut dir_count = 0usize;
34
35    #[cfg(windows)]
36    let itself = std::env::current_exe().ok();
37
38    // Uninstall the files, keeping track of any directories that are left empty.
39    let mut visited = BTreeSet::new();
40    for entry in &record {
41        let path = site_packages.join(&entry.path);
42
43        // On Windows, deleting the current executable is a special case.
44        #[cfg(windows)]
45        if let Some(itself) = itself.as_ref() {
46            if itself
47                .file_name()
48                .is_some_and(|itself| path.file_name().is_some_and(|path| itself == path))
49            {
50                if same_file::is_same_file(itself, &path).unwrap_or(false) {
51                    tracing::debug!("Detected self-delete of executable: {}", path.display());
52                    match self_replace::self_delete_outside_path(site_packages) {
53                        Ok(()) => {
54                            trace!("Removed file: {}", path.display());
55                            file_count += 1;
56                            if let Some(parent) = path.parent() {
57                                visited.insert(normalize_path(parent));
58                            }
59                        }
60                        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
61                        Err(err) => return Err(err.into()),
62                    }
63                    continue;
64                }
65            }
66        }
67
68        match fs_err::remove_file(&path) {
69            Ok(()) => {
70                trace!("Removed file: {}", path.display());
71                file_count += 1;
72                if let Some(parent) = path.parent() {
73                    visited.insert(normalize_path(parent));
74                }
75            }
76            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
77            Err(err) => match fs_err::remove_dir_all(&path) {
78                Ok(()) => {
79                    trace!("Removed directory: {}", path.display());
80                    dir_count += 1;
81                }
82                Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
83                Err(_) => return Err(err.into()),
84            },
85        }
86    }
87
88    // If any directories were left empty, remove them. Iterate in reverse order such that we visit
89    // the deepest directories first.
90    for path in visited.iter().rev() {
91        // No need to look at directories outside of `site-packages` (like `bin`).
92        if !path.starts_with(site_packages) {
93            continue;
94        }
95
96        // Iterate up the directory tree, removing any empty directories. It's insufficient to
97        // rely on `visited` alone here, because we may end up removing a directory whose parent
98        // directory doesn't contain any files, leaving the _parent_ directory empty.
99        let mut path = path.as_path();
100        loop {
101            // If we reach the site-packages directory, we're done.
102            if path == site_packages {
103                break;
104            }
105
106            // If the directory contains a `__pycache__` directory, always remove it. `__pycache__`
107            // may or may not be listed in the RECORD, but installers are expected to be smart
108            // enough to remove it either way.
109            let pycache = path.join("__pycache__");
110            match fs_err::remove_dir_all(&pycache) {
111                Ok(()) => {
112                    trace!("Removed directory: {}", pycache.display());
113                    dir_count += 1;
114                }
115                Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
116                Err(err) => return Err(err.into()),
117            }
118
119            // Try to read from the directory. If it doesn't exist, assume we deleted it in a
120            // previous iteration.
121            let mut read_dir = match fs_err::read_dir(path) {
122                Ok(read_dir) => read_dir,
123                Err(err) if err.kind() == std::io::ErrorKind::NotFound => break,
124                Err(err) => return Err(err.into()),
125            };
126
127            // If the directory is not empty, we're done.
128            if read_dir.next().is_some() {
129                break;
130            }
131
132            fs_err::remove_dir(path)?;
133
134            trace!("Removed directory: {}", path.display());
135            dir_count += 1;
136
137            if let Some(parent) = path.parent() {
138                path = parent;
139            } else {
140                break;
141            }
142        }
143    }
144
145    Ok(Uninstall {
146        file_count,
147        dir_count,
148    })
149}
150
151/// Uninstall the egg represented by the `.egg-info` directory.
152///
153/// See: <https://github.com/pypa/pip/blob/41587f5e0017bcd849f42b314dc8a34a7db75621/src/pip/_internal/req/req_uninstall.py#L483>
154pub fn uninstall_egg(egg_info: &Path) -> Result<Uninstall, Error> {
155    let mut file_count = 0usize;
156    let mut dir_count = 0usize;
157
158    let dist_location = egg_info
159        .parent()
160        .expect("egg-info directory is not in a site-packages directory");
161
162    // Read the `namespace_packages.txt` file.
163    let namespace_packages = {
164        let namespace_packages_path = egg_info.join("namespace_packages.txt");
165        match fs_err::read_to_string(namespace_packages_path) {
166            Ok(namespace_packages) => namespace_packages
167                .lines()
168                .map(ToString::to_string)
169                .collect::<Vec<_>>(),
170            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
171                vec![]
172            }
173            Err(err) => return Err(err.into()),
174        }
175    };
176
177    // Read the `top_level.txt` file, ignoring anything in `namespace_packages.txt`.
178    let top_level = {
179        let top_level_path = egg_info.join("top_level.txt");
180        match fs_err::read_to_string(&top_level_path) {
181            Ok(top_level) => top_level
182                .lines()
183                .map(ToString::to_string)
184                .filter(|line| !namespace_packages.contains(line))
185                .collect::<Vec<_>>(),
186            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
187                return Err(Error::MissingTopLevel(top_level_path));
188            }
189            Err(err) => return Err(err.into()),
190        }
191    };
192
193    // Remove everything in `top_level.txt`.
194    for entry in top_level {
195        let path = dist_location.join(&entry);
196
197        // Remove as a directory.
198        match fs_err::remove_dir_all(&path) {
199            Ok(()) => {
200                trace!("Removed directory: {}", path.display());
201                dir_count += 1;
202                continue;
203            }
204            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
205            Err(err) => return Err(err.into()),
206        }
207
208        // Remove as a `.py`, `.pyc`, or `.pyo` file.
209        for extension in &["py", "pyc", "pyo"] {
210            let path = path.with_extension(extension);
211            match fs_err::remove_file(&path) {
212                Ok(()) => {
213                    trace!("Removed file: {}", path.display());
214                    file_count += 1;
215                    break;
216                }
217                Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
218                Err(err) => return Err(err.into()),
219            }
220        }
221    }
222
223    // Remove the `.egg-info` directory.
224    match fs_err::remove_dir_all(egg_info) {
225        Ok(()) => {
226            trace!("Removed directory: {}", egg_info.display());
227            dir_count += 1;
228        }
229        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
230        Err(err) => {
231            return Err(err.into());
232        }
233    }
234
235    Ok(Uninstall {
236        file_count,
237        dir_count,
238    })
239}
240
241fn normcase(s: &str) -> String {
242    if cfg!(windows) {
243        s.replace('/', "\\").to_lowercase()
244    } else {
245        s.to_owned()
246    }
247}
248
249static EASY_INSTALL_PTH: LazyLock<Mutex<i32>> = LazyLock::new(Mutex::default);
250
251/// Uninstall the legacy editable represented by the `.egg-link` file.
252///
253/// See: <https://github.com/pypa/pip/blob/41587f5e0017bcd849f42b314dc8a34a7db75621/src/pip/_internal/req/req_uninstall.py#L534-L552>
254pub fn uninstall_legacy_editable(egg_link: &Path) -> Result<Uninstall, Error> {
255    let mut file_count = 0usize;
256
257    // Find the target line in the `.egg-link` file.
258    let contents = fs_err::read_to_string(egg_link)?;
259    let target_line = contents
260        .lines()
261        .find_map(|line| {
262            let line = line.trim();
263            if line.is_empty() { None } else { Some(line) }
264        })
265        .ok_or_else(|| Error::InvalidEggLink(egg_link.to_path_buf()))?;
266
267    // This comes from `pkg_resources.normalize_path`
268    let target_line = normcase(target_line);
269
270    match fs_err::remove_file(egg_link) {
271        Ok(()) => {
272            trace!("Removed file: {}", egg_link.display());
273            file_count += 1;
274        }
275        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
276        Err(err) => return Err(err.into()),
277    }
278
279    let site_package = egg_link.parent().ok_or(Error::BrokenVenv(
280        "`.egg-link` file is not in a directory".to_string(),
281    ))?;
282    let easy_install = site_package.join("easy-install.pth");
283
284    // Since uv has an environment lock, it's enough to add a mutex here to ensure we never
285    // lose writes to `easy-install.pth` (this is the only place in uv where `easy-install.pth`
286    // is modified).
287    let _guard = EASY_INSTALL_PTH.lock().unwrap();
288
289    let content = fs_err::read_to_string(&easy_install)?;
290    let mut new_content = String::with_capacity(content.len());
291    let mut removed = false;
292
293    // https://github.com/pypa/pip/blob/41587f5e0017bcd849f42b314dc8a34a7db75621/src/pip/_internal/req/req_uninstall.py#L634
294    for line in content.lines() {
295        if !removed && line.trim() == target_line {
296            removed = true;
297        } else {
298            new_content.push_str(line);
299            new_content.push('\n');
300        }
301    }
302    if removed {
303        write_atomic_sync(&easy_install, new_content)?;
304        trace!("Removed line from `easy-install.pth`: {target_line}");
305    }
306
307    Ok(Uninstall {
308        file_count,
309        dir_count: 0usize,
310    })
311}
312
313#[derive(Debug, Default)]
314pub struct Uninstall {
315    /// The number of files that were removed during the uninstallation.
316    pub file_count: usize,
317    /// The number of directories that were removed during the uninstallation.
318    pub dir_count: usize,
319}
320
321/// Normalize a path, removing things like `.` and `..`.
322///
323/// Source: <https://github.com/rust-lang/cargo/blob/b48c41aedbd69ee3990d62a0e2006edbb506a480/crates/cargo-util/src/paths.rs#L76C1-L109C2>
324fn normalize_path(path: &Path) -> PathBuf {
325    let mut components = path.components().peekable();
326    let mut ret = if let Some(c @ Component::Prefix(..)) = components.peek().copied() {
327        components.next();
328        PathBuf::from(c.as_os_str())
329    } else {
330        PathBuf::new()
331    };
332
333    for component in components {
334        match component {
335            Component::Prefix(..) => unreachable!(),
336            Component::RootDir => {
337                ret.push(component.as_os_str());
338            }
339            Component::CurDir => {}
340            Component::ParentDir => {
341                ret.pop();
342            }
343            Component::Normal(c) => {
344                ret.push(c);
345            }
346        }
347    }
348    ret
349}