mirror of https://github.com/astral-sh/uv
Enable `--dry-run` with `--locked` / `--frozen` for `uv sync` (#12778)
## Summary Closes #12687. <!-- What's the purpose of the change? What does it do, and why? --> ## Test Plan <!-- How was it tested? --> 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 <charlie.r.marsh@gmail.com>
This commit is contained in:
parent
291a13c276
commit
66df255a9c
|
|
@ -3229,7 +3229,7 @@ pub struct SyncArgs {
|
||||||
///
|
///
|
||||||
/// In dry-run mode, uv will resolve the project's dependencies and report on the resulting
|
/// 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.
|
/// 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,
|
pub dry_run: bool,
|
||||||
|
|
||||||
#[command(flatten)]
|
#[command(flatten)]
|
||||||
|
|
|
||||||
|
|
@ -333,7 +333,7 @@ impl<'env> LockOperation<'env> {
|
||||||
|
|
||||||
// If the lockfile changed, return an error.
|
// If the lockfile changed, return an error.
|
||||||
if matches!(result, LockResult::Changed(_, _)) {
|
if matches!(result, LockResult::Changed(_, _)) {
|
||||||
return Err(ProjectError::LockMismatch);
|
return Err(ProjectError::LockMismatch(Box::new(result.into_lock())));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(result)
|
Ok(result)
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,7 @@ pub(crate) mod tree;
|
||||||
#[derive(thiserror::Error, Debug)]
|
#[derive(thiserror::Error, Debug)]
|
||||||
pub(crate) enum ProjectError {
|
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`.")]
|
#[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(
|
#[error(
|
||||||
"Unable to find lockfile at `uv.lock`. To create a lockfile, run `uv lock` or `uv sync`."
|
"Unable to find lockfile at `uv.lock`. To create a lockfile, run `uv lock` or `uv sync`."
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ use uv_normalize::{DefaultGroups, PackageName};
|
||||||
use uv_pep508::{MarkerTree, VersionOrUrl};
|
use uv_pep508::{MarkerTree, VersionOrUrl};
|
||||||
use uv_pypi_types::{ParsedArchiveUrl, ParsedGitUrl, ParsedUrl};
|
use uv_pypi_types::{ParsedArchiveUrl, ParsedGitUrl, ParsedUrl};
|
||||||
use uv_python::{PythonDownloads, PythonEnvironment, PythonPreference, PythonRequest};
|
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_scripts::{Pep723ItemRef, Pep723Script};
|
||||||
use uv_settings::PythonInstallMirrors;
|
use uv_settings::PythonInstallMirrors;
|
||||||
use uv_types::{BuildIsolation, HashStrategy};
|
use uv_types::{BuildIsolation, HashStrategy};
|
||||||
|
|
@ -327,7 +327,7 @@ pub(crate) async fn sync(
|
||||||
SyncTarget::Script(script) => LockTarget::from(script),
|
SyncTarget::Script(script) => LockTarget::from(script),
|
||||||
};
|
};
|
||||||
|
|
||||||
let lock = match LockOperation::new(
|
let outcome = match LockOperation::new(
|
||||||
mode,
|
mode,
|
||||||
&settings.resolver,
|
&settings.resolver,
|
||||||
&network_settings,
|
&network_settings,
|
||||||
|
|
@ -379,68 +379,24 @@ pub(crate) async fn sync(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
result.into_lock()
|
Outcome::Success(result.into_lock())
|
||||||
}
|
}
|
||||||
Err(ProjectError::Operation(err)) => {
|
Err(ProjectError::Operation(err)) => {
|
||||||
return diagnostics::OperationDiagnostic::native_tls(network_settings.native_tls)
|
return diagnostics::OperationDiagnostic::native_tls(network_settings.native_tls)
|
||||||
.report(err)
|
.report(err)
|
||||||
.map_or(Ok(ExitStatus::Failure), |err| Err(err.into()))
|
.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()),
|
Err(err) => return Err(err.into()),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Identify the installation target.
|
// Identify the installation target.
|
||||||
let sync_target = match &target {
|
let sync_target =
|
||||||
SyncTarget::Project(project) => {
|
identify_installation_target(&target, outcome.lock(), all_packages, package.as_ref());
|
||||||
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 state = state.fork();
|
let state = state.fork();
|
||||||
|
|
||||||
|
|
@ -475,7 +431,79 @@ pub(crate) async fn sync(
|
||||||
Err(err) => return Err(err.into()),
|
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)]
|
#[derive(Debug, Clone)]
|
||||||
|
|
|
||||||
|
|
@ -7896,6 +7896,108 @@ fn sync_dry_run() -> Result<()> {
|
||||||
Ok(())
|
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]
|
#[test]
|
||||||
fn sync_script() -> Result<()> {
|
fn sync_script() -> Result<()> {
|
||||||
let context = TestContext::new_with_versions(&["3.8", "3.12"]);
|
let context = TestContext::new_with_versions(&["3.8", "3.12"]);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue