From e8685a2bf2ce3fdb79d94b91ac9ef7e786f5a2b0 Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Tue, 25 Jun 2024 08:18:37 -0500 Subject: [PATCH] Add `uv tool install --force` --- crates/uv-cli/src/lib.rs | 9 +++- crates/uv/src/commands/tool/install.rs | 59 +++++++++++++++++++++++--- crates/uv/src/main.rs | 1 + crates/uv/src/settings.rs | 3 ++ crates/uv/tests/tool_install.rs | 28 +++++++++++- 5 files changed, 90 insertions(+), 10 deletions(-) diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 15cad6342789f..4f967b7b92f98 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -9,7 +9,8 @@ use clap::{Args, Parser, Subcommand}; use distribution_types::{FlatIndexLocation, IndexUrl}; use uv_cache::CacheArgs; use uv_configuration::{ - ConfigSettingEntry, IndexStrategy, KeyringProviderType, PackageNameSpecifier, TargetTriple, + ConfigSettingEntry, IndexStrategy, KeyringProviderType, PackageNameSpecifier, + TargetTriple, }; use uv_normalize::{ExtraName, PackageName}; use uv_resolver::{AnnotationStyle, ExcludeNewer, PreReleaseMode, ResolutionMode}; @@ -1889,6 +1890,12 @@ pub struct ToolInstallArgs { #[command(flatten)] pub refresh: RefreshArgs, + /// Force installation of the tool. + /// + /// Will replace any existing entrypoints with the same name in the executable directory. + #[arg(long)] + pub force: bool, + /// The Python interpreter to use to build the run environment. /// /// By default, `uv` uses the virtual environment in the current working directory or any parent diff --git a/crates/uv/src/commands/tool/install.rs b/crates/uv/src/commands/tool/install.rs index 98e48fbc74f6f..28bee5c401b52 100644 --- a/crates/uv/src/commands/tool/install.rs +++ b/crates/uv/src/commands/tool/install.rs @@ -1,3 +1,4 @@ +use std::ffi::OsString; use std::fmt::Write; use std::str::FromStr; @@ -29,6 +30,7 @@ pub(crate) async fn install( python: Option, from: Option, with: Vec, + force: bool, settings: ResolverInstallerSettings, _isolated: bool, preview: PreviewMode, @@ -45,10 +47,14 @@ pub(crate) async fn install( let installed_tools = InstalledTools::from_settings()?; - // TODO(zanieb): Allow replacing an existing tool + // TODO(zanieb): Automatically replace an existing tool if the request differs if installed_tools.find_tool_entry(&name)?.is_some() { - writeln!(printer.stderr(), "Tool `{name}` is already installed.")?; - return Ok(ExitStatus::Failure); + if force { + debug!("Replacing existing tool due to `--force` flag."); + } else { + writeln!(printer.stderr(), "Tool `{name}` is already installed.")?; + return Ok(ExitStatus::Failure); + } } // TODO(zanieb): Figure out the interface here, do we infer the name or do we match the `run --from` interface? @@ -112,6 +118,8 @@ pub(crate) async fn install( fs_err::create_dir_all(&executable_directory) .context("Failed to create executable directory")?; + debug!("Installing into {}", executable_directory.user_display()); + let entrypoints = entrypoint_paths( &environment, installed_dist.name(), @@ -119,11 +127,48 @@ pub(crate) async fn install( None, )?; + // Determine the entry points targets + let targets = entrypoints + .into_iter() + .map(|(name, path)| { + let target = executable_directory.join( + path.file_name() + .map(std::borrow::ToOwned::to_owned) + .unwrap_or_else(|| OsString::from(name.clone())), + ); + (name, path, target) + }) + .collect::>(); + + // Check if they exist, before installing + let mut existing_targets = targets + .iter() + .filter(|(_, _, target)| target.exists()) + .peekable(); + if force { + for (name, _, target) in existing_targets { + debug!("Removing existing install of `{name}`"); + fs_err::remove_file(target)?; + } + } else if existing_targets.peek().is_some() { + let existing_targets = existing_targets + // SAFETY: We know the target has a filename because we just constructed it above + .map(|(_, _, target)| target.file_name().unwrap().to_string_lossy()) + .collect::>(); + let (s, exists) = if existing_targets.len() == 1 { + ("", "exists") + } else { + ("s", "exist") + }; + bail!( + "Entry point{s} for tool already {exists}: {} (use `--force` to overwrite)", + existing_targets.join(", ") + ) + } + // TODO(zanieb): Handle the case where there are no entrypoints - // TODO(zanieb): Better error when an entry point exists, check if they all are don't exist first - for (name, path) in entrypoints { - let target = executable_directory.join(path.file_name().unwrap()); - debug!("Installing {name} to {}", target.user_display()); + for (name, path, target) in targets { + debug!("Installing `{name}`"); replace_symlink(&path, &target).context("Failed to install entrypoint")?; } diff --git a/crates/uv/src/main.rs b/crates/uv/src/main.rs index f5bc602af38a8..8d997d038f352 100644 --- a/crates/uv/src/main.rs +++ b/crates/uv/src/main.rs @@ -812,6 +812,7 @@ async fn run() -> Result { args.python, args.from, args.with, + args.force, args.settings, globals.isolated, globals.preview, diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index 7a16d9ea7e91d..5e8733f789086 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -241,6 +241,7 @@ pub(crate) struct ToolInstallSettings { pub(crate) python: Option, pub(crate) refresh: Refresh, pub(crate) settings: ResolverInstallerSettings, + pub(crate) force: bool, } impl ToolInstallSettings { @@ -252,6 +253,7 @@ impl ToolInstallSettings { from, with, installer, + force, build, refresh, python, @@ -262,6 +264,7 @@ impl ToolInstallSettings { from, with, python, + force, refresh: Refresh::from(refresh), settings: ResolverInstallerSettings::combine( resolver_installer_options(installer, build), diff --git a/crates/uv/tests/tool_install.rs b/crates/uv/tests/tool_install.rs index 3888439d3d640..f55fdf89868dc 100644 --- a/crates/uv/tests/tool_install.rs +++ b/crates/uv/tests/tool_install.rs @@ -227,8 +227,8 @@ fn tool_install_entry_point_exists() { .arg("black") .env("UV_TOOL_DIR", tool_dir.as_os_str()) .env("XDG_BIN_HOME", bin_dir.as_os_str()), @r###" - success: true - exit_code: 0 + success: false + exit_code: 2 ----- stdout ----- ----- stderr ----- @@ -242,6 +242,7 @@ fn tool_install_entry_point_exists() { + packaging==24.0 + pathspec==0.12.1 + platformdirs==4.2.0 + error: Entry point for tool already exists: black (use `--force` to overwrite) "###); // We should not create a virtual environment @@ -258,6 +259,29 @@ fn tool_install_entry_point_exists() { assert_snapshot!(fs_err::read_to_string(bin_dir.join("black")).unwrap(), @""); }); + + // Test when multiple entry points exist + bin_dir.child("blackd").touch().unwrap(); + uv_snapshot!(context.filters(), context.tool_install() + .arg("black") + .env("UV_TOOL_DIR", tool_dir.as_os_str()) + .env("XDG_BIN_HOME", bin_dir.as_os_str()), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + warning: `uv tool install` is experimental and may change without warning. + Resolved 6 packages in [TIME] + Installed 6 packages in [TIME] + + black==24.3.0 + + click==8.1.7 + + mypy-extensions==1.0.0 + + packaging==24.0 + + pathspec==0.12.1 + + platformdirs==4.2.0 + error: Entry points for tool already exist: blackd, black (use `--force` to overwrite) + "###); } /// Test `uv tool install` when the bin directory is inferred from `$HOME`