Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/uv-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down
2 changes: 1 addition & 1 deletion crates/uv/src/commands/project/lock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion crates/uv/src/commands/project/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Lock>),

#[error(
"Unable to find lockfile at `uv.lock`. To create a lockfile, run `uv lock` or `uv sync`."
Expand Down
138 changes: 83 additions & 55 deletions crates/uv/src/commands/project/sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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<Lock>),
}

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)]
Expand Down
102 changes: 102 additions & 0 deletions crates/uv/tests/it/sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Comment on lines +7938 to +7942
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With --locked --dry-run, we perform an environment check first before erroring on the outdated lockfile.

");

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"]);
Expand Down
Loading