Add uv tool uninstall (#4641)

Co-authored-by: Charlie Marsh <charlie.r.marsh@gmail.com>
This commit is contained in:
Zanie Blue 2024-06-29 13:50:20 -04:00 committed by GitHub
parent 8d9b4a5e1c
commit 7da3423af9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 239 additions and 5 deletions

View file

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

View file

@ -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);

View file

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

View file

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

View file

@ -1,3 +1,4 @@
pub(crate) mod install;
pub(crate) mod list;
pub(crate) mod run;
pub(crate) mod uninstall;

View file

@ -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<ExitStatus> {
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)
}

View file

@ -830,7 +830,6 @@ async fn run() -> Result<ExitStatus> {
)
.await
}
Commands::Tool(ToolNamespace {
command: ToolCommand::List(args),
}) => {
@ -840,6 +839,15 @@ async fn run() -> Result<ExitStatus> {
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),
}) => {

View file

@ -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<FilesystemOptions>) -> Self {
let ToolUninstallArgs { name } = args;
Self { name }
}
}
#[derive(Debug, Clone, Default)]
pub(crate) enum ToolchainListKinds {
#[default]

View file

@ -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());

View file

@ -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
"###);
}