diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 15cad6342789..207125faa88f 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -1889,6 +1889,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 98e48fbc74f6..4aac14c53230 100644 --- a/crates/uv/src/commands/tool/install.rs +++ b/crates/uv/src/commands/tool/install.rs @@ -1,10 +1,13 @@ +use std::collections::BTreeSet; +use std::ffi::OsString; use std::fmt::Write; use std::str::FromStr; use anyhow::{bail, Context, Result}; use distribution_types::Name; -use pep508_rs::Requirement; +use itertools::Itertools; +use pep508_rs::Requirement; use pypi_types::VerbatimParsedUrl; use tracing::debug; use uv_cache::Cache; @@ -29,6 +32,7 @@ pub(crate) async fn install( python: Option, from: Option, with: Vec, + force: bool, settings: ResolverInstallerSettings, _isolated: bool, preview: PreviewMode, @@ -45,10 +49,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 +120,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 +129,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.iter().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 f5bc602af38a..8d997d038f35 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 7a16d9ea7e91..5e8733f78908 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 3888439d3d64..a0f8073866d0 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: black, blackd (use `--force` to overwrite) + "###); } /// Test `uv tool install` when the bin directory is inferred from `$HOME`