From 66df255a9cd56525ee95867623f62b16644a322f Mon Sep 17 00:00:00 2001 From: Christopher Tee Date: Mon, 14 Apr 2025 09:08:25 -0400 Subject: [PATCH] Enable `--dry-run` with `--locked` / `--frozen` for `uv sync` (#12778) ## Summary Closes #12687. ## Test Plan Added the corresponding integration tests for: - `uv sync --dry-run --locked` - [x] Preview lock changes - [x] Errors if lockfile is out-of-date - `uv sync --dry-run --frozen` - [x] Preview lock changes --------- Co-authored-by: Charlie Marsh --- crates/uv-cli/src/lib.rs | 2 +- crates/uv/src/commands/project/lock.rs | 2 +- crates/uv/src/commands/project/mod.rs | 2 +- crates/uv/src/commands/project/sync.rs | 138 +++++++++++++++---------- crates/uv/tests/it/sync.rs | 102 ++++++++++++++++++ 5 files changed, 188 insertions(+), 58 deletions(-) diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index d087ebe9f..08f1b5ee2 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -3229,7 +3229,7 @@ pub struct SyncArgs { /// /// In dry-run mode, uv will resolve the project's dependencies and report on the resulting /// changes to both the lockfile and the project environment, but will not modify either. - #[arg(long, conflicts_with = "locked", conflicts_with = "frozen")] + #[arg(long)] pub dry_run: bool, #[command(flatten)] diff --git a/crates/uv/src/commands/project/lock.rs b/crates/uv/src/commands/project/lock.rs index 3675ab204..d311b5398 100644 --- a/crates/uv/src/commands/project/lock.rs +++ b/crates/uv/src/commands/project/lock.rs @@ -333,7 +333,7 @@ impl<'env> LockOperation<'env> { // If the lockfile changed, return an error. if matches!(result, LockResult::Changed(_, _)) { - return Err(ProjectError::LockMismatch); + return Err(ProjectError::LockMismatch(Box::new(result.into_lock()))); } Ok(result) diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index ed562f36f..ee9266a12 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -72,7 +72,7 @@ pub(crate) mod tree; #[derive(thiserror::Error, Debug)] pub(crate) enum ProjectError { #[error("The lockfile at `uv.lock` needs to be updated, but `--locked` was provided. To update the lockfile, run `uv lock`.")] - LockMismatch, + LockMismatch(Box), #[error( "Unable to find lockfile at `uv.lock`. To create a lockfile, run `uv lock` or `uv sync`." diff --git a/crates/uv/src/commands/project/sync.rs b/crates/uv/src/commands/project/sync.rs index db98dfd06..3bf88556c 100644 --- a/crates/uv/src/commands/project/sync.rs +++ b/crates/uv/src/commands/project/sync.rs @@ -24,7 +24,7 @@ use uv_normalize::{DefaultGroups, PackageName}; use uv_pep508::{MarkerTree, VersionOrUrl}; use uv_pypi_types::{ParsedArchiveUrl, ParsedGitUrl, ParsedUrl}; use uv_python::{PythonDownloads, PythonEnvironment, PythonPreference, PythonRequest}; -use uv_resolver::{FlatIndex, Installable}; +use uv_resolver::{FlatIndex, Installable, Lock}; use uv_scripts::{Pep723ItemRef, Pep723Script}; use uv_settings::PythonInstallMirrors; use uv_types::{BuildIsolation, HashStrategy}; @@ -327,7 +327,7 @@ pub(crate) async fn sync( SyncTarget::Script(script) => LockTarget::from(script), }; - let lock = match LockOperation::new( + let outcome = match LockOperation::new( mode, &settings.resolver, &network_settings, @@ -379,68 +379,24 @@ pub(crate) async fn sync( } } } - result.into_lock() + Outcome::Success(result.into_lock()) } Err(ProjectError::Operation(err)) => { return diagnostics::OperationDiagnostic::native_tls(network_settings.native_tls) .report(err) .map_or(Ok(ExitStatus::Failure), |err| Err(err.into())) } + Err(ProjectError::LockMismatch(lock)) if dry_run.enabled() => { + // The lockfile is mismatched, but we're in dry-run mode. We should proceed with the + // sync operation, but exit with a non-zero status. + Outcome::LockMismatch(lock) + } Err(err) => return Err(err.into()), }; // Identify the installation target. - let sync_target = match &target { - SyncTarget::Project(project) => { - match &project { - VirtualProject::Project(project) => { - if all_packages { - InstallTarget::Workspace { - workspace: project.workspace(), - lock: &lock, - } - } else if let Some(package) = package.as_ref() { - InstallTarget::Project { - workspace: project.workspace(), - name: package, - lock: &lock, - } - } else { - // By default, install the root package. - InstallTarget::Project { - workspace: project.workspace(), - name: project.project_name(), - lock: &lock, - } - } - } - VirtualProject::NonProject(workspace) => { - if all_packages { - InstallTarget::NonProjectWorkspace { - workspace, - lock: &lock, - } - } else if let Some(package) = package.as_ref() { - InstallTarget::Project { - workspace, - name: package, - lock: &lock, - } - } else { - // By default, install the entire workspace. - InstallTarget::NonProjectWorkspace { - workspace, - lock: &lock, - } - } - } - } - } - SyncTarget::Script(script) => InstallTarget::Script { - script, - lock: &lock, - }, - }; + let sync_target = + identify_installation_target(&target, outcome.lock(), all_packages, package.as_ref()); let state = state.fork(); @@ -475,7 +431,79 @@ pub(crate) async fn sync( Err(err) => return Err(err.into()), } - Ok(ExitStatus::Success) + match outcome { + Outcome::Success(..) => Ok(ExitStatus::Success), + Outcome::LockMismatch(lock) => Err(ProjectError::LockMismatch(lock).into()), + } +} + +/// The outcome of a `lock` operation within a `sync` operation. +#[derive(Debug)] +enum Outcome { + /// The `lock` operation was successful. + Success(Lock), + /// The `lock` operation successfully resolved, but failed due to a mismatch (e.g., with `--locked`). + LockMismatch(Box), +} + +impl Outcome { + /// Return the [`Lock`] associated with this outcome. + fn lock(&self) -> &Lock { + match self { + Self::Success(lock) => lock, + Self::LockMismatch(lock) => lock, + } + } +} + +fn identify_installation_target<'a>( + target: &'a SyncTarget, + lock: &'a Lock, + all_packages: bool, + package: Option<&'a PackageName>, +) -> InstallTarget<'a> { + match &target { + SyncTarget::Project(project) => { + match &project { + VirtualProject::Project(project) => { + if all_packages { + InstallTarget::Workspace { + workspace: project.workspace(), + lock, + } + } else if let Some(package) = package { + InstallTarget::Project { + workspace: project.workspace(), + name: package, + lock, + } + } else { + // By default, install the root package. + InstallTarget::Project { + workspace: project.workspace(), + name: project.project_name(), + lock, + } + } + } + VirtualProject::NonProject(workspace) => { + if all_packages { + InstallTarget::NonProjectWorkspace { workspace, lock } + } else if let Some(package) = package { + InstallTarget::Project { + workspace, + name: package, + lock, + } + } else { + // By default, install the entire workspace. + InstallTarget::NonProjectWorkspace { workspace, lock } + } + } + } + } + SyncTarget::Script(script) => InstallTarget::Script { script, lock }, + } } #[derive(Debug, Clone)] diff --git a/crates/uv/tests/it/sync.rs b/crates/uv/tests/it/sync.rs index fb5b3fe04..7de30d035 100644 --- a/crates/uv/tests/it/sync.rs +++ b/crates/uv/tests/it/sync.rs @@ -7896,6 +7896,108 @@ fn sync_dry_run() -> Result<()> { Ok(()) } +#[test] +fn sync_dry_run_and_locked() -> 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.7.0"] + "#, + )?; + + // Lock the initial requirements. + context.lock().assert().success(); + + let existing = context.read("uv.lock"); + + // Update the requirements. + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["iniconfig"] + "#, + )?; + + // Running with `--locked` and `--dry-run` should error. + uv_snapshot!(context.filters(), context.sync().arg("--locked").arg("--dry-run"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + Discovered existing environment at: .venv + Resolved 2 packages in [TIME] + Would download 1 package + Would install 1 package + + iniconfig==2.0.0 + error: The lockfile at `uv.lock` needs to be updated, but `--locked` was provided. To update the lockfile, run `uv lock`. + "); + + let updated = context.read("uv.lock"); + + // And the lockfile should be unchanged. + assert_eq!(existing, updated); + + Ok(()) +} + +#[test] +fn sync_dry_run_and_frozen() -> 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.7.0"] + "#, + )?; + + // Lock the initial requirements. + context.lock().assert().success(); + + // Update the requirements. + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["iniconfig"] + "#, + )?; + + // Running with `--frozen` with `--dry-run` should preview dependencies to be installed. + uv_snapshot!(context.filters(), context.sync().arg("--frozen").arg("--dry-run"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Discovered existing environment at: .venv + Found up-to-date lockfile at: uv.lock + Would download 3 packages + Would install 3 packages + + anyio==3.7.0 + + idna==3.6 + + sniffio==1.3.1 + "); + + Ok(()) +} + #[test] fn sync_script() -> Result<()> { let context = TestContext::new_with_versions(&["3.8", "3.12"]);