Add `--dry-run` flag to `uv pip install` (#1436)

## What

Adds a `--dry-run` flag that ejects out of the installation process
early (but after resolution) and displays only what *would have*
installed

## Closes

Closes #1244 

## Out of Scope

I think it may be nice to include a `dry-run` flag for `uninstall` even
though `pip` doesn't implement this... thinking `Would uninstall X
packages: ...`

---------

Co-authored-by: Zanie Blue <contact@zanie.dev>
This commit is contained in:
Jacob Coffee 2024-03-12 01:19:30 -05:00 committed by GitHub
parent f3495d7cad
commit 15f6f9f448
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 437 additions and 13 deletions

View File

@ -1,6 +1,5 @@
use std::fmt::Write;
use std::process::ExitCode;
use std::time::Duration;
use std::{fmt::Display, fmt::Write, process::ExitCode};
use anyhow::Context;
use owo_colors::OwoColorize;
@ -19,6 +18,7 @@ use uv_cache::Cache;
use uv_fs::Simplified;
use uv_installer::compile_tree;
use uv_interpreter::PythonEnvironment;
use uv_normalize::PackageName;
pub(crate) use venv::venv;
pub(crate) use version::version;
@ -89,6 +89,13 @@ pub(super) struct ChangeEvent<T: InstalledMetadata> {
kind: ChangeEventKind,
}
#[derive(Debug)]
pub(super) struct DryRunEvent<T: Display> {
name: PackageName,
version: T,
kind: ChangeEventKind,
}
#[derive(Debug, Clone, Copy, clap::ValueEnum)]
pub(crate) enum VersionFormat {
Text,

View File

@ -1,6 +1,7 @@
use std::collections::HashSet;
use std::fmt::Write;
use std::path::Path;
use std::time::Instant;
use anstream::eprint;
use anyhow::{anyhow, Context, Result};
@ -11,7 +12,8 @@ use tempfile::tempdir_in;
use tracing::debug;
use distribution_types::{
IndexLocations, InstalledMetadata, LocalDist, LocalEditable, Name, Resolution,
DistributionMetadata, IndexLocations, InstalledMetadata, LocalDist, LocalEditable, Name,
Resolution,
};
use install_wheel_rs::linker::LinkMode;
use pep508_rs::{MarkerEnvironment, Requirement};
@ -39,7 +41,7 @@ use crate::commands::{compile_bytecode, elapsed, ChangeEvent, ChangeEventKind, E
use crate::printer::Printer;
use crate::requirements::{ExtrasSpecification, RequirementsSource, RequirementsSpecification};
use super::Upgrade;
use super::{DryRunEvent, Upgrade};
/// Install packages into the current environment.
#[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)]
@ -69,6 +71,7 @@ pub(crate) async fn pip_install(
break_system_packages: bool,
native_tls: bool,
cache: Cache,
dry_run: bool,
printer: Printer,
) -> Result<ExitStatus> {
let start = std::time::Instant::now();
@ -164,6 +167,9 @@ pub(crate) async fn pip_install(
)
.dimmed()
)?;
if dry_run {
writeln!(printer.stderr(), "Would make no changes")?;
}
return Ok(ExitStatus::Success);
}
@ -320,6 +326,7 @@ pub(crate) async fn pip_install(
&install_dispatch,
&cache,
&venv,
dry_run,
printer,
)
.await?;
@ -392,7 +399,7 @@ async fn build_editables(
build_dispatch: &BuildDispatch<'_>,
printer: Printer,
) -> Result<Vec<BuiltEditable>, Error> {
let start = std::time::Instant::now();
let start = Instant::now();
let downloader = Downloader::new(cache, tags, client, build_dispatch)
.with_reporter(DownloadReporter::from(printer).with_length(editables.len() as u64));
@ -558,6 +565,7 @@ async fn install(
build_dispatch: &BuildDispatch<'_>,
cache: &Cache,
venv: &PythonEnvironment,
dry_run: bool,
printer: Printer,
) -> Result<(), Error> {
let start = std::time::Instant::now();
@ -572,12 +580,7 @@ async fn install(
// Partition into those that should be linked from the cache (`local`), those that need to be
// downloaded (`remote`), and those that should be removed (`extraneous`).
let Plan {
local,
remote,
reinstalls,
extraneous: _,
} = Planner::with_requirements(&requirements)
let plan = Planner::with_requirements(&requirements)
.with_editable_requirements(&editables)
.build(
site_packages,
@ -590,6 +593,17 @@ async fn install(
)
.context("Failed to determine installation plan")?;
if dry_run {
return report_dry_run(resolution, plan, start, printer);
}
let Plan {
local,
remote,
reinstalls,
extraneous: _,
} = plan;
// Nothing to do.
if remote.is_empty() && local.is_empty() && reinstalls.is_empty() {
let s = if resolution.len() == 1 { "" } else { "s" };
@ -603,7 +617,6 @@ async fn install(
)
.dimmed()
)?;
return Ok(());
}
@ -622,7 +635,7 @@ async fn install(
let wheels = if remote.is_empty() {
vec![]
} else {
let start = std::time::Instant::now();
let start = Instant::now();
let downloader = Downloader::new(cache, tags, client, build_dispatch)
.with_reporter(DownloadReporter::from(printer).with_length(remote.len() as u64));
@ -728,6 +741,135 @@ async fn install(
}
}
#[allow(clippy::items_after_statements)]
fn report_dry_run(
resolution: &Resolution,
plan: Plan,
start: Instant,
printer: Printer,
) -> Result<(), Error> {
let Plan {
local,
remote,
reinstalls,
extraneous: _,
} = plan;
// Nothing to do.
if remote.is_empty() && local.is_empty() && reinstalls.is_empty() {
let s = if resolution.len() == 1 { "" } else { "s" };
writeln!(
printer.stderr(),
"{}",
format!(
"Audited {} in {}",
format!("{} package{}", resolution.len(), s).bold(),
elapsed(start.elapsed())
)
.dimmed()
)?;
writeln!(printer.stderr(), "Would make no changes")?;
return Ok(());
}
// Map any registry-based requirements back to those returned by the resolver.
let remote = remote
.iter()
.map(|dist| {
resolution
.get(&dist.name)
.cloned()
.expect("Resolution should contain all packages")
})
.collect::<Vec<_>>();
// Download, build, and unzip any missing distributions.
let wheels = if remote.is_empty() {
vec![]
} else {
let s = if remote.len() == 1 { "" } else { "s" };
writeln!(
printer.stderr(),
"{}",
format!(
"Would download {}",
format!("{} package{}", remote.len(), s).bold(),
)
.dimmed()
)?;
remote
};
// Remove any existing installations.
if !reinstalls.is_empty() {
let s = if reinstalls.len() == 1 { "" } else { "s" };
writeln!(
printer.stderr(),
"{}",
format!(
"Would uninstall {}",
format!("{} package{}", reinstalls.len(), s).bold(),
)
.dimmed()
)?;
}
// Install the resolved distributions.
let installs = wheels.len() + local.len();
if installs > 0 {
let s = if installs == 1 { "" } else { "s" };
writeln!(
printer.stderr(),
"{}",
format!("Would install {}", format!("{installs} package{s}").bold()).dimmed()
)?;
}
for event in reinstalls
.into_iter()
.map(|distribution| DryRunEvent {
name: distribution.name().clone(),
version: distribution.installed_version().to_string(),
kind: ChangeEventKind::Removed,
})
.chain(wheels.into_iter().map(|distribution| DryRunEvent {
name: distribution.name().clone(),
version: distribution.version_or_url().to_string(),
kind: ChangeEventKind::Added,
}))
.chain(local.into_iter().map(|distribution| DryRunEvent {
name: distribution.name().clone(),
version: distribution.installed_version().to_string(),
kind: ChangeEventKind::Added,
}))
.sorted_unstable_by(|a, b| a.name.cmp(&b.name).then_with(|| a.kind.cmp(&b.kind)))
{
match event.kind {
ChangeEventKind::Added => {
writeln!(
printer.stderr(),
" {} {}{}",
"+".green(),
event.name.as_ref().bold(),
event.version.dimmed()
)?;
}
ChangeEventKind::Removed => {
writeln!(
printer.stderr(),
" {} {}{}",
"-".red(),
event.name.as_ref().bold(),
event.version.dimmed()
)?;
}
}
}
Ok(())
}
// TODO(konstin): Also check the cache whether any cached or installed dist is already known to
// have been yanked, we currently don't show this message on the second run anymore
for dist in &remote {

View File

@ -891,6 +891,11 @@ struct PipInstallArgs {
/// format (e.g., `2006-12-02`).
#[arg(long, value_parser = date_or_datetime)]
exclude_newer: Option<DateTime<Utc>>,
/// Perform a dry run, i.e., don't actually install anything but resolve the dependencies and
/// print the resulting plan.
#[clap(long)]
dry_run: bool,
}
#[derive(Args)]
@ -1586,6 +1591,7 @@ async fn run() -> Result<ExitStatus> {
args.break_system_packages,
cli.native_tls,
cache,
args.dry_run,
printer,
)
.await

View File

@ -2414,3 +2414,272 @@ fn utf8_to_utf16_with_bom_be(s: &str) -> Vec<u8> {
byteorder::BigEndian::write_u16_into(&u16s, &mut u8s);
u8s
}
#[test]
fn dry_run_install() -> std::result::Result<(), Box<dyn std::error::Error>> {
let context = TestContext::new("3.12");
let requirements_txt = context.temp_dir.child("requirements.txt");
requirements_txt.touch()?;
requirements_txt.write_str("httpx==0.25.1")?;
uv_snapshot!(command(&context)
.arg("-r")
.arg("requirements.txt")
.arg("--dry-run")
.arg("--strict"), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 7 packages in [TIME]
Would download 7 packages
Would install 7 packages
+ anyio==4.0.0
+ certifi==2023.11.17
+ h11==0.14.0
+ httpcore==1.0.2
+ httpx==0.25.1
+ idna==3.4
+ sniffio==1.3.0
"###
);
Ok(())
}
#[test]
fn dry_run_install_url_dependency() -> std::result::Result<(), Box<dyn std::error::Error>> {
let context = TestContext::new("3.12");
let requirements_txt = context.temp_dir.child("requirements.txt");
requirements_txt.touch()?;
requirements_txt.write_str("anyio @ https://files.pythonhosted.org/packages/2d/b8/7333d87d5f03247215d86a86362fd3e324111788c6cdd8d2e6196a6ba833/anyio-4.2.0.tar.gz")?;
uv_snapshot!(command(&context)
.arg("-r")
.arg("requirements.txt")
.arg("--dry-run")
.arg("--strict"), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 3 packages in [TIME]
Would download 3 packages
Would install 3 packages
+ anyio @ https://files.pythonhosted.org/packages/2d/b8/7333d87d5f03247215d86a86362fd3e324111788c6cdd8d2e6196a6ba833/anyio-4.2.0.tar.gz
+ idna==3.4
+ sniffio==1.3.0
"###
);
Ok(())
}
#[test]
fn dry_run_uninstall_url_dependency() -> std::result::Result<(), Box<dyn std::error::Error>> {
let context = TestContext::new("3.12");
let requirements_txt = context.temp_dir.child("requirements.txt");
requirements_txt.touch()?;
requirements_txt.write_str("anyio @ https://files.pythonhosted.org/packages/2d/b8/7333d87d5f03247215d86a86362fd3e324111788c6cdd8d2e6196a6ba833/anyio-4.2.0.tar.gz")?;
// Install the URL dependency
uv_snapshot!(command(&context)
.arg("-r")
.arg("requirements.txt")
.arg("--strict"), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 3 packages in [TIME]
Downloaded 3 packages in [TIME]
Installed 3 packages in [TIME]
+ anyio==4.2.0 (from https://files.pythonhosted.org/packages/2d/b8/7333d87d5f03247215d86a86362fd3e324111788c6cdd8d2e6196a6ba833/anyio-4.2.0.tar.gz)
+ idna==3.4
+ sniffio==1.3.0
"###
);
// Then switch to a registry dependency
requirements_txt.write_str("anyio")?;
uv_snapshot!(command(&context)
.arg("-r")
.arg("requirements.txt")
.arg("--upgrade-package")
.arg("anyio")
.arg("--dry-run")
.arg("--strict"), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 3 packages in [TIME]
Would download 1 package
Would uninstall 1 package
Would install 1 package
- anyio==4.2.0 (from https://files.pythonhosted.org/packages/2d/b8/7333d87d5f03247215d86a86362fd3e324111788c6cdd8d2e6196a6ba833/anyio-4.2.0.tar.gz)
+ anyio==4.0.0
"###
);
Ok(())
}
#[test]
fn dry_run_install_already_installed() -> std::result::Result<(), Box<dyn std::error::Error>> {
let context = TestContext::new("3.12");
let requirements_txt = context.temp_dir.child("requirements.txt");
requirements_txt.touch()?;
requirements_txt.write_str("httpx==0.25.1")?;
// Install the package
uv_snapshot!(command(&context)
.arg("-r")
.arg("requirements.txt")
.arg("--strict"), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 7 packages in [TIME]
Downloaded 7 packages in [TIME]
Installed 7 packages in [TIME]
+ anyio==4.0.0
+ certifi==2023.11.17
+ h11==0.14.0
+ httpcore==1.0.2
+ httpx==0.25.1
+ idna==3.4
+ sniffio==1.3.0
"###
);
// Install again with dry run enabled
uv_snapshot!(command(&context)
.arg("-r")
.arg("requirements.txt")
.arg("--dry-run")
.arg("--strict"), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Audited 1 package in [TIME]
Would make no changes
"###
);
Ok(())
}
#[test]
fn dry_run_install_transitive_dependency_already_installed(
) -> std::result::Result<(), Box<dyn std::error::Error>> {
let context = TestContext::new("3.12");
let requirements_txt = context.temp_dir.child("requirements.txt");
requirements_txt.touch()?;
requirements_txt.write_str("httpcore==1.0.2")?;
// Install a dependency of httpx
uv_snapshot!(command(&context)
.arg("-r")
.arg("requirements.txt")
.arg("--strict"), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 3 packages in [TIME]
Downloaded 3 packages in [TIME]
Installed 3 packages in [TIME]
+ certifi==2023.11.17
+ h11==0.14.0
+ httpcore==1.0.2
"###
);
// Install it httpx with dry run enabled
requirements_txt.write_str("httpx==0.25.1")?;
uv_snapshot!(command(&context)
.arg("-r")
.arg("requirements.txt")
.arg("--dry-run")
.arg("--strict"), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 7 packages in [TIME]
Would download 4 packages
Would install 4 packages
+ anyio==4.0.0
+ httpx==0.25.1
+ idna==3.4
+ sniffio==1.3.0
"###
);
Ok(())
}
#[test]
fn dry_run_install_then_upgrade() -> std::result::Result<(), Box<dyn std::error::Error>> {
let context = TestContext::new("3.12");
let requirements_txt = context.temp_dir.child("requirements.txt");
requirements_txt.touch()?;
requirements_txt.write_str("httpx==0.25.0")?;
// Install the package
uv_snapshot!(command(&context)
.arg("-r")
.arg("requirements.txt")
.arg("--strict"), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 7 packages in [TIME]
Downloaded 7 packages in [TIME]
Installed 7 packages in [TIME]
+ anyio==4.0.0
+ certifi==2023.11.17
+ h11==0.14.0
+ httpcore==0.18.0
+ httpx==0.25.0
+ idna==3.4
+ sniffio==1.3.0
"###
);
// Bump the version and install with dry run enabled
requirements_txt.write_str("httpx==0.25.1")?;
uv_snapshot!(command(&context)
.arg("-r")
.arg("requirements.txt")
.arg("--dry-run"), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 7 packages in [TIME]
Would download 1 package
Would uninstall 1 package
Would install 1 package
- httpx==0.25.0
+ httpx==0.25.1
"###
);
Ok(())
}