Skip to content

Commit 15f6f9f

Browse files
JacobCoffeezanieb
andauthored
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 <[email protected]>
1 parent f3495d7 commit 15f6f9f

File tree

4 files changed

+437
-13
lines changed

4 files changed

+437
-13
lines changed

crates/uv/src/commands/mod.rs

+9-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
use std::fmt::Write;
2-
use std::process::ExitCode;
31
use std::time::Duration;
2+
use std::{fmt::Display, fmt::Write, process::ExitCode};
43

54
use anyhow::Context;
65
use owo_colors::OwoColorize;
@@ -19,6 +18,7 @@ use uv_cache::Cache;
1918
use uv_fs::Simplified;
2019
use uv_installer::compile_tree;
2120
use uv_interpreter::PythonEnvironment;
21+
use uv_normalize::PackageName;
2222
pub(crate) use venv::venv;
2323
pub(crate) use version::version;
2424

@@ -89,6 +89,13 @@ pub(super) struct ChangeEvent<T: InstalledMetadata> {
8989
kind: ChangeEventKind,
9090
}
9191

92+
#[derive(Debug)]
93+
pub(super) struct DryRunEvent<T: Display> {
94+
name: PackageName,
95+
version: T,
96+
kind: ChangeEventKind,
97+
}
98+
9299
#[derive(Debug, Clone, Copy, clap::ValueEnum)]
93100
pub(crate) enum VersionFormat {
94101
Text,

crates/uv/src/commands/pip_install.rs

+153-11
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use std::collections::HashSet;
22
use std::fmt::Write;
33
use std::path::Path;
4+
use std::time::Instant;
45

56
use anstream::eprint;
67
use anyhow::{anyhow, Context, Result};
@@ -11,7 +12,8 @@ use tempfile::tempdir_in;
1112
use tracing::debug;
1213

1314
use distribution_types::{
14-
IndexLocations, InstalledMetadata, LocalDist, LocalEditable, Name, Resolution,
15+
DistributionMetadata, IndexLocations, InstalledMetadata, LocalDist, LocalEditable, Name,
16+
Resolution,
1517
};
1618
use install_wheel_rs::linker::LinkMode;
1719
use pep508_rs::{MarkerEnvironment, Requirement};
@@ -39,7 +41,7 @@ use crate::commands::{compile_bytecode, elapsed, ChangeEvent, ChangeEventKind, E
3941
use crate::printer::Printer;
4042
use crate::requirements::{ExtrasSpecification, RequirementsSource, RequirementsSpecification};
4143

42-
use super::Upgrade;
44+
use super::{DryRunEvent, Upgrade};
4345

4446
/// Install packages into the current environment.
4547
#[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)]
@@ -69,6 +71,7 @@ pub(crate) async fn pip_install(
6971
break_system_packages: bool,
7072
native_tls: bool,
7173
cache: Cache,
74+
dry_run: bool,
7275
printer: Printer,
7376
) -> Result<ExitStatus> {
7477
let start = std::time::Instant::now();
@@ -164,6 +167,9 @@ pub(crate) async fn pip_install(
164167
)
165168
.dimmed()
166169
)?;
170+
if dry_run {
171+
writeln!(printer.stderr(), "Would make no changes")?;
172+
}
167173
return Ok(ExitStatus::Success);
168174
}
169175

@@ -320,6 +326,7 @@ pub(crate) async fn pip_install(
320326
&install_dispatch,
321327
&cache,
322328
&venv,
329+
dry_run,
323330
printer,
324331
)
325332
.await?;
@@ -392,7 +399,7 @@ async fn build_editables(
392399
build_dispatch: &BuildDispatch<'_>,
393400
printer: Printer,
394401
) -> Result<Vec<BuiltEditable>, Error> {
395-
let start = std::time::Instant::now();
402+
let start = Instant::now();
396403

397404
let downloader = Downloader::new(cache, tags, client, build_dispatch)
398405
.with_reporter(DownloadReporter::from(printer).with_length(editables.len() as u64));
@@ -558,6 +565,7 @@ async fn install(
558565
build_dispatch: &BuildDispatch<'_>,
559566
cache: &Cache,
560567
venv: &PythonEnvironment,
568+
dry_run: bool,
561569
printer: Printer,
562570
) -> Result<(), Error> {
563571
let start = std::time::Instant::now();
@@ -572,12 +580,7 @@ async fn install(
572580

573581
// Partition into those that should be linked from the cache (`local`), those that need to be
574582
// downloaded (`remote`), and those that should be removed (`extraneous`).
575-
let Plan {
576-
local,
577-
remote,
578-
reinstalls,
579-
extraneous: _,
580-
} = Planner::with_requirements(&requirements)
583+
let plan = Planner::with_requirements(&requirements)
581584
.with_editable_requirements(&editables)
582585
.build(
583586
site_packages,
@@ -590,6 +593,17 @@ async fn install(
590593
)
591594
.context("Failed to determine installation plan")?;
592595

596+
if dry_run {
597+
return report_dry_run(resolution, plan, start, printer);
598+
}
599+
600+
let Plan {
601+
local,
602+
remote,
603+
reinstalls,
604+
extraneous: _,
605+
} = plan;
606+
593607
// Nothing to do.
594608
if remote.is_empty() && local.is_empty() && reinstalls.is_empty() {
595609
let s = if resolution.len() == 1 { "" } else { "s" };
@@ -603,7 +617,6 @@ async fn install(
603617
)
604618
.dimmed()
605619
)?;
606-
607620
return Ok(());
608621
}
609622

@@ -622,7 +635,7 @@ async fn install(
622635
let wheels = if remote.is_empty() {
623636
vec![]
624637
} else {
625-
let start = std::time::Instant::now();
638+
let start = Instant::now();
626639

627640
let downloader = Downloader::new(cache, tags, client, build_dispatch)
628641
.with_reporter(DownloadReporter::from(printer).with_length(remote.len() as u64));
@@ -728,6 +741,135 @@ async fn install(
728741
}
729742
}
730743

744+
#[allow(clippy::items_after_statements)]
745+
fn report_dry_run(
746+
resolution: &Resolution,
747+
plan: Plan,
748+
start: Instant,
749+
printer: Printer,
750+
) -> Result<(), Error> {
751+
let Plan {
752+
local,
753+
remote,
754+
reinstalls,
755+
extraneous: _,
756+
} = plan;
757+
758+
// Nothing to do.
759+
if remote.is_empty() && local.is_empty() && reinstalls.is_empty() {
760+
let s = if resolution.len() == 1 { "" } else { "s" };
761+
writeln!(
762+
printer.stderr(),
763+
"{}",
764+
format!(
765+
"Audited {} in {}",
766+
format!("{} package{}", resolution.len(), s).bold(),
767+
elapsed(start.elapsed())
768+
)
769+
.dimmed()
770+
)?;
771+
writeln!(printer.stderr(), "Would make no changes")?;
772+
return Ok(());
773+
}
774+
775+
// Map any registry-based requirements back to those returned by the resolver.
776+
let remote = remote
777+
.iter()
778+
.map(|dist| {
779+
resolution
780+
.get(&dist.name)
781+
.cloned()
782+
.expect("Resolution should contain all packages")
783+
})
784+
.collect::<Vec<_>>();
785+
786+
// Download, build, and unzip any missing distributions.
787+
let wheels = if remote.is_empty() {
788+
vec![]
789+
} else {
790+
let s = if remote.len() == 1 { "" } else { "s" };
791+
writeln!(
792+
printer.stderr(),
793+
"{}",
794+
format!(
795+
"Would download {}",
796+
format!("{} package{}", remote.len(), s).bold(),
797+
)
798+
.dimmed()
799+
)?;
800+
remote
801+
};
802+
803+
// Remove any existing installations.
804+
if !reinstalls.is_empty() {
805+
let s = if reinstalls.len() == 1 { "" } else { "s" };
806+
writeln!(
807+
printer.stderr(),
808+
"{}",
809+
format!(
810+
"Would uninstall {}",
811+
format!("{} package{}", reinstalls.len(), s).bold(),
812+
)
813+
.dimmed()
814+
)?;
815+
}
816+
817+
// Install the resolved distributions.
818+
let installs = wheels.len() + local.len();
819+
820+
if installs > 0 {
821+
let s = if installs == 1 { "" } else { "s" };
822+
writeln!(
823+
printer.stderr(),
824+
"{}",
825+
format!("Would install {}", format!("{installs} package{s}").bold()).dimmed()
826+
)?;
827+
}
828+
829+
for event in reinstalls
830+
.into_iter()
831+
.map(|distribution| DryRunEvent {
832+
name: distribution.name().clone(),
833+
version: distribution.installed_version().to_string(),
834+
kind: ChangeEventKind::Removed,
835+
})
836+
.chain(wheels.into_iter().map(|distribution| DryRunEvent {
837+
name: distribution.name().clone(),
838+
version: distribution.version_or_url().to_string(),
839+
kind: ChangeEventKind::Added,
840+
}))
841+
.chain(local.into_iter().map(|distribution| DryRunEvent {
842+
name: distribution.name().clone(),
843+
version: distribution.installed_version().to_string(),
844+
kind: ChangeEventKind::Added,
845+
}))
846+
.sorted_unstable_by(|a, b| a.name.cmp(&b.name).then_with(|| a.kind.cmp(&b.kind)))
847+
{
848+
match event.kind {
849+
ChangeEventKind::Added => {
850+
writeln!(
851+
printer.stderr(),
852+
" {} {}{}",
853+
"+".green(),
854+
event.name.as_ref().bold(),
855+
event.version.dimmed()
856+
)?;
857+
}
858+
ChangeEventKind::Removed => {
859+
writeln!(
860+
printer.stderr(),
861+
" {} {}{}",
862+
"-".red(),
863+
event.name.as_ref().bold(),
864+
event.version.dimmed()
865+
)?;
866+
}
867+
}
868+
}
869+
870+
Ok(())
871+
}
872+
731873
// TODO(konstin): Also check the cache whether any cached or installed dist is already known to
732874
// have been yanked, we currently don't show this message on the second run anymore
733875
for dist in &remote {

crates/uv/src/main.rs

+6
Original file line numberDiff line numberDiff line change
@@ -891,6 +891,11 @@ struct PipInstallArgs {
891891
/// format (e.g., `2006-12-02`).
892892
#[arg(long, value_parser = date_or_datetime)]
893893
exclude_newer: Option<DateTime<Utc>>,
894+
895+
/// Perform a dry run, i.e., don't actually install anything but resolve the dependencies and
896+
/// print the resulting plan.
897+
#[clap(long)]
898+
dry_run: bool,
894899
}
895900

896901
#[derive(Args)]
@@ -1586,6 +1591,7 @@ async fn run() -> Result<ExitStatus> {
15861591
args.break_system_packages,
15871592
cli.native_tls,
15881593
cache,
1594+
args.dry_run,
15891595
printer,
15901596
)
15911597
.await

0 commit comments

Comments
 (0)