Migrate to XDG and Linux strategy for macOS directories (#5806)

This PR moves us to the Linux strategy for our global directories on
macOS. We both feel on the team _and_ have received feedback (in Issues
and Polls) that the `Application Support` directories are more intended
for GUIs, and CLI tools are correct to respect the XDG variables and use
the same directory paths on Linux and macOS.

Namely, we now use:

- `/Users/crmarsh/.local/share/uv/tools` (for tools)
- `/Users/crmarsh/.local/share/uv/python` (for Pythons)
- `/Users/crmarsh/.cache/uv` (for the cache)

The strategy is such that if the `/Users/crmarsh/Library/Application
Support/uv` already exists, we keep using it -- same goes for
`/Users/crmarsh/Library/Caches/uv`, so **it's entirely backwards
compatible**.

If you want to force a migration to the new schema, you can run:

- `uv cache clean`
- `uv tool uninstall --all`
- `uv python uninstall --all`

Which will clean up the macOS-specific directories, paving the way for
the above paths. In other words, once you run those commands, subsequent
`uv` operations will automatically use the `~/.cache` and `~/.local`
variants.

Closes https://github.com/astral-sh/uv/issues/4411.

---------

Co-authored-by: Zanie Blue <contact@zanie.dev>
This commit is contained in:
Charlie Marsh 2024-08-19 15:33:21 -04:00 committed by Zanie Blue
parent 04e3e7ce65
commit e11bbb539a
9 changed files with 136 additions and 17 deletions

13
Cargo.lock generated
View file

@ -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",
]

View file

@ -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" }

View file

@ -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 }

View file

@ -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<PathBuf>) -> Result<Self, io::Error> {
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"))
}
}
}

View file

@ -100,6 +100,24 @@ pub fn replace_symlink(src: impl AsRef<Path>, dst: impl AsRef<Path>) -> std::io:
}
}
#[cfg(unix)]
pub fn remove_symlink(path: impl AsRef<Path>) -> std::io::Result<()> {
fs_err::remove_file(path.as_ref())
}
#[cfg(windows)]
pub fn remove_symlink(path: impl AsRef<Path>) -> 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<Path>) -> impl Iterator<Item = PathBuf> {
.map(|entry| entry.path())
}
/// Returns `true` if a path is a temporary file or directory.
pub fn is_temporary(path: impl AsRef<Path>) -> 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);

View file

@ -14,5 +14,6 @@ workspace = true
[dependencies]
directories = { workspace = true }
etcetera = { workspace = true }
tempfile = { workspace = true }
fs-err = { workspace = true }

View file

@ -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<PathBuf>) -> Result<Self, io::Error> {
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")
}

View file

@ -22,11 +22,42 @@ pub(crate) async fn uninstall(
printer: Printer,
) -> Result<ExitStatus> {
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<String>,
all: bool,
printer: Printer,
) -> Result<ExitStatus> {
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"

View file

@ -27,6 +27,28 @@ pub(crate) async fn uninstall(name: Option<PackageName>, 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<PackageName>,
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<PackageName>, 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<PackageName>, 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<PackageName>, 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<PackageName>, 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<PackageName>, printer: Printer) -> Re
.join(", ")
)?;
Ok(ExitStatus::Success)
Ok(())
}
/// Uninstall a tool.