Skip to content

Commit

Permalink
Add --dry-run flag to uv pip install (#1436)
Browse files Browse the repository at this point in the history
## 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>
  • Loading branch information
JacobCoffee and zanieb authored Mar 12, 2024
1 parent f3495d7 commit 15f6f9f
Show file tree
Hide file tree
Showing 4 changed files with 437 additions and 13 deletions.
11 changes: 9 additions & 2 deletions crates/uv/src/commands/mod.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;

Expand Down Expand Up @@ -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,
Expand Down
164 changes: 153 additions & 11 deletions crates/uv/src/commands/pip_install.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand All @@ -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};
Expand Down Expand Up @@ -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)]
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -320,6 +326,7 @@ pub(crate) async fn pip_install(
&install_dispatch,
&cache,
&venv,
dry_run,
printer,
)
.await?;
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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();
Expand All @@ -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,
Expand All @@ -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" };
Expand All @@ -603,7 +617,6 @@ async fn install(
)
.dimmed()
)?;

return Ok(());
}

Expand All @@ -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));
Expand Down Expand Up @@ -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 {
Expand Down
6 changes: 6 additions & 0 deletions crates/uv/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -1586,6 +1591,7 @@ async fn run() -> Result<ExitStatus> {
args.break_system_packages,
cli.native_tls,
cache,
args.dry_run,
printer,
)
.await
Expand Down
Loading

0 comments on commit 15f6f9f

Please sign in to comment.