From 729148dac99aa00fa3805955d564325086c645da Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Fri, 19 Jul 2024 09:18:32 -0400 Subject: [PATCH] Add `--frozen` to `uv add`, `uv remove`, and `uv tree` (#5214) ## Summary E.g., `uv add foo --frozen` will avoid updating or even creating a `uv.lock`. --- crates/uv-cli/src/lib.rs | 59 +++++++++++++++------ crates/uv/src/commands/project/add.rs | 20 +++---- crates/uv/src/commands/project/remove.rs | 20 +++---- crates/uv/src/commands/project/tree.rs | 14 +++-- crates/uv/src/lib.rs | 6 +++ crates/uv/src/settings.rs | 20 ++++++- crates/uv/tests/edit.rs | 48 +++++++++++++++++ crates/uv/tests/tree.rs | 66 ++++++++++++++++++++++++ 8 files changed, 209 insertions(+), 44 deletions(-) diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index e293792bb8ee..552631362ccb 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -1784,14 +1784,6 @@ 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. @@ -1823,6 +1815,14 @@ pub struct RunArgs { #[arg(long)] pub with: Vec, + /// 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, + #[command(flatten)] pub installer: ResolverInstallerArgs, @@ -1854,14 +1854,6 @@ pub struct RunArgs { #[derive(Args)] #[allow(clippy::struct_excessive_bools)] pub struct SyncArgs { - /// 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. @@ -1886,10 +1878,19 @@ pub struct SyncArgs { pub no_dev: bool, /// Does not clean the environment. - /// Without this flag any extraneous installations will be removed. + /// + /// When omitted, any extraneous installations will be removed. #[arg(long)] pub no_clean: bool, + /// 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, + #[command(flatten)] pub installer: ResolverInstallerArgs, @@ -1990,6 +1991,14 @@ pub struct AddArgs { #[arg(long)] pub extra: Option>, + /// Assert that the `uv.lock` will remain unchanged. + #[arg(long, conflicts_with = "frozen")] + pub locked: bool, + + /// Add the requirements without updating the `uv.lock` file. + #[arg(long, conflicts_with = "locked")] + pub frozen: bool, + #[command(flatten)] pub installer: ResolverInstallerArgs, @@ -2034,6 +2043,14 @@ pub struct RemoveArgs { #[arg(long, conflicts_with("dev"))] pub optional: Option, + /// Assert that the `uv.lock` will remain unchanged. + #[arg(long, conflicts_with = "frozen")] + pub locked: bool, + + /// Remove the requirements without updating the `uv.lock` file. + #[arg(long, conflicts_with = "locked")] + pub frozen: bool, + #[command(flatten)] pub installer: ResolverInstallerArgs, @@ -2069,6 +2086,14 @@ pub struct TreeArgs { #[command(flatten)] pub tree: DisplayTreeArgs, + /// Assert that the `uv.lock` will remain unchanged. + #[arg(long, conflicts_with = "frozen")] + pub locked: bool, + + /// Display the requirements without updating the `uv.lock` file. + #[arg(long, conflicts_with = "locked")] + pub frozen: bool, + #[command(flatten)] pub build: BuildArgs, diff --git a/crates/uv/src/commands/project/add.rs b/crates/uv/src/commands/project/add.rs index 2c8e1e6ac4cf..8ad36ccfcd9f 100644 --- a/crates/uv/src/commands/project/add.rs +++ b/crates/uv/src/commands/project/add.rs @@ -18,7 +18,6 @@ use uv_warnings::warn_user_once; use crate::commands::pip::operations::Modifications; use crate::commands::pip::resolution_environment; -use crate::commands::project::lock::commit; use crate::commands::reporters::ResolverReporter; use crate::commands::{project, ExitStatus, SharedState}; use crate::printer::Printer; @@ -27,6 +26,8 @@ use crate::settings::ResolverInstallerSettings; /// Add one or more packages to the project requirements. #[allow(clippy::fn_params_excessive_bools)] pub(crate) async fn add( + locked: bool, + frozen: bool, requirements: Vec, editable: Option, dependency_type: DependencyType, @@ -203,17 +204,21 @@ pub(crate) async fn add( pyproject.to_string(), )?; + // If `--frozen`, exit early. There's no reason to lock and sync, and we don't need a `uv.lock` + // to exist at all. + if frozen { + return Ok(ExitStatus::Success); + } + // Initialize any shared state. let state = SharedState::default(); - // Read the existing lockfile. - let existing = project::lock::read(project.workspace()).await?; - // Lock and sync the environment, if necessary. - let lock = project::lock::do_lock( + let lock = project::lock::do_safe_lock( + locked, + frozen, project.workspace(), venv.interpreter(), - existing.as_ref(), settings.as_ref().into(), &state, preview, @@ -224,9 +229,6 @@ pub(crate) async fn add( printer, ) .await?; - if !existing.is_some_and(|existing| existing == lock) { - commit(&lock, project.workspace()).await?; - } // Perform a full sync, because we don't know what exactly is affected by the removal. // TODO(ibraheem): Should we accept CLI overrides for this? Should we even sync here? diff --git a/crates/uv/src/commands/project/remove.rs b/crates/uv/src/commands/project/remove.rs index 82be0cfad277..492acd91b739 100644 --- a/crates/uv/src/commands/project/remove.rs +++ b/crates/uv/src/commands/project/remove.rs @@ -11,13 +11,14 @@ use uv_python::{PythonFetch, PythonPreference, PythonRequest}; use uv_warnings::{warn_user, warn_user_once}; use crate::commands::pip::operations::Modifications; -use crate::commands::project::lock::commit; use crate::commands::{project, ExitStatus, SharedState}; use crate::printer::Printer; use crate::settings::ResolverInstallerSettings; /// Remove one or more packages from the project requirements. pub(crate) async fn remove( + locked: bool, + frozen: bool, requirements: Vec, dependency_type: DependencyType, package: Option, @@ -83,6 +84,12 @@ pub(crate) async fn remove( pyproject.to_string(), )?; + // If `--frozen`, exit early. There's no reason to lock and sync, and we don't need a `uv.lock` + // to exist at all. + if frozen { + return Ok(ExitStatus::Success); + } + // Discover or create the virtual environment. let venv = project::get_or_init_environment( project.workspace(), @@ -99,14 +106,12 @@ pub(crate) async fn remove( // Initialize any shared state. let state = SharedState::default(); - // Read the existing lockfile. - let existing = project::lock::read(project.workspace()).await?; - // Lock and sync the environment, if necessary. - let lock = project::lock::do_lock( + let lock = project::lock::do_safe_lock( + locked, + frozen, project.workspace(), venv.interpreter(), - existing.as_ref(), settings.as_ref().into(), &state, preview, @@ -117,9 +122,6 @@ pub(crate) async fn remove( printer, ) .await?; - if !existing.is_some_and(|existing| existing == lock) { - commit(&lock, project.workspace()).await?; - } // Perform a full sync, because we don't know what exactly is affected by the removal. // TODO(ibraheem): Should we accept CLI overrides for this? Should we even sync here? diff --git a/crates/uv/src/commands/project/tree.rs b/crates/uv/src/commands/project/tree.rs index 595b01ebc4ca..09683d8d22f4 100644 --- a/crates/uv/src/commands/project/tree.rs +++ b/crates/uv/src/commands/project/tree.rs @@ -22,7 +22,10 @@ use crate::settings::ResolverSettings; use super::SharedState; /// Run a command. +#[allow(clippy::fn_params_excessive_bools)] pub(crate) async fn tree( + locked: bool, + frozen: bool, depth: u8, prune: Vec, package: Vec, @@ -60,14 +63,12 @@ pub(crate) async fn tree( .await? .into_interpreter(); - // Read the existing lockfile. - let existing = project::lock::read(&workspace).await?; - // Update the lock file, if necessary. - let lock = project::lock::do_lock( + let lock = project::lock::do_safe_lock( + locked, + frozen, &workspace, &interpreter, - existing.as_ref(), settings.as_ref(), &SharedState::default(), preview, @@ -78,9 +79,6 @@ pub(crate) async fn tree( printer, ) .await?; - if !existing.is_some_and(|existing| existing == lock) { - project::lock::commit(&lock, &workspace).await?; - } // Read packages from the lockfile. let mut packages: IndexMap<_, Vec<_>> = IndexMap::new(); diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index 651425eaf6d0..43ced139d192 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -917,6 +917,8 @@ async fn run_project( let cache = cache.init()?.with_refresh(args.refresh); commands::add( + args.locked, + args.frozen, args.requirements, args.editable, args.dependency_type, @@ -948,6 +950,8 @@ async fn run_project( let cache = cache.init()?.with_refresh(args.refresh); commands::remove( + args.locked, + args.frozen, args.requirements, args.dependency_type, args.package, @@ -973,6 +977,8 @@ async fn run_project( let cache = cache.init()?; commands::tree( + args.locked, + args.frozen, args.depth, args.prune, args.package, diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index a6346d37a3ed..cbcebd1bf692 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -557,6 +557,8 @@ impl LockSettings { #[allow(clippy::struct_excessive_bools, dead_code)] #[derive(Debug, Clone)] pub(crate) struct AddSettings { + pub(crate) locked: bool, + pub(crate) frozen: bool, pub(crate) requirements: Vec, pub(crate) dependency_type: DependencyType, pub(crate) editable: Option, @@ -585,6 +587,8 @@ impl AddSettings { rev, tag, branch, + locked, + frozen, installer, build, refresh, @@ -606,6 +610,8 @@ impl AddSettings { }; Self { + locked, + frozen, requirements, dependency_type, editable, @@ -629,6 +635,8 @@ impl AddSettings { #[allow(clippy::struct_excessive_bools, dead_code)] #[derive(Debug, Clone)] pub(crate) struct RemoveSettings { + pub(crate) locked: bool, + pub(crate) frozen: bool, pub(crate) requirements: Vec, pub(crate) dependency_type: DependencyType, pub(crate) package: Option, @@ -645,6 +653,8 @@ impl RemoveSettings { dev, optional, requirements, + locked, + frozen, installer, build, refresh, @@ -661,6 +671,8 @@ impl RemoveSettings { }; Self { + locked, + frozen, requirements, dependency_type, package, @@ -678,6 +690,8 @@ impl RemoveSettings { #[allow(clippy::struct_excessive_bools)] #[derive(Debug, Clone)] pub(crate) struct TreeSettings { + pub(crate) locked: bool, + pub(crate) frozen: bool, pub(crate) depth: u8, pub(crate) prune: Vec, pub(crate) package: Vec, @@ -692,18 +706,22 @@ impl TreeSettings { pub(crate) fn resolve(args: TreeArgs, filesystem: Option) -> Self { let TreeArgs { tree, + locked, + frozen, build, resolver, python, } = args; Self { - python, + locked, + frozen, depth: tree.depth, prune: tree.prune, package: tree.package, no_dedupe: tree.no_dedupe, invert: tree.invert, + python, resolver: ResolverSettings::combine(resolver_options(resolver, build), filesystem), } } diff --git a/crates/uv/tests/edit.rs b/crates/uv/tests/edit.rs index 9baeafdaf36f..7027687b5490 100644 --- a/crates/uv/tests/edit.rs +++ b/crates/uv/tests/edit.rs @@ -1822,3 +1822,51 @@ fn add_puts_default_indentation_in_pyproject_toml_if_not_observed() -> Result<() }); Ok(()) } + +/// Add a requirement without updating the lockfile. +#[test] +fn add_frozen() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(indoc! {r#" + [project] + name = "project" + version = "0.1.0" + # ... + requires-python = ">=3.12" + dependencies = [] + "#})?; + + uv_snapshot!(context.filters(), context.add(&["anyio==3.7.0"]).arg("--frozen"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv add` is experimental and may change without warning. + "###); + + let pyproject_toml = fs_err::read_to_string(context.temp_dir.join("pyproject.toml"))?; + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + pyproject_toml, @r###" + [project] + name = "project" + version = "0.1.0" + # ... + requires-python = ">=3.12" + dependencies = [ + "anyio==3.7.0", + ] + "### + ); + }); + + assert!(!context.temp_dir.join("uv.lock").exists()); + + Ok(()) +} diff --git a/crates/uv/tests/tree.rs b/crates/uv/tests/tree.rs index d6a52a47ceea..8d8c4b4db536 100644 --- a/crates/uv/tests/tree.rs +++ b/crates/uv/tests/tree.rs @@ -114,3 +114,69 @@ fn invert() -> Result<()> { Ok(()) } + +#[test] +fn 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"] + "#, + )?; + + uv_snapshot!(context.filters(), context.tree(), @r###" + success: true + exit_code: 0 + ----- stdout ----- + project v0.1.0 + └── anyio v4.3.0 + ├── idna v3.6 + └── sniffio v1.3.1 + + ----- stderr ----- + warning: `uv run` is experimental and may change without warning. + Resolved 4 packages in [TIME] + "### + ); + + // `uv tree` should update the lockfile + let lock = fs_err::read_to_string(context.temp_dir.join("uv.lock"))?; + assert!(!lock.is_empty()); + + // Update the project dependencies. + 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 = ["iniconfig"] + "#, + )?; + + // Running with `--frozen` should show the stale tree. + uv_snapshot!(context.filters(), context.tree().arg("--frozen"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + project v0.1.0 + └── anyio v4.3.0 + ├── idna v3.6 + └── sniffio v1.3.1 + + ----- stderr ----- + warning: `uv run` is experimental and may change without warning. + "### + ); + + Ok(()) +}