From dfe2faa71eee6d9db99dcc79a9119235e46ddb1f Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Thu, 18 Jul 2024 14:55:17 -0400 Subject: [PATCH] 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. --- crates/uv-cli/src/lib.rs | 16 +++ crates/uv/src/commands/project/lock.rs | 93 ++++++++++++++-- crates/uv/src/commands/project/run.rs | 32 +++--- crates/uv/src/commands/project/sync.rs | 102 +++++------------- crates/uv/src/lib.rs | 4 + crates/uv/src/settings.rs | 12 +++ crates/uv/tests/run.rs | 141 +++++++++++++++++++++++++ crates/uv/tests/sync.rs | 2 +- 8 files changed, 301 insertions(+), 101 deletions(-) diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 4a2b61af1034..e293792bb8ee 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -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, diff --git a/crates/uv/src/commands/project/lock.rs b/crates/uv/src/commands/project/lock.rs index 168bfab08a15..3f3175f5e58a 100644 --- a/crates/uv/src/commands/project/lock.rs +++ b/crates/uv/src/commands/project/lock.rs @@ -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, 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 { + 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, diff --git a/crates/uv/src/commands/project/run.rs b/crates/uv/src/commands/project/run.rs index 59979db1e678..a43842019980 100644 --- a/crates/uv/src/commands/project/run.rs +++ b/crates/uv/src/commands/project/run.rs @@ -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, + locked: bool, + frozen: bool, package: Option, 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, diff --git a/crates/uv/src/commands/project/sync.rs b/crates/uv/src/commands/project/sync.rs index 08e349f7a7b2..0de4b91239b1 100644 --- a/crates/uv/src/commands/project/sync.rs +++ b/crates/uv/src/commands/project/sync.rs @@ -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. diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index 10e49a8e3a30..651425eaf6d0 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -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, diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index f3f8bd90d6d8..a6346d37a3ed 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -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) -> 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, 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) -> 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), diff --git a/crates/uv/tests/run.rs b/crates/uv/tests/run.rs index 3314257cfe1c..779776e5ef77 100644 --- a/crates/uv/tests/run.rs +++ b/crates/uv/tests/run.rs @@ -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(()) +} diff --git a/crates/uv/tests/sync.rs b/crates/uv/tests/sync.rs index ee28786d6ed0..bff4195a562d 100644 --- a/crates/uv/tests/sync.rs +++ b/crates/uv/tests/sync.rs @@ -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 -----