diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 48b874ca9..81d350aa0 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -2903,6 +2903,13 @@ pub struct LockArgs { #[arg(long, env = EnvVars::UV_FROZEN, value_parser = clap::builder::BoolishValueParser::new(), conflicts_with = "locked")] pub frozen: bool, + /// Perform a dry run, without writing the lockfile. + /// + /// In dry-run mode, uv will resolve the project's dependencies and report on the resulting + /// changes, but will not write the lockfile to disk. + #[arg(long, conflicts_with = "frozen", conflicts_with = "locked")] + pub dry_run: bool, + #[command(flatten)] pub resolver: ResolverArgs, diff --git a/crates/uv/src/commands/project/add.rs b/crates/uv/src/commands/project/add.rs index a25f09ab6..66be4b0b3 100644 --- a/crates/uv/src/commands/project/add.rs +++ b/crates/uv/src/commands/project/add.rs @@ -661,6 +661,7 @@ async fn lock_and_sync( let mut lock = project::lock::do_safe_lock( locked, frozen, + false, project.workspace(), venv.interpreter(), settings.into(), @@ -775,6 +776,7 @@ async fn lock_and_sync( lock = project::lock::do_safe_lock( locked, frozen, + false, project.workspace(), venv.interpreter(), settings.into(), diff --git a/crates/uv/src/commands/project/export.rs b/crates/uv/src/commands/project/export.rs index 4cbccb67e..82f3b9c33 100644 --- a/crates/uv/src/commands/project/export.rs +++ b/crates/uv/src/commands/project/export.rs @@ -95,6 +95,7 @@ pub(crate) async fn export( let lock = match do_safe_lock( locked, frozen, + false, project.workspace(), &interpreter, settings.as_ref(), diff --git a/crates/uv/src/commands/project/lock.rs b/crates/uv/src/commands/project/lock.rs index c80bd6726..5cc030231 100644 --- a/crates/uv/src/commands/project/lock.rs +++ b/crates/uv/src/commands/project/lock.rs @@ -70,10 +70,12 @@ impl LockResult { } /// Resolve the project requirements into a lockfile. +#[allow(clippy::fn_params_excessive_bools)] pub(crate) async fn lock( project_dir: &Path, locked: bool, frozen: bool, + dry_run: bool, python: Option, settings: ResolverSettings, python_preference: PythonPreference, @@ -108,6 +110,7 @@ pub(crate) async fn lock( match do_safe_lock( locked, frozen, + dry_run, &workspace, &interpreter, settings.as_ref(), @@ -123,9 +126,25 @@ pub(crate) async fn lock( .await { Ok(lock) => { - if let LockResult::Changed(Some(previous), lock) = &lock { - report_upgrades(previous, lock, printer)?; + if dry_run { + let changed = if let LockResult::Changed(previous, lock) = &lock { + report_upgrades(previous.as_ref(), lock, printer, dry_run)? + } else { + false + }; + if !changed { + writeln!( + printer.stderr(), + "{}", + "No lockfile changes detected".bold() + )?; + } + } else { + if let LockResult::Changed(Some(previous), lock) = &lock { + report_upgrades(Some(previous), lock, printer, dry_run)?; + } } + Ok(ExitStatus::Success) } Err(ProjectError::Operation(pip::operations::Error::Resolve( @@ -152,9 +171,11 @@ pub(crate) async fn lock( } /// Perform a lock operation, respecting the `--locked` and `--frozen` parameters. +#[allow(clippy::fn_params_excessive_bools)] pub(super) async fn do_safe_lock( locked: bool, frozen: bool, + dry_run: bool, workspace: &Workspace, interpreter: &Interpreter, settings: ResolverSettingsRef<'_>, @@ -224,8 +245,10 @@ pub(super) async fn do_safe_lock( .await?; // If the lockfile changed, write it to disk. - if let LockResult::Changed(_, lock) = &result { - commit(lock, workspace).await?; + if !dry_run { + if let LockResult::Changed(_, lock) = &result { + commit(lock, workspace).await?; + } } Ok(result) @@ -916,17 +939,28 @@ pub(crate) async fn read(workspace: &Workspace) -> Result, ProjectE } /// Reports on the versions that were upgraded in the new lockfile. -fn report_upgrades(existing_lock: &Lock, new_lock: &Lock, printer: Printer) -> anyhow::Result<()> { +/// +/// Returns `true` if any upgrades were reported. +fn report_upgrades( + existing_lock: Option<&Lock>, + new_lock: &Lock, + printer: Printer, + dry_run: bool, +) -> anyhow::Result { let existing_packages: FxHashMap<&PackageName, BTreeSet<&Version>> = - existing_lock.packages().iter().fold( - FxHashMap::with_capacity_and_hasher(existing_lock.packages().len(), FxBuildHasher), - |mut acc, package| { - acc.entry(package.name()) - .or_default() - .insert(package.version()); - acc - }, - ); + if let Some(existing_lock) = existing_lock { + existing_lock.packages().iter().fold( + FxHashMap::with_capacity_and_hasher(existing_lock.packages().len(), FxBuildHasher), + |mut acc, package| { + acc.entry(package.name()) + .or_default() + .insert(package.version()); + acc + }, + ) + } else { + FxHashMap::default() + }; let new_distributions: FxHashMap<&PackageName, BTreeSet<&Version>> = new_lock.packages().iter().fold( @@ -939,11 +973,13 @@ fn report_upgrades(existing_lock: &Lock, new_lock: &Lock, printer: Printer) -> a }, ); + let mut updated = false; for name in existing_packages .keys() .chain(new_distributions.keys()) .collect::>() { + updated = true; match (existing_packages.get(name), new_distributions.get(name)) { (Some(existing_versions), Some(new_versions)) => { if existing_versions != new_versions { @@ -960,7 +996,7 @@ fn report_upgrades(existing_lock: &Lock, new_lock: &Lock, printer: Printer) -> a writeln!( printer.stderr(), "{} {name} {existing_versions} -> {new_versions}", - "Updated".green().bold() + if dry_run { "Update" } else { "Updated" }.green().bold() )?; } } @@ -973,7 +1009,7 @@ fn report_upgrades(existing_lock: &Lock, new_lock: &Lock, printer: Printer) -> a writeln!( printer.stderr(), "{} {name} {existing_versions}", - "Removed".red().bold() + if dry_run { "Remove" } else { "Removed" }.red().bold() )?; } (None, Some(new_versions)) => { @@ -985,7 +1021,7 @@ fn report_upgrades(existing_lock: &Lock, new_lock: &Lock, printer: Printer) -> a writeln!( printer.stderr(), "{} {name} {new_versions}", - "Added".green().bold() + if dry_run { "Add" } else { "Added" }.green().bold() )?; } (None, None) => { @@ -994,5 +1030,5 @@ fn report_upgrades(existing_lock: &Lock, new_lock: &Lock, printer: Printer) -> a } } - Ok(()) + Ok(updated) } diff --git a/crates/uv/src/commands/project/remove.rs b/crates/uv/src/commands/project/remove.rs index d5671388e..4d6bf27ac 100644 --- a/crates/uv/src/commands/project/remove.rs +++ b/crates/uv/src/commands/project/remove.rs @@ -175,6 +175,7 @@ pub(crate) async fn remove( let lock = project::lock::do_safe_lock( locked, frozen, + false, project.workspace(), venv.interpreter(), settings.as_ref().into(), diff --git a/crates/uv/src/commands/project/run.rs b/crates/uv/src/commands/project/run.rs index ed94cc20a..f3a6063f5 100644 --- a/crates/uv/src/commands/project/run.rs +++ b/crates/uv/src/commands/project/run.rs @@ -543,6 +543,7 @@ pub(crate) async fn run( let result = match project::lock::do_safe_lock( locked, frozen, + false, project.workspace(), venv.interpreter(), settings.as_ref().into(), diff --git a/crates/uv/src/commands/project/sync.rs b/crates/uv/src/commands/project/sync.rs index 52bee272b..c6f08af06 100644 --- a/crates/uv/src/commands/project/sync.rs +++ b/crates/uv/src/commands/project/sync.rs @@ -112,6 +112,7 @@ pub(crate) async fn sync( let lock = match do_safe_lock( locked, frozen, + false, target.workspace(), venv.interpreter(), settings.as_ref().into(), diff --git a/crates/uv/src/commands/project/tree.rs b/crates/uv/src/commands/project/tree.rs index d5cae81d7..6078ae8a9 100644 --- a/crates/uv/src/commands/project/tree.rs +++ b/crates/uv/src/commands/project/tree.rs @@ -67,6 +67,7 @@ pub(crate) async fn tree( let lock = project::lock::do_safe_lock( locked, frozen, + false, &workspace, &interpreter, settings.as_ref(), diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index 21e2659fd..e29db5545 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -1352,6 +1352,7 @@ async fn run_project( project_dir, args.locked, args.frozen, + args.dry_run, args.python, args.settings, globals.python_preference, diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index c9c4fc5a9..d9bde2a2a 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -767,6 +767,7 @@ impl SyncSettings { pub(crate) struct LockSettings { pub(crate) locked: bool, pub(crate) frozen: bool, + pub(crate) dry_run: bool, pub(crate) python: Option, pub(crate) refresh: Refresh, pub(crate) settings: ResolverSettings, @@ -779,6 +780,7 @@ impl LockSettings { let LockArgs { locked, frozen, + dry_run, resolver, build, refresh, @@ -788,6 +790,7 @@ impl LockSettings { Self { locked, frozen, + dry_run, python: python.and_then(Maybe::into_option), refresh: Refresh::from(refresh), settings: ResolverSettings::combine(resolver_options(resolver, build), filesystem), diff --git a/crates/uv/tests/it/lock.rs b/crates/uv/tests/it/lock.rs index 9c0dd8746..2ac9bd5b6 100644 --- a/crates/uv/tests/it/lock.rs +++ b/crates/uv/tests/it/lock.rs @@ -16078,3 +16078,132 @@ fn lock_multiple_sources_extra() -> Result<()> { Ok(()) } + +#[test] +fn lock_dry_run() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [ + "anyio <3 ; python_version == '3.12'", + "anyio >3, <4 ; python_version > '3.12'", + "matplotlib==3.1.0" + ] + "#, + )?; + + uv_snapshot!(context.filters(), context.lock(), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 12 packages in [TIME] + "###); + + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [ + "requests==2.25.1", + "matplotlib==3.5.0" + ] + "#, + )?; + + uv_snapshot!(context.filters(), context.lock().arg("--dry-run"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 19 packages in [TIME] + Remove anyio v2.2.0, v3.7.1 + Add certifi v2024.2.2 + Add chardet v4.0.0 + Add fonttools v4.50.0 + Update idna v3.6 -> v2.10 + Update matplotlib v3.1.0 -> v3.5.0 + Add packaging v24.0 + Add pillow v10.2.0 + Add requests v2.25.1 + Add setuptools v69.2.0 + Add setuptools-scm v8.0.4 + Remove sniffio v1.3.1 + Add typing-extensions v4.10.0 + Add urllib3 v1.26.18 + "###); + + Ok(()) +} + +#[test] +fn lock_dry_run_noop() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [ + "anyio <3 ; python_version == '3.12'", + "anyio >3, <4 ; python_version > '3.12'", + ] + "#, + )?; + + uv_snapshot!(context.filters(), context.lock().arg("--dry-run"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 5 packages in [TIME] + Add anyio v2.2.0, v3.7.1 + Add idna v3.6 + Add project v0.1.0 + Add sniffio v1.3.1 + "###); + + uv_snapshot!(context.filters(), context.lock(), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 5 packages in [TIME] + "###); + + uv_snapshot!(context.filters(), context.lock().arg("--dry-run"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 5 packages in [TIME] + No lockfile changes detected + "###); + + uv_snapshot!(context.filters(), context.lock().arg("--dry-run").arg("-U"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 5 packages in [TIME] + "###); + + Ok(()) +} diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 4ea817bee..a5cdcd5fc 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -1706,6 +1706,10 @@ uv lock [OPTIONS]

See --project to only change the project root directory.

+
--dry-run

Perform a dry run, without writing the lockfile.

+ +

In dry-run mode, uv will resolve the project’s dependencies and report on the resulting changes, but will not write the lockfile to disk.

+
--exclude-newer exclude-newer

Limit candidate packages to those that were uploaded prior to the given date.

Accepts both RFC 3339 timestamps (e.g., 2006-12-02T02:07:43Z) and local dates in the same format (e.g., 2006-12-02) in your system’s configured time zone.