Add --locked and --frozen to uv run CLI (#5196)

## Summary

You can now use `uv run --locked` to assert that the lockfile doesn't
change, or `uv run --frozen` to run without attempting to update the
lockfile at all.

Closes https://github.com/astral-sh/uv/issues/5185.
This commit is contained in:
Charlie Marsh 2024-07-18 14:55:17 -04:00 committed by GitHub
parent 6a6e3b464f
commit dfe2faa71e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 301 additions and 101 deletions

View file

@ -1784,6 +1784,14 @@ impl ExternalCommand {
#[derive(Args)]
#[allow(clippy::struct_excessive_bools)]
pub struct RunArgs {
/// Assert that the `uv.lock` will remain unchanged.
#[arg(long, conflicts_with = "frozen")]
pub locked: bool,
/// Install without updating the `uv.lock` file.
#[arg(long, conflicts_with = "locked")]
pub frozen: bool,
/// Include optional dependencies from the extra group name; may be provided more than once.
///
/// Only applies to `pyproject.toml`, `setup.py`, and `setup.cfg` sources.
@ -1909,6 +1917,14 @@ pub struct SyncArgs {
#[derive(Args)]
#[allow(clippy::struct_excessive_bools)]
pub struct LockArgs {
/// Assert that the `uv.lock` will remain unchanged.
#[arg(long, conflicts_with = "frozen")]
pub locked: bool,
/// Assert that a `uv.lock` exists, without updating it.
#[arg(long, conflicts_with = "locked")]
pub frozen: bool,
#[command(flatten)]
pub resolver: ResolverArgs,

View file

@ -30,6 +30,8 @@ use crate::settings::{ResolverSettings, ResolverSettingsRef};
/// Resolve the project requirements into a lockfile.
pub(crate) async fn lock(
locked: bool,
frozen: bool,
python: Option<String>,
settings: ResolverSettings,
preview: PreviewMode,
@ -62,14 +64,12 @@ pub(crate) async fn lock(
.await?
.into_interpreter();
// Read the existing lockfile.
let existing = read(&workspace).await?;
// Perform the lock operation.
match do_lock(
match do_safe_lock(
locked,
frozen,
&workspace,
&interpreter,
existing.as_ref(),
settings.as_ref(),
&SharedState::default(),
preview,
@ -81,12 +81,7 @@ pub(crate) async fn lock(
)
.await
{
Ok(lock) => {
if !existing.is_some_and(|existing| existing == lock) {
commit(&lock, &workspace).await?;
}
Ok(ExitStatus::Success)
}
Ok(_) => Ok(ExitStatus::Success),
Err(ProjectError::Operation(pip::operations::Error::Resolve(
uv_resolver::ResolveError::NoSolution(err),
))) => {
@ -98,6 +93,82 @@ pub(crate) async fn lock(
}
}
/// Perform a lock operation, respecting the `--locked` and `--frozen` parameters.
pub(super) async fn do_safe_lock(
locked: bool,
frozen: bool,
workspace: &Workspace,
interpreter: &Interpreter,
settings: ResolverSettingsRef<'_>,
state: &SharedState,
preview: PreviewMode,
connectivity: Connectivity,
concurrency: Concurrency,
native_tls: bool,
cache: &Cache,
printer: Printer,
) -> Result<Lock, ProjectError> {
if frozen {
// Read the existing lockfile, but don't attempt to lock the project.
read(workspace)
.await?
.ok_or_else(|| ProjectError::MissingLockfile)
} else if locked {
// Read the existing lockfile.
let existing = read(workspace)
.await?
.ok_or_else(|| ProjectError::MissingLockfile)?;
// Perform the lock operation, but don't write the lockfile to disk.
let lock = do_lock(
workspace,
interpreter,
Some(&existing),
settings,
state,
preview,
connectivity,
concurrency,
native_tls,
cache,
printer,
)
.await?;
// If the locks disagree, return an error.
if lock != existing {
return Err(ProjectError::LockMismatch);
}
Ok(lock)
} else {
// Read the existing lockfile.
let existing = read(workspace).await?;
// Perform the lock operation.
let lock = do_lock(
workspace,
interpreter,
existing.as_ref(),
settings,
state,
preview,
connectivity,
concurrency,
native_tls,
cache,
printer,
)
.await?;
if !existing.is_some_and(|existing| existing == lock) {
commit(&lock, workspace).await?;
}
Ok(lock)
}
}
/// Lock the project requirements into a lockfile.
pub(super) async fn do_lock(
workspace: &Workspace,

View file

@ -28,15 +28,19 @@ use uv_warnings::warn_user_once;
use crate::commands::pip::operations::Modifications;
use crate::commands::project::environment::CachedEnvironment;
use crate::commands::project::ProjectError;
use crate::commands::reporters::PythonDownloadReporter;
use crate::commands::{project, ExitStatus, SharedState};
use crate::commands::{pip, project, ExitStatus, SharedState};
use crate::printer::Printer;
use crate::settings::ResolverInstallerSettings;
/// Run a command.
#[allow(clippy::fn_params_excessive_bools)]
pub(crate) async fn run(
command: ExternalCommand,
requirements: Vec<RequirementsSource>,
locked: bool,
frozen: bool,
package: Option<PackageName>,
extras: ExtrasSpecification,
dev: bool,
@ -180,14 +184,11 @@ pub(crate) async fn run(
)
.await?;
// Read the existing lockfile.
let existing = project::lock::read(project.workspace()).await?;
// Lock and sync the environment.
let lock = project::lock::do_lock(
let lock = match project::lock::do_safe_lock(
locked,
frozen,
project.workspace(),
venv.interpreter(),
existing.as_ref(),
settings.as_ref().into(),
&state,
preview,
@ -197,11 +198,18 @@ pub(crate) async fn run(
cache,
printer,
)
.await?;
if !existing.is_some_and(|existing| existing == lock) {
project::lock::commit(&lock, project.workspace()).await?;
}
.await
{
Ok(lock) => lock,
Err(ProjectError::Operation(pip::operations::Error::Resolve(
uv_resolver::ResolveError::NoSolution(err),
))) => {
let report = miette::Report::msg(format!("{err}")).context(err.header());
anstream::eprint!("{report:?}");
return Ok(ExitStatus::Failure);
}
Err(err) => return Err(err.into()),
};
project::sync::do_sync(
&project,

View file

@ -15,7 +15,7 @@ use uv_types::{BuildIsolation, HashStrategy};
use uv_warnings::warn_user_once;
use crate::commands::pip::operations::Modifications;
use crate::commands::project::lock::do_lock;
use crate::commands::project::lock::do_safe_lock;
use crate::commands::project::{ProjectError, SharedState};
use crate::commands::{pip, project, ExitStatus};
use crate::printer::Printer;
@ -63,83 +63,31 @@ pub(crate) async fn sync(
// Initialize any shared state.
let state = SharedState::default();
let lock = if frozen {
// Read the existing lockfile.
project::lock::read(project.workspace())
.await?
.ok_or_else(|| ProjectError::MissingLockfile)?
} else if locked {
// Read the existing lockfile.
let existing = project::lock::read(project.workspace())
.await?
.ok_or_else(|| ProjectError::MissingLockfile)?;
// Perform the lock operation, but don't write the lockfile to disk.
let lock = match do_lock(
project.workspace(),
venv.interpreter(),
Some(&existing),
settings.as_ref().into(),
&state,
preview,
connectivity,
concurrency,
native_tls,
cache,
printer,
)
.await
{
Ok(lock) => lock,
Err(ProjectError::Operation(pip::operations::Error::Resolve(
uv_resolver::ResolveError::NoSolution(err),
))) => {
let report = miette::Report::msg(format!("{err}")).context(err.header());
anstream::eprint!("{report:?}");
return Ok(ExitStatus::Failure);
}
Err(err) => return Err(err.into()),
};
// If the locks disagree, return an error.
if lock != existing {
return Err(ProjectError::LockMismatch.into());
}
lock
} else {
// Read the existing lockfile.
let existing = project::lock::read(project.workspace()).await?;
// Perform the lock operation.
match do_lock(
project.workspace(),
venv.interpreter(),
existing.as_ref(),
settings.as_ref().into(),
&state,
preview,
connectivity,
concurrency,
native_tls,
cache,
printer,
)
.await
{
Ok(lock) => {
project::lock::commit(&lock, project.workspace()).await?;
lock
}
Err(ProjectError::Operation(pip::operations::Error::Resolve(
uv_resolver::ResolveError::NoSolution(err),
))) => {
let report = miette::Report::msg(format!("{err}")).context(err.header());
anstream::eprint!("{report:?}");
return Ok(ExitStatus::Failure);
}
Err(err) => return Err(err.into()),
let lock = match do_safe_lock(
locked,
frozen,
project.workspace(),
venv.interpreter(),
settings.as_ref().into(),
&state,
preview,
connectivity,
concurrency,
native_tls,
cache,
printer,
)
.await
{
Ok(lock) => lock,
Err(ProjectError::Operation(pip::operations::Error::Resolve(
uv_resolver::ResolveError::NoSolution(err),
))) => {
let report = miette::Report::msg(format!("{err}")).context(err.header());
anstream::eprint!("{report:?}");
return Ok(ExitStatus::Failure);
}
Err(err) => return Err(err.into()),
};
// Perform the sync operation.

View file

@ -838,6 +838,8 @@ async fn run_project(
commands::run(
args.command,
requirements,
args.locked,
args.frozen,
args.package,
args.extras,
args.dev,
@ -891,6 +893,8 @@ async fn run_project(
let cache = cache.init()?.with_refresh(args.refresh);
commands::lock(
args.locked,
args.frozen,
args.python,
args.settings,
globals.preview,

View file

@ -151,6 +151,8 @@ impl CacheSettings {
#[allow(clippy::struct_excessive_bools)]
#[derive(Debug, Clone)]
pub(crate) struct RunSettings {
pub(crate) locked: bool,
pub(crate) frozen: bool,
pub(crate) extras: ExtrasSpecification,
pub(crate) dev: bool,
pub(crate) command: ExternalCommand,
@ -166,6 +168,8 @@ impl RunSettings {
#[allow(clippy::needless_pass_by_value)]
pub(crate) fn resolve(args: RunArgs, filesystem: Option<FilesystemOptions>) -> Self {
let RunArgs {
locked,
frozen,
extra,
all_extras,
no_all_extras,
@ -181,6 +185,8 @@ impl RunSettings {
} = args;
Self {
locked,
frozen,
extras: ExtrasSpecification::from_args(
flag(all_extras, no_all_extras).unwrap_or_default(),
extra.unwrap_or_default(),
@ -517,6 +523,8 @@ impl SyncSettings {
#[allow(clippy::struct_excessive_bools, dead_code)]
#[derive(Debug, Clone)]
pub(crate) struct LockSettings {
pub(crate) locked: bool,
pub(crate) frozen: bool,
pub(crate) python: Option<String>,
pub(crate) refresh: Refresh,
pub(crate) settings: ResolverSettings,
@ -527,6 +535,8 @@ impl LockSettings {
#[allow(clippy::needless_pass_by_value)]
pub(crate) fn resolve(args: LockArgs, filesystem: Option<FilesystemOptions>) -> Self {
let LockArgs {
locked,
frozen,
resolver,
build,
refresh,
@ -534,6 +544,8 @@ impl LockSettings {
} = args;
Self {
locked,
frozen,
python,
refresh: Refresh::from(refresh),
settings: ResolverSettings::combine(resolver_options(resolver, build), filesystem),

View file

@ -1,6 +1,7 @@
#![cfg(all(feature = "python", feature = "pypi"))]
use anyhow::Result;
use assert_cmd::assert::OutputAssertExt;
use assert_fs::prelude::*;
use indoc::indoc;
@ -400,3 +401,143 @@ fn run_with() -> Result<()> {
Ok(())
}
#[test]
fn run_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"]
"#,
)?;
// Running with `--locked` should error, if no lockfile is present.
uv_snapshot!(context.filters(), context.run().arg("--locked").arg("--").arg("python").arg("--version"), @r###"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
warning: `uv run` is experimental and may change without warning.
error: Unable to find lockfile at `uv.lock`. To create a lockfile, run `uv lock` or `uv sync`.
"###);
// Lock the initial requirements.
context.lock().assert().success();
let existing = fs_err::read_to_string(context.temp_dir.child("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` should error.
uv_snapshot!(context.filters(), context.run().arg("--locked").arg("--").arg("python").arg("--version"), @r###"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
warning: `uv run` is experimental and may change without warning.
Resolved 2 packages in [TIME]
error: The lockfile at `uv.lock` needs to be updated, but `--locked` was provided. To update the lockfile, run `uv lock`.
"###);
let updated = fs_err::read_to_string(context.temp_dir.child("uv.lock"))?;
// And the lockfile should be unchanged.
assert_eq!(existing, updated);
// Lock the updated requirements.
context.lock().assert().success();
// Running with `--locked` should succeed.
uv_snapshot!(context.filters(), context.run().arg("--locked").arg("--").arg("python").arg("--version"), @r###"
success: true
exit_code: 0
----- stdout -----
Python 3.12.[X]
----- stderr -----
warning: `uv run` is experimental and may change without warning.
Resolved 2 packages in [TIME]
Prepared 2 packages in [TIME]
Installed 2 packages in [TIME]
+ iniconfig==2.0.0
+ project==0.1.0 (from file://[TEMP_DIR]/)
"###);
Ok(())
}
#[test]
fn run_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"]
"#,
)?;
// Running with `--frozen` should error, if no lockfile is present.
uv_snapshot!(context.filters(), context.run().arg("--frozen").arg("--").arg("python").arg("--version"), @r###"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
warning: `uv run` is experimental and may change without warning.
error: Unable to find lockfile at `uv.lock`. To create a lockfile, run `uv lock` or `uv sync`.
"###);
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` should install the stale lockfile.
uv_snapshot!(context.filters(), context.run().arg("--frozen").arg("--").arg("python").arg("--version"), @r###"
success: true
exit_code: 0
----- stdout -----
Python 3.12.[X]
----- stderr -----
warning: `uv run` is experimental and may change without warning.
Prepared 4 packages in [TIME]
Installed 4 packages in [TIME]
+ anyio==3.7.0
+ idna==3.6
+ project==0.1.0 (from file://[TEMP_DIR]/)
+ sniffio==1.3.1
"###);
Ok(())
}

View file

@ -59,7 +59,7 @@ fn locked() -> Result<()> {
)?;
// Running with `--locked` should error, if no lockfile is present.
uv_snapshot!(context.filters(), context.sync().arg("--frozen"), @r###"
uv_snapshot!(context.filters(), context.sync().arg("--locked"), @r###"
success: false
exit_code: 2
----- stdout -----