diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 7c96ac7384d5..29c2522c905b 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -1839,6 +1839,10 @@ pub struct PipUninstallArgs { #[arg(long, conflicts_with = "target")] pub prefix: Option, + /// Perform a dry run, i.e., don't actually uninstall anything but print the resulting plan. + #[arg(long)] + pub dry_run: bool, + #[command(flatten)] pub compat_args: compat::PipGlobalCompatArgs, } diff --git a/crates/uv/src/commands/pip/uninstall.rs b/crates/uv/src/commands/pip/uninstall.rs index 462bfc749c21..4a7b63778616 100644 --- a/crates/uv/src/commands/pip/uninstall.rs +++ b/crates/uv/src/commands/pip/uninstall.rs @@ -23,6 +23,7 @@ use crate::commands::{elapsed, ExitStatus}; use crate::printer::Printer; /// Uninstall packages from the current environment. +#[allow(clippy::fn_params_excessive_bools)] pub(crate) async fn pip_uninstall( sources: &[RequirementsSource], python: Option, @@ -35,6 +36,7 @@ pub(crate) async fn pip_uninstall( native_tls: bool, keyring_provider: KeyringProviderType, allow_insecure_host: &[TrustedHost], + dry_run: bool, printer: Printer, ) -> Result { let start = std::time::Instant::now(); @@ -142,13 +144,15 @@ pub(crate) async fn pip_uninstall( for package in &names { let installed = site_packages.get_packages(package); if installed.is_empty() { - writeln!( - printer.stderr(), - "{}{} Skipping {} as it is not installed", - "warning".yellow().bold(), - ":".bold(), - package.as_ref().bold() - )?; + if !dry_run { + writeln!( + printer.stderr(), + "{}{} Skipping {} as it is not installed", + "warning".yellow().bold(), + ":".bold(), + package.as_ref().bold() + )?; + } } else { distributions.extend(installed); } @@ -158,13 +162,15 @@ pub(crate) async fn pip_uninstall( for url in &urls { let installed = site_packages.get_urls(url); if installed.is_empty() { - writeln!( - printer.stderr(), - "{}{} Skipping {} as it is not installed", - "warning".yellow().bold(), - ":".bold(), - url.as_ref().bold() - )?; + if !dry_run { + writeln!( + printer.stderr(), + "{}{} Skipping {} as it is not installed", + "warning".yellow().bold(), + ":".bold(), + url.as_ref().bold() + )?; + } } else { distributions.extend(installed); } @@ -177,43 +183,58 @@ pub(crate) async fn pip_uninstall( }; if distributions.is_empty() { - writeln!( - printer.stderr(), - "{}{} No packages to uninstall", - "warning".yellow().bold(), - ":".bold(), - )?; + if dry_run { + writeln!(printer.stderr(), "Would make no changes")?; + } else { + writeln!( + printer.stderr(), + "{}{} No packages to uninstall", + "warning".yellow().bold(), + ":".bold(), + )?; + } return Ok(ExitStatus::Success); } // Uninstall each package. - for distribution in &distributions { - let summary = uv_installer::uninstall(distribution).await?; - debug!( - "Uninstalled {} ({} file{}, {} director{})", - distribution.name(), - summary.file_count, - if summary.file_count == 1 { "" } else { "s" }, - summary.dir_count, - if summary.dir_count == 1 { "y" } else { "ies" }, - ); + if !dry_run { + for distribution in &distributions { + let summary = uv_installer::uninstall(distribution).await?; + debug!( + "Uninstalled {} ({} file{}, {} director{})", + distribution.name(), + summary.file_count, + if summary.file_count == 1 { "" } else { "s" }, + summary.dir_count, + if summary.dir_count == 1 { "y" } else { "ies" }, + ); + } } - writeln!( - printer.stderr(), - "{}", - format!( - "Uninstalled {} {}", + let uninstalls = distributions.len(); + let s = if uninstalls == 1 { "" } else { "s" }; + if dry_run { + writeln!( + printer.stderr(), + "{}", format!( - "{} package{}", - distributions.len(), - if distributions.len() == 1 { "" } else { "s" } + "Would uninstall {}", + format!("{uninstalls} package{s}").bold(), ) - .bold(), - format!("in {}", elapsed(start.elapsed())).dimmed() - ) - .dimmed() - )?; + .dimmed() + )?; + } else { + writeln!( + printer.stderr(), + "{}", + format!( + "Uninstalled {} {}", + format!("{uninstalls} package{s}").bold(), + format!("in {}", elapsed(start.elapsed())).dimmed(), + ) + .dimmed() + )?; + } for distribution in distributions { writeln!( diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index 40c3fe587e5f..a9dd0cdff2ef 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -568,6 +568,7 @@ async fn run(mut cli: Cli) -> Result { globals.native_tls, args.settings.keyring_provider, &globals.allow_insecure_host, + args.dry_run, printer, ) .await diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index 6429b9f1c90a..ff9fe6fc25c9 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -1760,6 +1760,7 @@ impl PipInstallSettings { pub(crate) struct PipUninstallSettings { pub(crate) package: Vec, pub(crate) requirements: Vec, + pub(crate) dry_run: bool, pub(crate) settings: PipSettings, } @@ -1777,12 +1778,14 @@ impl PipUninstallSettings { no_break_system_packages, target, prefix, + dry_run, compat_args: _, } = args; Self { package, requirements, + dry_run, settings: PipSettings::combine( PipOptions { python: python.and_then(Maybe::into_option), diff --git a/crates/uv/tests/it/pip_uninstall.rs b/crates/uv/tests/it/pip_uninstall.rs index 31f329f52942..8cf30d986720 100644 --- a/crates/uv/tests/it/pip_uninstall.rs +++ b/crates/uv/tests/it/pip_uninstall.rs @@ -494,3 +494,64 @@ Version: 0.22.0 Ok(()) } + +#[test] +fn dry_run_uninstall_egg_info() -> Result<()> { + let context = TestContext::new("3.12"); + + let site_packages = ChildPath::new(context.site_packages()); + + // Manually create a `.egg-info` directory. + site_packages + .child("zstandard-0.22.0-py3.12.egg-info") + .create_dir_all()?; + site_packages + .child("zstandard-0.22.0-py3.12.egg-info") + .child("top_level.txt") + .write_str("zstd")?; + site_packages + .child("zstandard-0.22.0-py3.12.egg-info") + .child("SOURCES.txt") + .write_str("")?; + site_packages + .child("zstandard-0.22.0-py3.12.egg-info") + .child("PKG-INFO") + .write_str("")?; + site_packages + .child("zstandard-0.22.0-py3.12.egg-info") + .child("dependency_links.txt") + .write_str("")?; + site_packages + .child("zstandard-0.22.0-py3.12.egg-info") + .child("entry_points.txt") + .write_str("")?; + + // Manually create the package directory. + site_packages.child("zstd").create_dir_all()?; + site_packages + .child("zstd") + .child("__init__.py") + .write_str("")?; + + // Run `pip uninstall`. + uv_snapshot!(context.pip_uninstall() + .arg("--dry-run") + .arg("zstandard"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Would uninstall 1 package + - zstandard==0.22.0 + "###); + + // The `.egg-info` directory should still exist. + assert!(site_packages + .child("zstandard-0.22.0-py3.12.egg-info") + .exists()); + // The package directory should still exist. + assert!(site_packages.child("zstd").child("__init__.py").exists()); + + Ok(()) +} diff --git a/docs/reference/cli.md b/docs/reference/cli.md index d2d31ef151c7..d2735144d3d6 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -6554,6 +6554,8 @@ uv pip uninstall [OPTIONS] >

See --project to only change the project root directory.

+
--dry-run

Perform a dry run, i.e., don’t actually uninstall anything but print the resulting plan

+
--help, -h

Display the concise help for this command

--keyring-provider keyring-provider

Attempt to use keyring for authentication for remote requirements files.