From 8d01f70beb41a1eaab52e30b1c572bff1137eae2 Mon Sep 17 00:00:00 2001 From: Alex Willmer Date: Mon, 2 Dec 2024 02:57:47 +0000 Subject: [PATCH] Add `--dry-run` to `uv pip uninstall` (#9557) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary This proposes adding the command line option `uv pip uninstall --dry-run ...`, complementing the existing `uv pip install --dry-run ...` added for #1244 in #1436. This option does not exist in PyPA's `pip uninstall`, if adopted it would be unique to `uv pip`. The code should be considered PoC, it is baby's first Rust. The initial motivation was while investigating https://github.com/moreati/ansible-uv/issues/2 - to allow Ansible module `moreati.uv.pip` to work with`state: absent` in "check_mode" (Ansible's equivalent of a dry run), without requiring `packaging` or `setuptools`. ## Test Plan One new unit test has been added. I pedge to add more if the feature is desired/accepted Example usage ```console ➜ uv git:(pip-uninstall--dry-run) rm -rf .venv ➜ uv git:(pip-uninstall--dry-run) ./target/debug/uv venv Using CPython 3.13.0 Creating virtual environment at: .venv Activate with: source .venv/bin/activate ➜ uv git:(pip-uninstall--dry-run) ./target/debug/uv pip install httpx Resolved 7 packages in 178ms Prepared 5 packages in 60ms Installed 7 packages in 15ms + anyio==4.6.2.post1 + certifi==2024.8.30 + h11==0.14.0 + httpcore==1.0.7 + httpx==0.28.0 + idna==3.10 + sniffio==1.3.1 ➜ uv git:(pip-uninstall--dry-run) ./target/debug/uv pip uninstall --dry-run httpx Would uninstall 1 package - httpx==0.28.0 ➜ uv git:(pip-uninstall--dry-run) ./target/debug/uv pip list Package Version -------- ----------- anyio 4.6.2.post1 certifi 2024.8.30 h11 0.14.0 httpcore 1.0.7 httpx 0.28.0 idna 3.10 sniffio 1.3.1 ``` --------- Co-authored-by: Charlie Marsh --- crates/uv-cli/src/lib.rs | 4 + crates/uv/src/commands/pip/uninstall.rs | 107 ++++++++++++++---------- crates/uv/src/lib.rs | 1 + crates/uv/src/settings.rs | 3 + crates/uv/tests/it/pip_uninstall.rs | 61 ++++++++++++++ docs/reference/cli.md | 2 + 6 files changed, 135 insertions(+), 43 deletions(-) diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 7c96ac738..29c2522c9 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 462bfc749..4a7b63778 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 40c3fe587..a9dd0cdff 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 6429b9f1c..ff9fe6fc2 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 31f329f52..8cf30d986 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 d2d31ef15..d2735144d 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.