From 7da3423af941a31430ef8dec2986e575130fd045 Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Sat, 29 Jun 2024 13:50:20 -0400 Subject: [PATCH] Add `uv tool uninstall` (#4641) Co-authored-by: Charlie Marsh --- crates/uv-cli/src/lib.rs | 8 ++ crates/uv-tool/src/lib.rs | 3 + crates/uv-tool/src/tool.rs | 8 +- crates/uv/src/commands/mod.rs | 1 + crates/uv/src/commands/tool/mod.rs | 1 + crates/uv/src/commands/tool/uninstall.rs | 65 +++++++++++++ crates/uv/src/main.rs | 10 +- crates/uv/src/settings.rs | 21 +++- crates/uv/tests/common/mod.rs | 8 ++ crates/uv/tests/tool_uninstall.rs | 119 +++++++++++++++++++++++ 10 files changed, 239 insertions(+), 5 deletions(-) create mode 100644 crates/uv/src/commands/tool/uninstall.rs create mode 100644 crates/uv/tests/tool_uninstall.rs diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 1db5a21fe..a541942da 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -1875,6 +1875,8 @@ pub enum ToolCommand { Install(ToolInstallArgs), /// List installed tools. List(ToolListArgs), + /// Uninstall a tool. + Uninstall(ToolUninstallArgs), } #[derive(Args)] @@ -1975,6 +1977,12 @@ pub struct ToolInstallArgs { #[allow(clippy::struct_excessive_bools)] pub struct ToolListArgs; +#[derive(Args)] +#[allow(clippy::struct_excessive_bools)] +pub struct ToolUninstallArgs { + pub name: String, +} + #[derive(Args)] #[allow(clippy::struct_excessive_bools)] pub struct ToolchainNamespace { diff --git a/crates/uv-tool/src/lib.rs b/crates/uv-tool/src/lib.rs index 906b57f88..5752017ee 100644 --- a/crates/uv-tool/src/lib.rs +++ b/crates/uv-tool/src/lib.rs @@ -143,6 +143,9 @@ impl InstalledTools { Ok(()) } + /// Remove the environment for a tool. + /// + /// Does not remove the tool's entrypoints. pub fn remove_environment(&self, name: &str) -> Result<(), Error> { let _lock = self.acquire_lock(); let environment_path = self.root.join(name); diff --git a/crates/uv-tool/src/tool.rs b/crates/uv-tool/src/tool.rs index eaee699fc..9e2cc58e3 100644 --- a/crates/uv-tool/src/tool.rs +++ b/crates/uv-tool/src/tool.rs @@ -25,8 +25,8 @@ pub struct Tool { #[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Deserialize)] #[serde(rename_all = "kebab-case")] pub struct ToolEntrypoint { - name: String, - install_path: PathBuf, + pub name: String, + pub install_path: PathBuf, } /// Format an array so that each element is on its own line and has a trailing comma. @@ -105,6 +105,10 @@ impl Tool { table } + + pub fn entrypoints(&self) -> &[ToolEntrypoint] { + &self.entrypoints + } } impl ToolEntrypoint { diff --git a/crates/uv/src/commands/mod.rs b/crates/uv/src/commands/mod.rs index 7af049be3..56cca196e 100644 --- a/crates/uv/src/commands/mod.rs +++ b/crates/uv/src/commands/mod.rs @@ -27,6 +27,7 @@ pub(crate) use self_update::self_update; pub(crate) use tool::install::install as tool_install; pub(crate) use tool::list::list as tool_list; pub(crate) use tool::run::run as tool_run; +pub(crate) use tool::uninstall::uninstall as tool_uninstall; pub(crate) use toolchain::find::find as toolchain_find; pub(crate) use toolchain::install::install as toolchain_install; pub(crate) use toolchain::list::list as toolchain_list; diff --git a/crates/uv/src/commands/tool/mod.rs b/crates/uv/src/commands/tool/mod.rs index 0fb53090a..1939e4889 100644 --- a/crates/uv/src/commands/tool/mod.rs +++ b/crates/uv/src/commands/tool/mod.rs @@ -1,3 +1,4 @@ pub(crate) mod install; pub(crate) mod list; pub(crate) mod run; +pub(crate) mod uninstall; diff --git a/crates/uv/src/commands/tool/uninstall.rs b/crates/uv/src/commands/tool/uninstall.rs new file mode 100644 index 000000000..ad7b57be3 --- /dev/null +++ b/crates/uv/src/commands/tool/uninstall.rs @@ -0,0 +1,65 @@ +use std::fmt::Write; + +use anyhow::{bail, Result}; +use itertools::Itertools; + +use tracing::debug; +use uv_configuration::PreviewMode; +use uv_fs::Simplified; +use uv_tool::InstalledTools; +use uv_warnings::warn_user_once; + +use crate::commands::ExitStatus; +use crate::printer::Printer; + +/// Uninstall a tool. +#[allow(clippy::too_many_arguments)] +pub(crate) async fn uninstall( + name: String, + preview: PreviewMode, + printer: Printer, +) -> Result { + if preview.is_disabled() { + warn_user_once!("`uv tool uninstall` is experimental and may change without warning."); + } + + let installed_tools = InstalledTools::from_settings()?; + let Some(receipt) = installed_tools.get_tool_receipt(&name)? else { + bail!("Tool `{}` is not installed", name); + }; + + // Remove the tool itself. + installed_tools.remove_environment(&name)?; + + // Remove the tool's entrypoints. + let entrypoints = receipt.entrypoints(); + for entrypoint in entrypoints { + debug!( + "Removing entrypoint: {}", + entrypoint.install_path.user_display() + ); + match fs_err::tokio::remove_file(&entrypoint.install_path).await { + Ok(()) => {} + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + debug!( + "Entrypoint not found: {}", + entrypoint.install_path.user_display() + ); + } + Err(err) => { + return Err(err.into()); + } + } + } + + writeln!( + printer.stderr(), + "Uninstalled: {}", + entrypoints + .iter() + .map(|entrypoint| &entrypoint.name) + .join(", ") + )?; + + Ok(ExitStatus::Success) +} diff --git a/crates/uv/src/main.rs b/crates/uv/src/main.rs index 1fd0c7a4e..23d6df140 100644 --- a/crates/uv/src/main.rs +++ b/crates/uv/src/main.rs @@ -830,7 +830,6 @@ async fn run() -> Result { ) .await } - Commands::Tool(ToolNamespace { command: ToolCommand::List(args), }) => { @@ -840,6 +839,15 @@ async fn run() -> Result { commands::tool_list(globals.preview, printer).await } + Commands::Tool(ToolNamespace { + command: ToolCommand::Uninstall(args), + }) => { + // Resolve the settings from the command-line arguments and workspace configuration. + let args = settings::ToolUninstallSettings::resolve(args, filesystem); + show_settings!(args); + + commands::tool_uninstall(args.name, globals.preview, printer).await + } Commands::Toolchain(ToolchainNamespace { command: ToolchainCommand::List(args), }) => { diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index a1c356b92..29eae6687 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -14,8 +14,8 @@ use uv_cli::{ AddArgs, ColorChoice, Commands, ExternalCommand, GlobalArgs, ListFormat, LockArgs, Maybe, PipCheckArgs, PipCompileArgs, PipFreezeArgs, PipInstallArgs, PipListArgs, PipShowArgs, PipSyncArgs, PipTreeArgs, PipUninstallArgs, RemoveArgs, RunArgs, SyncArgs, ToolInstallArgs, - ToolListArgs, ToolRunArgs, ToolchainFindArgs, ToolchainInstallArgs, ToolchainListArgs, - VenvArgs, + ToolListArgs, ToolRunArgs, ToolUninstallArgs, ToolchainFindArgs, ToolchainInstallArgs, + ToolchainListArgs, VenvArgs, }; use uv_client::Connectivity; use uv_configuration::{ @@ -291,6 +291,23 @@ impl ToolListSettings { } } +/// The resolved settings to use for a `tool uninstall` invocation. +#[allow(clippy::struct_excessive_bools)] +#[derive(Debug, Clone)] +pub(crate) struct ToolUninstallSettings { + pub(crate) name: String, +} + +impl ToolUninstallSettings { + /// Resolve the [`ToolUninstallSettings`] from the CLI and filesystem configuration. + #[allow(clippy::needless_pass_by_value)] + pub(crate) fn resolve(args: ToolUninstallArgs, _filesystem: Option) -> Self { + let ToolUninstallArgs { name } = args; + + Self { name } + } +} + #[derive(Debug, Clone, Default)] pub(crate) enum ToolchainListKinds { #[default] diff --git a/crates/uv/tests/common/mod.rs b/crates/uv/tests/common/mod.rs index 05973f965..99096807a 100644 --- a/crates/uv/tests/common/mod.rs +++ b/crates/uv/tests/common/mod.rs @@ -420,6 +420,14 @@ impl TestContext { command } + /// Create a `uv tool uninstall` command with options shared across scenarios. + pub fn tool_uninstall(&self) -> std::process::Command { + let mut command = std::process::Command::new(get_bin()); + command.arg("tool").arg("uninstall"); + self.add_shared_args(&mut command); + command + } + /// Create a `uv add` command for the given requirements. pub fn add(&self, reqs: &[&str]) -> Command { let mut command = Command::new(get_bin()); diff --git a/crates/uv/tests/tool_uninstall.rs b/crates/uv/tests/tool_uninstall.rs new file mode 100644 index 000000000..6d478a6f6 --- /dev/null +++ b/crates/uv/tests/tool_uninstall.rs @@ -0,0 +1,119 @@ +#![cfg(all(feature = "python", feature = "pypi"))] + +use assert_cmd::assert::OutputAssertExt; +use assert_fs::fixture::PathChild; +use common::{uv_snapshot, TestContext}; + +mod common; + +#[test] +fn tool_uninstall() { + let context = TestContext::new("3.12"); + let tool_dir = context.temp_dir.child("tools"); + let bin_dir = context.temp_dir.child("bin"); + + // Install `black` + context + .tool_install() + .arg("black==24.2.0") + .env("UV_TOOL_DIR", tool_dir.as_os_str()) + .env("XDG_BIN_HOME", bin_dir.as_os_str()) + .assert() + .success(); + + uv_snapshot!(context.filters(), context.tool_uninstall().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 + ----- stdout ----- + + ----- stderr ----- + warning: `uv tool uninstall` is experimental and may change without warning. + Uninstalled: black, blackd + "###); + + // After uninstalling the tool, it shouldn't be listed. + uv_snapshot!(context.filters(), context.tool_list() + .env("UV_TOOL_DIR", tool_dir.as_os_str()) + .env("XDG_BIN_HOME", bin_dir.as_os_str()), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv tool list` is experimental and may change without warning. + No tools installed + "###); + + // After uninstalling the tool, we should be able to reinstall it. + uv_snapshot!(context.filters(), context.tool_install() + .arg("black==24.2.0") + .env("UV_TOOL_DIR", tool_dir.as_os_str()) + .env("XDG_BIN_HOME", bin_dir.as_os_str()), @r###" + success: true + exit_code: 0 + ----- 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.2.0 + + click==8.1.7 + + mypy-extensions==1.0.0 + + packaging==24.0 + + pathspec==0.12.1 + + platformdirs==4.2.0 + Installed: black, blackd + "###); +} + +#[test] +fn tool_uninstall_not_installed() { + let context = TestContext::new("3.12"); + let tool_dir = context.temp_dir.child("tools"); + let bin_dir = context.temp_dir.child("bin"); + + uv_snapshot!(context.filters(), context.tool_uninstall().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 uninstall` is experimental and may change without warning. + error: Tool `black` is not installed + "###); +} + +#[test] +fn tool_uninstall_missing_receipt() { + let context = TestContext::new("3.12"); + let tool_dir = context.temp_dir.child("tools"); + let bin_dir = context.temp_dir.child("bin"); + + // Install `black` + context + .tool_install() + .arg("black==24.2.0") + .env("UV_TOOL_DIR", tool_dir.as_os_str()) + .env("XDG_BIN_HOME", bin_dir.as_os_str()) + .assert() + .success(); + + fs_err::remove_file(tool_dir.join("black").join("uv-receipt.toml")).unwrap(); + + uv_snapshot!(context.filters(), context.tool_uninstall().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 uninstall` is experimental and may change without warning. + error: Tool `black` is not installed + "###); +}