diff --git a/Cargo.lock b/Cargo.lock index c2d10f022635..410b2ac46dbf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1185,6 +1185,17 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + [[package]] name = "event-listener" version = "5.3.1" @@ -4624,6 +4635,7 @@ dependencies = [ "clap", "directories", "distribution-types", + "etcetera", "fs-err", "nanoid", "pypi-types", @@ -5184,6 +5196,7 @@ name = "uv-state" version = "0.0.1" dependencies = [ "directories", + "etcetera", "fs-err", "tempfile", ] diff --git a/Cargo.toml b/Cargo.toml index 0d9c633f4d8b..4900684a46d9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -84,6 +84,7 @@ dirs-sys = { version = "0.4.1" } dunce = { version = "1.0.4" } either = { version = "1.12.0" } encoding_rs_io = { version = "0.1.7" } +etcetera = { version = "0.8.0" } flate2 = { version = "1.0.28", default-features = false } fs-err = { version = "2.11.0" } fs2 = { version = "0.4.3" } diff --git a/crates/uv-cache/Cargo.toml b/crates/uv-cache/Cargo.toml index eaa77fce3030..36851c883da7 100644 --- a/crates/uv-cache/Cargo.toml +++ b/crates/uv-cache/Cargo.toml @@ -22,12 +22,13 @@ uv-normalize = { workspace = true } clap = { workspace = true, features = ["derive", "env"], optional = true } directories = { workspace = true } +etcetera = { workspace = true } fs-err = { workspace = true, features = ["tokio"] } nanoid = { workspace = true } +rmp-serde = { workspace = true } rustc-hash = { workspace = true } serde = { workspace = true, features = ["derive"] } tempfile = { workspace = true } tracing = { workspace = true } url = { workspace = true } walkdir = { workspace = true } -rmp-serde = { workspace = true } diff --git a/crates/uv-cache/src/cli.rs b/crates/uv-cache/src/cli.rs index 90982f01d735..78d278fad4d0 100644 --- a/crates/uv-cache/src/cli.rs +++ b/crates/uv-cache/src/cli.rs @@ -1,10 +1,10 @@ use std::io; use std::path::PathBuf; +use crate::Cache; use clap::Parser; use directories::ProjectDirs; - -use crate::Cache; +use etcetera::BaseStrategy; #[derive(Parser, Debug, Clone)] #[command(next_help_heading = "Cache options")] @@ -40,13 +40,24 @@ impl Cache { /// Returns an absolute cache dir. pub fn from_settings(no_cache: bool, cache_dir: Option) -> Result { if no_cache { - Cache::temp() + Self::temp() } else if let Some(cache_dir) = cache_dir { - Ok(Cache::from_path(cache_dir)) - } else if let Some(project_dirs) = ProjectDirs::from("", "", "uv") { - Ok(Cache::from_path(project_dirs.cache_dir())) + Ok(Self::from_path(cache_dir)) + } else if let Some(cache_dir) = ProjectDirs::from("", "", "uv") + .map(|dirs| dirs.cache_dir().to_path_buf()) + .filter(|dir| dir.exists()) + { + // If the user has an existing directory at (e.g.) `/Users/user/Library/Caches/uv`, + // respect it for backwards compatibility. Otherwise, prefer the XDG strategy, even on + // macOS. + Ok(Self::from_path(cache_dir)) + } else if let Some(cache_dir) = etcetera::base_strategy::choose_base_strategy() + .ok() + .map(|dirs| dirs.cache_dir().join("uv")) + { + Ok(Self::from_path(cache_dir)) } else { - Ok(Cache::from_path(".uv_cache")) + Ok(Self::from_path(".uv_cache")) } } } diff --git a/crates/uv-fs/src/lib.rs b/crates/uv-fs/src/lib.rs index 58c73ef12890..cdabfcd8d0bb 100644 --- a/crates/uv-fs/src/lib.rs +++ b/crates/uv-fs/src/lib.rs @@ -100,6 +100,24 @@ pub fn replace_symlink(src: impl AsRef, dst: impl AsRef) -> std::io: } } +#[cfg(unix)] +pub fn remove_symlink(path: impl AsRef) -> std::io::Result<()> { + fs_err::remove_file(path.as_ref()) +} + +#[cfg(windows)] +pub fn remove_symlink(path: impl AsRef) -> std::io::Result<()> { + match junction::delete(dunce::simplified(path.as_ref())) { + Ok(()) => match fs_err::remove_dir_all(path.as_ref()) { + Ok(()) => Ok(()), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(err) => Err(err), + }, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(err) => Err(err), + } +} + /// Return a [`NamedTempFile`] in the specified directory. /// /// Sets the permissions of the temporary file to `0o666`, to match the non-temporary file default. @@ -283,6 +301,14 @@ pub fn files(path: impl AsRef) -> impl Iterator { .map(|entry| entry.path()) } +/// Returns `true` if a path is a temporary file or directory. +pub fn is_temporary(path: impl AsRef) -> bool { + path.as_ref() + .file_name() + .and_then(|name| name.to_str()) + .map_or(false, |name| name.starts_with(".tmp")) +} + /// A file lock that is automatically released when dropped. #[derive(Debug)] pub struct LockedFile(fs_err::File); diff --git a/crates/uv-state/Cargo.toml b/crates/uv-state/Cargo.toml index 7f20e58987fc..3b3bd2c1f7f3 100644 --- a/crates/uv-state/Cargo.toml +++ b/crates/uv-state/Cargo.toml @@ -14,5 +14,6 @@ workspace = true [dependencies] directories = { workspace = true } +etcetera = { workspace = true } tempfile = { workspace = true } fs-err = { workspace = true } diff --git a/crates/uv-state/src/lib.rs b/crates/uv-state/src/lib.rs index f385c321a35d..9d1f65485e70 100644 --- a/crates/uv-state/src/lib.rs +++ b/crates/uv-state/src/lib.rs @@ -5,6 +5,7 @@ use std::{ }; use directories::ProjectDirs; +use etcetera::BaseStrategy; use fs_err as fs; use tempfile::{tempdir, TempDir}; @@ -84,8 +85,19 @@ impl StateStore { pub fn from_settings(state_dir: Option) -> Result { if let Some(state_dir) = state_dir { StateStore::from_path(state_dir) - } else if let Some(project_dirs) = ProjectDirs::from("", "", "uv") { - StateStore::from_path(project_dirs.data_dir()) + } else if let Some(data_dir) = ProjectDirs::from("", "", "uv") + .map(|dirs| dirs.data_dir().to_path_buf()) + .filter(|dir| dir.exists()) + { + // If the user has an existing directory at (e.g.) `/Users/user/Library/Application Support/uv`, + // respect it for backwards compatibility. Otherwise, prefer the XDG strategy, even on + // macOS. + StateStore::from_path(data_dir) + } else if let Some(data_dir) = etcetera::base_strategy::choose_base_strategy() + .ok() + .map(|dirs| dirs.data_dir().join("uv")) + { + StateStore::from_path(data_dir) } else { StateStore::from_path(".uv") } diff --git a/crates/uv/src/commands/python/uninstall.rs b/crates/uv/src/commands/python/uninstall.rs index 9df982abee2a..9227be637caa 100644 --- a/crates/uv/src/commands/python/uninstall.rs +++ b/crates/uv/src/commands/python/uninstall.rs @@ -22,11 +22,42 @@ pub(crate) async fn uninstall( printer: Printer, ) -> Result { - let start = std::time::Instant::now(); - let installations = ManagedPythonInstallations::from_settings()?.init()?; let _lock = installations.acquire_lock()?; + // Perform the uninstallation. + do_uninstall(&installations, targets, all, printer).await?; + + // Clean up any empty directories. + if uv_fs::directories(installations.root()).all(|path| uv_fs::is_temporary(&path)) { + fs_err::tokio::remove_dir_all(&installations.root()).await?; + + if let Some(top_level) = installations.root().parent() { + // Remove the `toolchains` symlink. + match uv_fs::remove_symlink(top_level.join("toolchains")) { + Ok(()) => {} + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => return Err(err.into()), + } + + if uv_fs::directories(top_level).all(|path| uv_fs::is_temporary(&path)) { + fs_err::tokio::remove_dir_all(top_level).await?; + } + } + } + + Ok(ExitStatus::Success) +} + +/// Perform the uninstallation of managed Python installations. +async fn do_uninstall( + installations: &ManagedPythonInstallations, + targets: Vec, + all: bool, + printer: Printer, +) -> Result { + let start = std::time::Instant::now(); + let requests = if all { vec![PythonRequest::Any] } else { @@ -108,6 +139,7 @@ pub(crate) async fn uninstall( } } + // Report on any uninstalled installations. if !uninstalled.is_empty() { if let [uninstalled] = uninstalled.as_slice() { // Ex) "Uninstalled Python 3.9.7 in 1.68s" diff --git a/crates/uv/src/commands/tool/uninstall.rs b/crates/uv/src/commands/tool/uninstall.rs index 831e677519c7..b43a9e606853 100644 --- a/crates/uv/src/commands/tool/uninstall.rs +++ b/crates/uv/src/commands/tool/uninstall.rs @@ -27,6 +27,28 @@ pub(crate) async fn uninstall(name: Option, printer: Printer) -> Re Err(err) => return Err(err.into()), }; + // Perform the uninstallation. + do_uninstall(&installed_tools, name, printer).await?; + + // Clean up any empty directories. + if uv_fs::directories(installed_tools.root()).all(|path| uv_fs::is_temporary(&path)) { + fs_err::tokio::remove_dir_all(&installed_tools.root()).await?; + if let Some(top_level) = installed_tools.root().parent() { + if uv_fs::directories(top_level).all(|path| uv_fs::is_temporary(&path)) { + fs_err::tokio::remove_dir_all(top_level).await?; + } + } + } + + Ok(ExitStatus::Success) +} + +/// Perform the uninstallation. +async fn do_uninstall( + installed_tools: &InstalledTools, + name: Option, + printer: Printer, +) -> Result<()> { let mut dangling = false; let mut entrypoints = if let Some(name) = name { let Some(receipt) = installed_tools.get_tool_receipt(&name)? else { @@ -37,7 +59,7 @@ pub(crate) async fn uninstall(name: Option, printer: Printer) -> Re printer.stderr(), "Removed dangling environment for `{name}`" )?; - return Ok(ExitStatus::Success); + return Ok(()); } Err(uv_tool::Error::Io(err)) if err.kind() == std::io::ErrorKind::NotFound => { bail!("`{name}` is not installed"); @@ -48,7 +70,7 @@ pub(crate) async fn uninstall(name: Option, printer: Printer) -> Re } }; - uninstall_tool(&name, &receipt, &installed_tools).await? + uninstall_tool(&name, &receipt, installed_tools).await? } else { let mut entrypoints = vec![]; for (name, receipt) in installed_tools.tools()? { @@ -72,7 +94,7 @@ pub(crate) async fn uninstall(name: Option, printer: Printer) -> Re } }; - entrypoints.extend(uninstall_tool(&name, &receipt, &installed_tools).await?); + entrypoints.extend(uninstall_tool(&name, &receipt, installed_tools).await?); } entrypoints }; @@ -83,7 +105,7 @@ pub(crate) async fn uninstall(name: Option, printer: Printer) -> Re if !dangling { writeln!(printer.stderr(), "Nothing to uninstall")?; } - return Ok(ExitStatus::Success); + return Ok(()); } let s = if entrypoints.len() == 1 { "" } else { "s" }; @@ -97,7 +119,7 @@ pub(crate) async fn uninstall(name: Option, printer: Printer) -> Re .join(", ") )?; - Ok(ExitStatus::Success) + Ok(()) } /// Uninstall a tool.