mirror of
https://github.com/astral-sh/uv.git
synced 2025-11-02 12:59:45 +00:00
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:
parent
6a6e3b464f
commit
dfe2faa71e
8 changed files with 301 additions and 101 deletions
|
|
@ -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,
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 -----
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue