diff --git a/crates/uv/src/commands/mod.rs b/crates/uv/src/commands/mod.rs index 3330cc7e6a2d..97a2a3853491 100644 --- a/crates/uv/src/commands/mod.rs +++ b/crates/uv/src/commands/mod.rs @@ -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 { kind: ChangeEventKind, } +#[derive(Debug)] +pub(super) struct DryRunEvent { + name: PackageName, + version: T, + kind: ChangeEventKind, +} + #[derive(Debug, Clone, Copy, clap::ValueEnum)] pub(crate) enum VersionFormat { Text, diff --git a/crates/uv/src/commands/pip_install.rs b/crates/uv/src/commands/pip_install.rs index e8e7a9f7a536..8b285f830038 100644 --- a/crates/uv/src/commands/pip_install.rs +++ b/crates/uv/src/commands/pip_install.rs @@ -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 { 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, 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::>(); + + // 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 { diff --git a/crates/uv/src/main.rs b/crates/uv/src/main.rs index 9132e7c5ed78..9cfb288e7a5a 100644 --- a/crates/uv/src/main.rs +++ b/crates/uv/src/main.rs @@ -891,6 +891,11 @@ struct PipInstallArgs { /// format (e.g., `2006-12-02`). #[arg(long, value_parser = date_or_datetime)] exclude_newer: Option>, + + /// 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 { args.break_system_packages, cli.native_tls, cache, + args.dry_run, printer, ) .await diff --git a/crates/uv/tests/pip_install.rs b/crates/uv/tests/pip_install.rs index 7a7bb8cbf3e5..fa1e2ca25c06 100644 --- a/crates/uv/tests/pip_install.rs +++ b/crates/uv/tests/pip_install.rs @@ -2414,3 +2414,272 @@ fn utf8_to_utf16_with_bom_be(s: &str) -> Vec { byteorder::BigEndian::write_u16_into(&u16s, &mut u8s); u8s } + +#[test] +fn dry_run_install() -> std::result::Result<(), Box> { + 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> { + 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> { + 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> { + 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> { + 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> { + 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(()) +}