Skip to content

Commit

Permalink
Add --dry-run to uv pip uninstall (#9557)
Browse files Browse the repository at this point in the history
## 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
moreati/ansible-uv#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 <charlie.r.marsh@gmail.com>
  • Loading branch information
moreati and charliermarsh authored Dec 2, 2024
1 parent 89a25ba commit 8d01f70
Show file tree
Hide file tree
Showing 6 changed files with 135 additions and 43 deletions.
4 changes: 4 additions & 0 deletions crates/uv-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1839,6 +1839,10 @@ pub struct PipUninstallArgs {
#[arg(long, conflicts_with = "target")]
pub prefix: Option<PathBuf>,

/// 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,
}
Expand Down
107 changes: 64 additions & 43 deletions crates/uv/src/commands/pip/uninstall.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
Expand All @@ -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<ExitStatus> {
let start = std::time::Instant::now();
Expand Down Expand Up @@ -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);
}
Expand All @@ -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);
}
Expand All @@ -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!(
Expand Down
1 change: 1 addition & 0 deletions crates/uv/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -568,6 +568,7 @@ async fn run(mut cli: Cli) -> Result<ExitStatus> {
globals.native_tls,
args.settings.keyring_provider,
&globals.allow_insecure_host,
args.dry_run,
printer,
)
.await
Expand Down
3 changes: 3 additions & 0 deletions crates/uv/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1760,6 +1760,7 @@ impl PipInstallSettings {
pub(crate) struct PipUninstallSettings {
pub(crate) package: Vec<String>,
pub(crate) requirements: Vec<PathBuf>,
pub(crate) dry_run: bool,
pub(crate) settings: PipSettings,
}

Expand All @@ -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),
Expand Down
61 changes: 61 additions & 0 deletions crates/uv/tests/it/pip_uninstall.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
}
2 changes: 2 additions & 0 deletions docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -6554,6 +6554,8 @@ uv pip uninstall [OPTIONS] <PACKAGE|--requirements <REQUIREMENTS>>

<p>See <code>--project</code> to only change the project root directory.</p>

</dd><dt><code>--dry-run</code></dt><dd><p>Perform a dry run, i.e., don&#8217;t actually uninstall anything but print the resulting plan</p>

</dd><dt><code>--help</code>, <code>-h</code></dt><dd><p>Display the concise help for this command</p>

</dd><dt><code>--keyring-provider</code> <i>keyring-provider</i></dt><dd><p>Attempt to use <code>keyring</code> for authentication for remote requirements files.</p>
Expand Down

0 comments on commit 8d01f70

Please sign in to comment.