diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 37acc18a4077..fa05e1917b1e 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -3109,7 +3109,7 @@ pub struct ToolDirArgs { pub struct ToolUninstallArgs { /// The name of the tool to uninstall. #[arg(required = true)] - pub name: Option, + pub name: Option>, /// Uninstall all tools. #[arg(long, conflicts_with("name"))] @@ -3121,7 +3121,7 @@ pub struct ToolUninstallArgs { pub struct ToolUpgradeArgs { /// The name of the tool to upgrade. #[arg(required = true)] - pub name: Option, + pub name: Vec, /// Upgrade all tools. #[arg(long, conflicts_with("name"))] diff --git a/crates/uv/src/commands/tool/uninstall.rs b/crates/uv/src/commands/tool/uninstall.rs index 7e6fa39bb4fa..cc4191bf8717 100644 --- a/crates/uv/src/commands/tool/uninstall.rs +++ b/crates/uv/src/commands/tool/uninstall.rs @@ -13,13 +13,19 @@ use crate::commands::ExitStatus; use crate::printer::Printer; /// Uninstall a tool. -pub(crate) async fn uninstall(name: Option, printer: Printer) -> Result { +pub(crate) async fn uninstall( + name: Option>, + printer: Printer, +) -> Result { let installed_tools = InstalledTools::from_settings()?.init()?; let _lock = match installed_tools.lock().await { Ok(lock) => lock, Err(uv_tool::Error::Io(err)) if err.kind() == std::io::ErrorKind::NotFound => { - if let Some(name) = name { - bail!("`{name}` is not installed"); + if let Some(names) = name { + for name in names { + writeln!(printer.stderr(), "`{name}` is not installed")?; + } + return Ok(ExitStatus::Success); } writeln!(printer.stderr(), "Nothing to uninstall")?; return Ok(ExitStatus::Success); @@ -88,31 +94,35 @@ impl IgnoreCurrentlyBeingDeleted for Result<(), std::io::Error> { /// Perform the uninstallation. async fn do_uninstall( installed_tools: &InstalledTools, - name: Option, + names: Option>, 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 { - // If the tool is not installed, attempt to remove the environment anyway. - match installed_tools.remove_environment(&name) { - Ok(()) => { - writeln!( - printer.stderr(), - "Removed dangling environment for `{name}`" - )?; - return Ok(()); - } - Err(uv_tool::Error::Io(err)) if err.kind() == std::io::ErrorKind::NotFound => { - bail!("`{name}` is not installed"); - } - Err(err) => { - return Err(err.into()); + let mut entrypoints = if let Some(names) = names { + let mut entrypoints = vec![]; + for name in names { + let Some(receipt) = installed_tools.get_tool_receipt(&name)? else { + // If the tool is not installed properly, attempt to remove the environment anyway. + match installed_tools.remove_environment(&name) { + Ok(()) => { + writeln!( + printer.stderr(), + "Removed dangling environment for `{name}`" + )?; + return Ok(()); + } + Err(uv_tool::Error::Io(err)) if err.kind() == std::io::ErrorKind::NotFound => { + bail!("`{name}` is not installed"); + } + Err(err) => { + return Err(err.into()); + } } - } - }; + }; - uninstall_tool(&name, &receipt, installed_tools).await? + entrypoints.extend(uninstall_tool(&name, &receipt, installed_tools).await?); + } + entrypoints } else { let mut entrypoints = vec![]; for (name, receipt) in installed_tools.tools()? { diff --git a/crates/uv/src/commands/tool/upgrade.rs b/crates/uv/src/commands/tool/upgrade.rs index 191c60f299f2..d3f3e3978871 100644 --- a/crates/uv/src/commands/tool/upgrade.rs +++ b/crates/uv/src/commands/tool/upgrade.rs @@ -21,7 +21,7 @@ use crate::settings::ResolverInstallerSettings; /// Upgrade a tool. pub(crate) async fn upgrade( - name: Option, + name: Vec, connectivity: Connectivity, args: ResolverInstallerOptions, filesystem: ResolverInstallerOptions, @@ -34,16 +34,18 @@ pub(crate) async fn upgrade( let installed_tools = InstalledTools::from_settings()?.init()?; let _lock = installed_tools.lock().await?; - let names: BTreeSet = - name.map(|name| BTreeSet::from_iter([name])) - .unwrap_or_else(|| { - installed_tools - .tools() - .unwrap_or_default() - .into_iter() - .map(|(name, _)| name) - .collect() - }); + let names: BTreeSet = { + if name.is_empty() { + installed_tools + .tools() + .unwrap_or_default() + .into_iter() + .map(|(name, _)| name) + .collect() + } else { + name.into_iter().collect() + } + }; if names.is_empty() { writeln!(printer.stderr(), "Nothing to upgrade")?; diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index 95ddb3514355..19c312bcb79b 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -414,7 +414,7 @@ impl ToolInstallSettings { #[allow(clippy::struct_excessive_bools)] #[derive(Debug, Clone)] pub(crate) struct ToolUpgradeSettings { - pub(crate) name: Option, + pub(crate) name: Vec, pub(crate) args: ResolverInstallerOptions, pub(crate) filesystem: ResolverInstallerOptions, } @@ -445,7 +445,7 @@ impl ToolUpgradeSettings { .unwrap_or_default(); Self { - name: name.filter(|_| !all), + name: if all { vec![] } else { name }, args, filesystem, } @@ -473,7 +473,7 @@ impl ToolListSettings { #[allow(clippy::struct_excessive_bools)] #[derive(Debug, Clone)] pub(crate) struct ToolUninstallSettings { - pub(crate) name: Option, + pub(crate) name: Option>, } impl ToolUninstallSettings { diff --git a/crates/uv/tests/tool_uninstall.rs b/crates/uv/tests/tool_uninstall.rs index 445ce42a7586..9a9a3e7fb31a 100644 --- a/crates/uv/tests/tool_uninstall.rs +++ b/crates/uv/tests/tool_uninstall.rs @@ -68,6 +68,53 @@ fn tool_uninstall() { "###); } +#[test] +fn tool_uninstall_multiple_names() { + let context = TestContext::new("3.12").with_filtered_exe_suffix(); + 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(); + + context + .tool_install() + .arg("ruff==0.3.4") + .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").arg("ruff") + .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 ----- + Uninstalled 3 executables: black, blackd, ruff + "###); + + // 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 ----- + No tools installed + "###); +} + #[test] fn tool_uninstall_not_installed() { let context = TestContext::new("3.12").with_filtered_exe_suffix(); diff --git a/crates/uv/tests/tool_upgrade.rs b/crates/uv/tests/tool_upgrade.rs index 6bada9b4043d..8cb76ddd89ef 100644 --- a/crates/uv/tests/tool_upgrade.rs +++ b/crates/uv/tests/tool_upgrade.rs @@ -56,6 +56,81 @@ fn test_tool_upgrade_name() { "###); } +#[test] +fn test_tool_upgrade_multiple_names() { + let context = TestContext::new("3.12") + .with_filtered_counts() + .with_filtered_exe_suffix(); + let tool_dir = context.temp_dir.child("tools"); + let bin_dir = context.temp_dir.child("bin"); + + // Install `python-dotenv` from Test PyPI, to get an outdated version. + uv_snapshot!(context.filters(), context.tool_install() + .arg("python-dotenv") + .arg("--index-url") + .arg("https://test.pypi.org/simple/") + .env("UV_TOOL_DIR", tool_dir.as_os_str()) + .env("XDG_BIN_HOME", bin_dir.as_os_str()) + .env("PATH", bin_dir.as_os_str()), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved [N] packages in [TIME] + Prepared [N] packages in [TIME] + Installed [N] packages in [TIME] + + python-dotenv==0.10.2.post2 + Installed 1 executable: dotenv + "###); + + // Install `babel` from Test PyPI, to get an outdated version. + uv_snapshot!(context.filters(), context.tool_install() + .arg("babel") + .arg("--index-url") + .arg("https://test.pypi.org/simple/") + .env("UV_TOOL_DIR", tool_dir.as_os_str()) + .env("XDG_BIN_HOME", bin_dir.as_os_str()) + .env("PATH", bin_dir.as_os_str()), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved [N] packages in [TIME] + Prepared [N] packages in [TIME] + Installed [N] packages in [TIME] + + babel==2.6.0 + + pytz==2018.5 + Installed 1 executable: pybabel + "###); + + // Upgrade `babel` and `python-dotenv` from PyPI. + uv_snapshot!(context.filters(), context.tool_upgrade() + .arg("babel") + .arg("python-dotenv") + .arg("--index-url") + .arg("https://pypi.org/simple/") + .env("UV_TOOL_DIR", tool_dir.as_os_str()) + .env("XDG_BIN_HOME", bin_dir.as_os_str()) + .env("PATH", bin_dir.as_os_str()), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Updated babel v2.6.0 -> v2.14.0 + - babel==2.6.0 + + babel==2.14.0 + - pytz==2018.5 + Installed 1 executable: pybabel + Updated python-dotenv v0.10.2.post2 -> v1.0.1 + - python-dotenv==0.10.2.post2 + + python-dotenv==1.0.1 + Installed 1 executable: dotenv + "###); +} + #[test] fn test_tool_upgrade_all() { let context = TestContext::new("3.12") diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 4ba82d500610..e5017a28cee6 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -2832,7 +2832,7 @@ If a tool was installed with specific settings, they will be respected on upgrad

Usage

``` -uv tool upgrade [OPTIONS] +uv tool upgrade [OPTIONS] ... ```

Arguments

@@ -3150,7 +3150,7 @@ Uninstall a tool

Usage

``` -uv tool uninstall [OPTIONS] +uv tool uninstall [OPTIONS] ... ```

Arguments