mirror of https://github.com/astral-sh/uv
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:
parent
f3495d7cad
commit
15f6f9f448
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue