From 25ea3bcfbe6ebf7e4826eba4ed3c2e155dd498c8 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Sun, 20 Sep 2026 10:49:09 -0400 Subject: [PATCH 1/4] Materialize fake-uv without checkout symlinks (#21855) ## Summary When Git checks out symlinks as regular files, the `fake-uv` fixture cannot be installed because its `src` entry contains a link target instead of Python sources. We now copy the current Python sources into a temporary fixture for each Python module test, using a helper local to the test module. Closes https://github.com/astral-sh/uv/issues/21850. --- crates/uv/tests/python/python_module.rs | 130 ++++++++++++++++-------- 1 file changed, 87 insertions(+), 43 deletions(-) diff --git a/crates/uv/tests/python/python_module.rs b/crates/uv/tests/python/python_module.rs index 5cc586d7c03..ab1a2e23c65 100644 --- a/crates/uv/tests/python/python_module.rs +++ b/crates/uv/tests/python/python_module.rs @@ -1,11 +1,14 @@ +use std::path::PathBuf; + use assert_cmd::assert::OutputAssertExt; use assert_fs::prelude::{FileTouch, FileWriteStr, PathChild, PathCreateDir}; +use fs_err as fs; use indoc::{formatdoc, indoc}; use uv_fs::Simplified; use uv_static::EnvVars; -use uv_test::{site_packages_path, uv_snapshot}; +use uv_test::{TestContext, copy_dir_ignore, site_packages_path, uv_snapshot}; /// Filter the user scheme, which differs between Windows and Unix. fn user_scheme_bin_filter() -> (String, String) { @@ -30,8 +33,25 @@ sys.base_prefix = '/dev/null' print(uv.find_uv_bin()) "; +/// Copy the current Python sources into a fixture independent of Git's symlink support. +fn fake_uv(context: &TestContext) -> anyhow::Result { + let package = context.workspace_root.join("test/packages/fake-uv"); + let destination = context.temp_dir.join("fake-uv"); + fs::create_dir(&destination)?; + fs::copy( + package.join("pyproject.toml"), + destination.join("pyproject.toml"), + )?; + copy_dir_ignore(package.join("scripts"), destination.join("scripts"))?; + copy_dir_ignore( + context.workspace_root.join("python"), + destination.join("src"), + )?; + Ok(destination) +} + #[test] -fn find_uv_bin_target() { +fn find_uv_bin_target() -> anyhow::Result<()> { let context = uv_test::test_context!("3.12") .with_filtered_python_names() .with_filtered_virtualenv_bin() @@ -43,7 +63,7 @@ fn find_uv_bin_target() { // Install in a target directory uv_snapshot!(context.filters(), context.pip_install() - .arg(context.workspace_root.join("test/packages/fake-uv")) + .arg(fake_uv(&context)?) .arg("--target") .arg("target"), @" exit_code: 0 (success) @@ -52,7 +72,7 @@ fn find_uv_bin_target() { Resolved 1 package in [TIME] Prepared 1 package in [TIME] Installed 1 package in [TIME] - + uv==0.1.0 (from file://[WORKSPACE]/test/packages/fake-uv) + + uv==0.1.0 (from file://[TEMP_DIR]/fake-uv) " ); @@ -66,10 +86,12 @@ fn find_uv_bin_target() { [TEMP_DIR]/target/[BIN]/uv " ); + + Ok(()) } #[test] -fn find_uv_bin_prefix() { +fn find_uv_bin_prefix() -> anyhow::Result<()> { let context = uv_test::test_context!("3.12") .with_filtered_python_names() .with_filtered_virtualenv_bin() @@ -83,7 +105,7 @@ fn find_uv_bin_prefix() { let prefix = context.temp_dir.child("prefix"); uv_snapshot!(context.filters(), context.pip_install() - .arg(context.workspace_root.join("test/packages/fake-uv")) + .arg(fake_uv(&context)?) .arg("--prefix") .arg(prefix.path()), @" exit_code: 0 (success) @@ -92,7 +114,7 @@ fn find_uv_bin_prefix() { Resolved 1 package in [TIME] Prepared 1 package in [TIME] Installed 1 package in [TIME] - + uv==0.1.0 (from file://[WORKSPACE]/test/packages/fake-uv) + + uv==0.1.0 (from file://[TEMP_DIR]/fake-uv) " ); @@ -109,10 +131,12 @@ fn find_uv_bin_prefix() { [TEMP_DIR]/prefix/[BIN]/uv " ); + + Ok(()) } #[test] -fn find_uv_bin_base_prefix() { +fn find_uv_bin_base_prefix() -> anyhow::Result<()> { let context = uv_test::test_context!("3.12") .with_filtered_python_names() .with_filtered_virtualenv_bin() @@ -131,14 +155,14 @@ fn find_uv_bin_base_prefix() { uv_snapshot!(context.filters(), context.pip_install() .arg("--python") .arg(base_venv.path()) - .arg(context.workspace_root.join("test/packages/fake-uv")), @" + .arg(fake_uv(&context)?), @" exit_code: 0 (success) ----- stderr ----- Using Python 3.12.[X] environment at: base-venv Resolved 1 package in [TIME] Prepared 1 package in [TIME] Installed 1 package in [TIME] - + uv==0.1.0 (from file://[WORKSPACE]/test/packages/fake-uv) + + uv==0.1.0 (from file://[TEMP_DIR]/fake-uv) " ); @@ -154,6 +178,8 @@ fn find_uv_bin_base_prefix() { [TEMP_DIR]/base-venv/[BIN]/uv " ); + + Ok(()) } #[test] @@ -181,7 +207,7 @@ fn find_uv_bin_in_ephemeral_environment() -> anyhow::Result<()> { // We should find the binary in an ephemeral `--with` environment uv_snapshot!(context.filters(), context.run() .arg("--with") - .arg(context.workspace_root.join("test/packages/fake-uv")) + .arg(fake_uv(&context)?) .arg("python") .arg("-c") .arg(TEST_SCRIPT), @" @@ -195,7 +221,7 @@ fn find_uv_bin_in_ephemeral_environment() -> anyhow::Result<()> { Resolved 1 package in [TIME] Prepared 1 package in [TIME] Installed 1 package in [TIME] - + uv==0.1.0 (from file://[WORKSPACE]/test/packages/fake-uv) + + uv==0.1.0 (from file://[TEMP_DIR]/fake-uv) " ); @@ -225,7 +251,7 @@ fn find_uv_bin_in_parent_of_ephemeral_environment() -> anyhow::Result<()> { [tool.uv.sources] uv = {{ path = "{}" }} "#, - context.workspace_root.join("test/packages/fake-uv").portable_display() + fake_uv(&context)?.portable_display() })?; // When running in an ephemeral environment, we should find the binary in the project @@ -245,7 +271,7 @@ fn find_uv_bin_in_parent_of_ephemeral_environment() -> anyhow::Result<()> { Resolved 2 packages in [TIME] Prepared 1 package in [TIME] Installed 1 package in [TIME] - + uv==0.1.0 (from file://[WORKSPACE]/test/packages/fake-uv) + + uv==0.1.0 (from file://[TEMP_DIR]/fake-uv) Resolved 3 packages in [TIME] Prepared 3 packages in [TIME] Installed 3 packages in [TIME] @@ -259,7 +285,7 @@ fn find_uv_bin_in_parent_of_ephemeral_environment() -> anyhow::Result<()> { } #[test] -fn find_uv_bin_user_bin() { +fn find_uv_bin_user_bin() -> anyhow::Result<()> { let context = uv_test::test_context!("3.12") .with_filtered_python_names() .with_filtered_virtualenv_bin() @@ -286,13 +312,13 @@ fn find_uv_bin_user_bin() { // Install in a virtual environment uv_snapshot!(context.filters(), context.pip_install() - .arg(context.workspace_root.join("test/packages/fake-uv")), @" + .arg(fake_uv(&context)?), @" exit_code: 0 (success) ----- stderr ----- Resolved 1 package in [TIME] Prepared 1 package in [TIME] Installed 1 package in [TIME] - + uv==0.1.0 (from file://[WORKSPACE]/test/packages/fake-uv) + + uv==0.1.0 (from file://[TEMP_DIR]/fake-uv) " ); @@ -307,7 +333,7 @@ fn find_uv_bin_user_bin() { ); // Remove the virtual environment one for some reason - fs_err::remove_file(if cfg!(unix) { + fs::remove_file(if cfg!(unix) { context.venv.child("bin").child("uv") } else { context.venv.child("Scripts").child("uv.exe") @@ -323,10 +349,12 @@ fn find_uv_bin_user_bin() { [USER_SCHEME]/[BIN]/uv " ); + + Ok(()) } #[test] -fn find_uv_bin_error_message() { +fn find_uv_bin_error_message() -> anyhow::Result<()> { let mut context = uv_test::test_context!("3.12") .with_filtered_python_names() .with_filtered_virtualenv_bin() @@ -361,18 +389,18 @@ fn find_uv_bin_error_message() { // Install in a virtual environment uv_snapshot!(context.filters(), context.pip_install() - .arg(context.workspace_root.join("test/packages/fake-uv")), @" + .arg(fake_uv(&context)?), @" exit_code: 0 (success) ----- stderr ----- Resolved 1 package in [TIME] Prepared 1 package in [TIME] Installed 1 package in [TIME] - + uv==0.1.0 (from file://[WORKSPACE]/test/packages/fake-uv) + + uv==0.1.0 (from file://[TEMP_DIR]/fake-uv) " ); // Remove the virtual environment executable for some reason - fs_err::remove_file(if cfg!(unix) { + fs::remove_file(if cfg!(unix) { context.venv.child("bin").child("uv") } else { context.venv.child("Scripts").child("uv.exe") @@ -395,11 +423,13 @@ fn find_uv_bin_error_message() { - [USER_SCHEME]/[BIN] "# ); + + Ok(()) } #[cfg(feature = "test-python-eol")] #[test] -fn find_uv_bin_py38() { +fn find_uv_bin_py38() -> anyhow::Result<()> { let context = uv_test::test_context!("3.8") .with_filtered_python_names() .with_filtered_virtualenv_bin() @@ -411,13 +441,13 @@ fn find_uv_bin_py38() { // Install in a virtual environment uv_snapshot!(context.filters(), context.pip_install() - .arg(context.workspace_root.join("test/packages/fake-uv")), @" + .arg(fake_uv(&context)?), @" exit_code: 0 (success) ----- stderr ----- Resolved 1 package in [TIME] Prepared 1 package in [TIME] Installed 1 package in [TIME] - + uv==0.1.0 (from file://[WORKSPACE]/test/packages/fake-uv) + + uv==0.1.0 (from file://[TEMP_DIR]/fake-uv) " ); @@ -430,10 +460,12 @@ fn find_uv_bin_py38() { [VENV]/[BIN]/uv " ); + + Ok(()) } #[test] -fn find_uv_bin_py39() { +fn find_uv_bin_py39() -> anyhow::Result<()> { let context = uv_test::test_context!("3.9") .with_filtered_python_names() .with_filtered_virtualenv_bin() @@ -445,13 +477,13 @@ fn find_uv_bin_py39() { // Install in a virtual environment uv_snapshot!(context.filters(), context.pip_install() - .arg(context.workspace_root.join("test/packages/fake-uv")), @" + .arg(fake_uv(&context)?), @" exit_code: 0 (success) ----- stderr ----- Resolved 1 package in [TIME] Prepared 1 package in [TIME] Installed 1 package in [TIME] - + uv==0.1.0 (from file://[WORKSPACE]/test/packages/fake-uv) + + uv==0.1.0 (from file://[TEMP_DIR]/fake-uv) " ); @@ -464,10 +496,12 @@ fn find_uv_bin_py39() { [VENV]/[BIN]/uv " ); + + Ok(()) } #[test] -fn find_uv_bin_py310() { +fn find_uv_bin_py310() -> anyhow::Result<()> { let context = uv_test::test_context!("3.10") .with_filtered_python_names() .with_filtered_virtualenv_bin() @@ -479,13 +513,13 @@ fn find_uv_bin_py310() { // Install in a virtual environment uv_snapshot!(context.filters(), context.pip_install() - .arg(context.workspace_root.join("test/packages/fake-uv")), @" + .arg(fake_uv(&context)?), @" exit_code: 0 (success) ----- stderr ----- Resolved 1 package in [TIME] Prepared 1 package in [TIME] Installed 1 package in [TIME] - + uv==0.1.0 (from file://[WORKSPACE]/test/packages/fake-uv) + + uv==0.1.0 (from file://[TEMP_DIR]/fake-uv) " ); @@ -498,10 +532,12 @@ fn find_uv_bin_py310() { [VENV]/[BIN]/uv " ); + + Ok(()) } #[test] -fn find_uv_bin_py311() { +fn find_uv_bin_py311() -> anyhow::Result<()> { let context = uv_test::test_context!("3.11") .with_filtered_python_names() .with_filtered_virtualenv_bin() @@ -513,13 +549,13 @@ fn find_uv_bin_py311() { // Install in a virtual environment uv_snapshot!(context.filters(), context.pip_install() - .arg(context.workspace_root.join("test/packages/fake-uv")), @" + .arg(fake_uv(&context)?), @" exit_code: 0 (success) ----- stderr ----- Resolved 1 package in [TIME] Prepared 1 package in [TIME] Installed 1 package in [TIME] - + uv==0.1.0 (from file://[WORKSPACE]/test/packages/fake-uv) + + uv==0.1.0 (from file://[TEMP_DIR]/fake-uv) " ); @@ -532,10 +568,12 @@ fn find_uv_bin_py311() { [VENV]/[BIN]/uv " ); + + Ok(()) } #[test] -fn find_uv_bin_py312() { +fn find_uv_bin_py312() -> anyhow::Result<()> { let context = uv_test::test_context!("3.12") .with_filtered_python_names() .with_filtered_virtualenv_bin() @@ -547,13 +585,13 @@ fn find_uv_bin_py312() { // Install in a virtual environment uv_snapshot!(context.filters(), context.pip_install() - .arg(context.workspace_root.join("test/packages/fake-uv")), @" + .arg(fake_uv(&context)?), @" exit_code: 0 (success) ----- stderr ----- Resolved 1 package in [TIME] Prepared 1 package in [TIME] Installed 1 package in [TIME] - + uv==0.1.0 (from file://[WORKSPACE]/test/packages/fake-uv) + + uv==0.1.0 (from file://[TEMP_DIR]/fake-uv) " ); @@ -566,10 +604,12 @@ fn find_uv_bin_py312() { [VENV]/[BIN]/uv " ); + + Ok(()) } #[test] -fn find_uv_bin_py313() { +fn find_uv_bin_py313() -> anyhow::Result<()> { let context = uv_test::test_context!("3.13") .with_filtered_python_names() .with_filtered_virtualenv_bin() @@ -581,13 +621,13 @@ fn find_uv_bin_py313() { // Install in a virtual environment uv_snapshot!(context.filters(), context.pip_install() - .arg(context.workspace_root.join("test/packages/fake-uv")), @" + .arg(fake_uv(&context)?), @" exit_code: 0 (success) ----- stderr ----- Resolved 1 package in [TIME] Prepared 1 package in [TIME] Installed 1 package in [TIME] - + uv==0.1.0 (from file://[WORKSPACE]/test/packages/fake-uv) + + uv==0.1.0 (from file://[TEMP_DIR]/fake-uv) " ); @@ -600,10 +640,12 @@ fn find_uv_bin_py313() { [VENV]/[BIN]/uv " ); + + Ok(()) } #[test] -fn find_uv_bin_py314() { +fn find_uv_bin_py314() -> anyhow::Result<()> { let context = uv_test::test_context!("3.14") .with_filtered_python_names() .with_filtered_virtualenv_bin() @@ -615,13 +657,13 @@ fn find_uv_bin_py314() { // Install in a virtual environment uv_snapshot!(context.filters(), context.pip_install() - .arg(context.workspace_root.join("test/packages/fake-uv")), @" + .arg(fake_uv(&context)?), @" exit_code: 0 (success) ----- stderr ----- Resolved 1 package in [TIME] Prepared 1 package in [TIME] Installed 1 package in [TIME] - + uv==0.1.0 (from file://[WORKSPACE]/test/packages/fake-uv) + + uv==0.1.0 (from file://[TEMP_DIR]/fake-uv) " ); @@ -634,4 +676,6 @@ fn find_uv_bin_py314() { [VENV]/[BIN]/uv " ); + + Ok(()) } From 12f32b22bf324a2f0ce51680ac4a7a47b009984f Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Sun, 20 Sep 2026 11:48:00 -0400 Subject: [PATCH 2/4] Extract the project edit rollback interface (#21859) Move `uv add`'s project and script snapshots, error rollback, and Ctrl-C handling into a shared `ProjectEdit` guard. The caller commits the edit after locking and syncing succeeds. We retain the existing snapshot contents, modification check, and rollback boundary so other editing commands can adopt the interface separately. --- crates/uv/src/commands/project/add.rs | 125 +++++-------------------- crates/uv/src/commands/project/edit.rs | 108 +++++++++++++++++++++ crates/uv/src/commands/project/mod.rs | 1 + 3 files changed, 132 insertions(+), 102 deletions(-) create mode 100644 crates/uv/src/commands/project/edit.rs diff --git a/crates/uv/src/commands/project/add.rs b/crates/uv/src/commands/project/add.rs index caad6bf9217..f680dab379c 100644 --- a/crates/uv/src/commands/project/add.rs +++ b/crates/uv/src/commands/project/add.rs @@ -54,6 +54,7 @@ use crate::commands::pip::loggers::{ DefaultInstallLogger, DefaultResolveLogger, SummaryResolveLogger, }; use crate::commands::pip::operations::Modifications; +use crate::commands::project::edit::{ProjectEdit, ProjectSnapshot}; use crate::commands::project::install_target::InstallTarget; use crate::commands::project::lock::LockMode; use crate::commands::project::lock_target::LockTarget; @@ -757,22 +758,7 @@ pub(crate) async fn add( // Update the `pypackage.toml` in-memory. let target = target.update(&content, &WorkspaceCache::default())?; - // Set the Ctrl-C handler to revert changes on exit. - let _ = ctrlc::set_handler({ - let snapshot = snapshot.clone(); - move || { - if modified { - let _ = snapshot.revert(); - } - - #[expect(clippy::exit, clippy::cast_possible_wrap)] - std::process::exit(if cfg!(windows) { - 0xC000_013A_u32 as i32 - } else { - 130 - }); - } - }); + let edit = ProjectEdit::new(snapshot, modified); // Use separate state for locking and syncing. let lock_state = state.fork(); @@ -811,28 +797,25 @@ pub(crate) async fn add( )) .await { - Ok(()) => Ok(ExitStatus::Success), - Err(err) => { - if modified { - let _ = snapshot.revert(); - } - match err { - ProjectError::Operation(err) => { - let standard_library_package = - standard_library_package(&err, &edits, python_minor); - Err(UvError::from(err) - .map_user(|cause| { - AddDependencyError { - cause, - standard_library_package, - } - .into() - }) - .into()) - } - err => Err(UvError::from(err).into()), - } + Ok(()) => { + edit.commit(); + Ok(ExitStatus::Success) } + Err(err) => match err { + ProjectError::Operation(err) => { + let standard_library_package = standard_library_package(&err, &edits, python_minor); + Err(UvError::from(err) + .map_user(|cause| { + AddDependencyError { + cause, + standard_library_package, + } + .into() + }) + .into()) + } + err => Err(UvError::from(err).into()), + }, } } @@ -1461,7 +1444,7 @@ impl AddTarget { } /// Take a snapshot of the target. - async fn snapshot(&self) -> Result { + async fn snapshot(&self) -> Result { // Read the lockfile into memory. let target = match self { Self::Script(script, _) => LockTarget::from(script), @@ -1473,70 +1456,8 @@ impl AddTarget { // breaking the assumption that the workspace cache is only used by the modifying code // when changing it. match self { - Self::Script(script, _) => Ok(AddTargetSnapshot::Script(script.clone(), lock)), - Self::Project(project, _) => { - Ok(AddTargetSnapshot::Project(project.clone_detach(), lock)) - } - } - } -} - -#[derive(Debug, Clone)] -#[expect(clippy::large_enum_variant)] -enum AddTargetSnapshot { - Script(Pep723Script, Option>), - Project(VirtualProject, Option>), -} - -impl AddTargetSnapshot { - /// Write the snapshot back to disk (e.g., to a `pyproject.toml` and `uv.lock`). - fn revert(&self) -> Result<(), io::Error> { - match self { - Self::Script(script, lock) => { - // Write the PEP 723 script back to disk. - debug!("Reverting changes to PEP 723 script block"); - script.write(&script.metadata.raw)?; - - // Write the lockfile back to disk. - let target = LockTarget::from(script); - if let Some(lock) = lock { - debug!("Reverting changes to `uv.lock`"); - fs_err::write(target.lock_path(), lock)?; - } else { - debug!("Removing `uv.lock`"); - fs_err::remove_file(target.lock_path())?; - } - Ok(()) - } - Self::Project(project, lock) => { - // Write the workspace `pyproject.toml` back to disk. - let workspace = project.workspace(); - if workspace.install_path() != project.root() { - debug!("Reverting changes to workspace `pyproject.toml`"); - fs_err::write( - workspace.install_path().join("pyproject.toml"), - workspace.pyproject_toml().as_ref(), - )?; - } - - // Write the `pyproject.toml` back to disk. - debug!("Reverting changes to `pyproject.toml`"); - fs_err::write( - project.root().join("pyproject.toml"), - project.pyproject_toml().as_ref(), - )?; - - // Write the lockfile back to disk. - let target = LockTarget::from(project.workspace()); - if let Some(lock) = lock { - debug!("Reverting changes to `uv.lock`"); - fs_err::write(target.lock_path(), lock)?; - } else { - debug!("Removing `uv.lock`"); - fs_err::remove_file(target.lock_path())?; - } - Ok(()) - } + Self::Script(script, _) => Ok(ProjectSnapshot::Script(script.clone(), lock)), + Self::Project(project, _) => Ok(ProjectSnapshot::Project(project.clone_detach(), lock)), } } } diff --git a/crates/uv/src/commands/project/edit.rs b/crates/uv/src/commands/project/edit.rs new file mode 100644 index 00000000000..35fe6d048dc --- /dev/null +++ b/crates/uv/src/commands/project/edit.rs @@ -0,0 +1,108 @@ +use std::io; + +use tracing::debug; +use uv_scripts::Pep723Script; +use uv_workspace::VirtualProject; + +use crate::commands::project::lock_target::LockTarget; + +/// Restore a project edit on errors or Ctrl-C unless it succeeds. +pub(super) struct ProjectEdit { + snapshot: Option, +} + +impl ProjectEdit { + /// Start guarding an edit once its files have been modified. + pub(super) fn new(snapshot: ProjectSnapshot, modified: bool) -> Self { + let snapshot = modified.then_some(snapshot); + let _ = ctrlc::set_handler({ + let snapshot = snapshot.clone(); + move || { + if let Some(snapshot) = &snapshot { + let _ = snapshot.revert(); + } + + #[expect(clippy::cast_possible_wrap)] + std::process::exit(if cfg!(windows) { + 0xC000_013A_u32 as i32 + } else { + 130 + }); + } + }); + Self { snapshot } + } + + /// Keep the edited files when the operation succeeds. + pub(super) fn commit(mut self) { + self.snapshot = None; + } +} + +impl Drop for ProjectEdit { + fn drop(&mut self) { + if let Some(snapshot) = &self.snapshot { + let _ = snapshot.revert(); + } + } +} + +#[derive(Debug, Clone)] +#[expect(clippy::large_enum_variant)] +pub(super) enum ProjectSnapshot { + Script(Pep723Script, Option>), + Project(VirtualProject, Option>), +} + +impl ProjectSnapshot { + /// Write the snapshot back to disk (e.g., to a `pyproject.toml` and `uv.lock`). + fn revert(&self) -> Result<(), io::Error> { + match self { + Self::Script(script, lock) => { + // Write the PEP 723 script back to disk. + debug!("Reverting changes to PEP 723 script block"); + script.write(&script.metadata.raw)?; + + // Write the lockfile back to disk. + let target = LockTarget::from(script); + if let Some(lock) = lock { + debug!("Reverting changes to `uv.lock`"); + fs_err::write(target.lock_path(), lock)?; + } else { + debug!("Removing `uv.lock`"); + fs_err::remove_file(target.lock_path())?; + } + Ok(()) + } + Self::Project(project, lock) => { + // Write the workspace `pyproject.toml` back to disk. + let workspace = project.workspace(); + if workspace.install_path() != project.root() { + debug!("Reverting changes to workspace `pyproject.toml`"); + fs_err::write( + workspace.install_path().join("pyproject.toml"), + workspace.pyproject_toml().as_ref(), + )?; + } + + // Write the `pyproject.toml` back to disk. + debug!("Reverting changes to `pyproject.toml`"); + fs_err::write( + project.root().join("pyproject.toml"), + project.pyproject_toml().as_ref(), + )?; + + // Write the lockfile back to disk. + let target = LockTarget::from(project.workspace()); + if let Some(lock) = lock { + debug!("Reverting changes to `uv.lock`"); + fs_err::write(target.lock_path(), lock)?; + } else { + debug!("Removing `uv.lock`"); + fs_err::remove_file(target.lock_path())?; + } + Ok(()) + } + } + } +} diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index 07d51f572e0..5f7a936a948 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -70,6 +70,7 @@ use crate::settings::{ pub(crate) mod add; pub(crate) mod audit; pub(crate) mod check; +mod edit; pub(crate) mod environment; pub(crate) mod export; pub(crate) mod format; From 5bd5672f3dd124b2365801f2da366ebe9c8a8edf Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Sun, 20 Sep 2026 12:06:39 -0400 Subject: [PATCH 3/4] Fix rollback for uv add (#21860) An unchanged, read-only workspace manifest can prevent `uv add` from restoring an edited member after a failure. Frozen edits also read the lockfile unnecessarily and can fail when it is unreadable. Snapshot the affected files directly, skip unchanged files during rollback, and attempt each restoration independently. Frozen edits exclude the lockfile. We also guard edits before the first write, so errors before locking and syncing restore the files, and clear the snapshot on success so a later interrupt cannot undo a committed edit. Builds on https://github.com/astral-sh/uv/pull/21859. --- crates/uv-workspace/src/workspace.rs | 16 -- crates/uv/src/commands/project/add.rs | 44 +++--- crates/uv/src/commands/project/edit.rs | 141 +++++++++--------- crates/uv/src/commands/project/lock_target.rs | 9 -- crates/uv/tests/project/edit.rs | 88 +++++++++++ 5 files changed, 176 insertions(+), 122 deletions(-) diff --git a/crates/uv-workspace/src/workspace.rs b/crates/uv-workspace/src/workspace.rs index 6ca86e31dd4..ada1c0ac5c9 100644 --- a/crates/uv-workspace/src/workspace.rs +++ b/crates/uv-workspace/src/workspace.rs @@ -2294,22 +2294,6 @@ impl VirtualProject { }) } - /// Clone while detaching from the original workspace `Arc`, freeing the original state for - /// modification. - /// - /// This is intended for rollbacks only. - #[must_use] - pub fn clone_detach(&self) -> Self { - match self { - Self::Project(project) => Self::Project(ProjectWorkspace { - project_root: project.project_root.clone(), - project_name: project.project_name.clone(), - workspace: Arc::new((*project.workspace).clone()), - }), - Self::NonProject(workspace) => Self::NonProject(Arc::new((**workspace).clone())), - } - } - /// Return the root of the project. pub fn root(&self) -> &Path { match self { diff --git a/crates/uv/src/commands/project/add.rs b/crates/uv/src/commands/project/add.rs index f680dab379c..b123b78387b 100644 --- a/crates/uv/src/commands/project/add.rs +++ b/crates/uv/src/commands/project/add.rs @@ -54,7 +54,7 @@ use crate::commands::pip::loggers::{ DefaultInstallLogger, DefaultResolveLogger, SummaryResolveLogger, }; use crate::commands::pip::operations::Modifications; -use crate::commands::project::edit::{ProjectEdit, ProjectSnapshot}; +use crate::commands::project::edit::ProjectEdit; use crate::commands::project::install_target::InstallTarget; use crate::commands::project::lock::LockMode; use crate::commands::project::lock_target::LockTarget; @@ -543,7 +543,20 @@ pub(crate) async fn add( } // Store the content prior to any modifications. - let snapshot = target.snapshot().await?; + let paths = match &target { + AddTarget::Script(script, _) => vec![script.path.clone()], + AddTarget::Project(project, _) => vec![ + project.root().join("pyproject.toml"), + project.workspace().install_path().join("pyproject.toml"), + ], + }; + let edit = ProjectEdit::new( + paths.into_iter().chain( + frozen + .is_none() + .then(|| LockTarget::from(&target).lock_path()), + ), + )?; // If the user provides a single, named index, pin all requirements to that index. let index = indexes @@ -555,9 +568,6 @@ pub(crate) async fn add( debug!("Pinning all requirements to index: `{index}`"); }); - // Track modification status, for reverts. - let mut modified = false; - // Determine whether to use workspace mode. let use_workspace = match workspace { Some(workspace) => workspace, @@ -587,6 +597,7 @@ pub(crate) async fn add( // If workspace mode is enabled, add any members to the `workspace` section of the // `pyproject.toml` file. if use_workspace { + let mut modified = false; let AddTarget::Project(project, python_target) = target else { unreachable!("`--workspace` and `--script` are conflicting options"); }; @@ -738,11 +749,12 @@ pub(crate) async fn add( let content = toml.to_string(); // Save the modified `pyproject.toml` or script. - modified |= target.write(&content)?; + target.write(&content)?; // If `--frozen`, exit early. There's no reason to lock and sync, since we don't need a `uv.lock` // to exist at all. if frozen.is_some() { + edit.commit(); return Ok(ExitStatus::Success); } @@ -758,8 +770,6 @@ pub(crate) async fn add( // Update the `pypackage.toml` in-memory. let target = target.update(&content, &WorkspaceCache::default())?; - let edit = ProjectEdit::new(snapshot, modified); - // Use separate state for locking and syncing. let lock_state = state.fork(); let sync_state = state; @@ -1442,24 +1452,6 @@ impl AddTarget { } } } - - /// Take a snapshot of the target. - async fn snapshot(&self) -> Result { - // Read the lockfile into memory. - let target = match self { - Self::Script(script, _) => LockTarget::from(script), - Self::Project(project, _) => LockTarget::Workspace(project.workspace()), - }; - let lock = target.read_bytes().await?; - - // Obtain a detached a copy of the old structure so we can revert to it without - // breaking the assumption that the workspace cache is only used by the modifying code - // when changing it. - match self { - Self::Script(script, _) => Ok(ProjectSnapshot::Script(script.clone(), lock)), - Self::Project(project, _) => Ok(ProjectSnapshot::Project(project.clone_detach(), lock)), - } - } } #[derive(Debug, Clone)] diff --git a/crates/uv/src/commands/project/edit.rs b/crates/uv/src/commands/project/edit.rs index 35fe6d048dc..8921fc4569f 100644 --- a/crates/uv/src/commands/project/edit.rs +++ b/crates/uv/src/commands/project/edit.rs @@ -1,26 +1,38 @@ +use std::collections::BTreeSet; use std::io; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, PoisonError}; -use tracing::debug; -use uv_scripts::Pep723Script; -use uv_workspace::VirtualProject; +use anyhow::Result; +use tracing::{debug, warn}; +use uv_fs::Simplified; -use crate::commands::project::lock_target::LockTarget; - -/// Restore a project edit on errors or Ctrl-C unless it succeeds. +/// Restore project or script files on errors and Ctrl-C, unless the edit is committed. +/// +/// Only changed files are restored. Callers must exclude files they cannot modify, such as +/// lockfiles when editing with `--frozen`. pub(super) struct ProjectEdit { - snapshot: Option, + files: Arc>>, } impl ProjectEdit { - /// Start guarding an edit once its files have been modified. - pub(super) fn new(snapshot: ProjectSnapshot, modified: bool) -> Self { - let snapshot = modified.then_some(snapshot); + /// Snapshot the files an operation can modify and install its Ctrl-C handler. + pub(super) fn new(paths: impl IntoIterator) -> Result { + let files = paths + .into_iter() + .collect::>() + .into_iter() + .map(|path| { + let contents = read_file(&path)?; + Ok(FileSnapshot { path, contents }) + }) + .collect::>>()?; + let files = Arc::new(Mutex::new(files)); + let _ = ctrlc::set_handler({ - let snapshot = snapshot.clone(); + let files = Arc::clone(&files); move || { - if let Some(snapshot) = &snapshot { - let _ = snapshot.revert(); - } + revert(&mut files.lock().unwrap_or_else(PoisonError::into_inner)); #[expect(clippy::cast_possible_wrap)] std::process::exit(if cfg!(windows) { @@ -30,79 +42,66 @@ impl ProjectEdit { }); } }); - Self { snapshot } + + Ok(Self { files }) } /// Keep the edited files when the operation succeeds. - pub(super) fn commit(mut self) { - self.snapshot = None; + pub(super) fn commit(self) { + self.files + .lock() + .unwrap_or_else(PoisonError::into_inner) + .clear(); } } impl Drop for ProjectEdit { fn drop(&mut self) { - if let Some(snapshot) = &self.snapshot { - let _ = snapshot.revert(); - } + revert(&mut self.files.lock().unwrap_or_else(PoisonError::into_inner)); } } -#[derive(Debug, Clone)] -#[expect(clippy::large_enum_variant)] -pub(super) enum ProjectSnapshot { - Script(Pep723Script, Option>), - Project(VirtualProject, Option>), +struct FileSnapshot { + path: PathBuf, + contents: Option>, } -impl ProjectSnapshot { - /// Write the snapshot back to disk (e.g., to a `pyproject.toml` and `uv.lock`). - fn revert(&self) -> Result<(), io::Error> { - match self { - Self::Script(script, lock) => { - // Write the PEP 723 script back to disk. - debug!("Reverting changes to PEP 723 script block"); - script.write(&script.metadata.raw)?; +impl FileSnapshot { + /// Restore the original contents, or remove a file created by the operation. + fn revert(&self) -> io::Result<()> { + // An unchanged file may be read-only, even when another file in the edit is writable. + if let Ok(contents) = read_file(&self.path) + && contents == self.contents + { + return Ok(()); + } - // Write the lockfile back to disk. - let target = LockTarget::from(script); - if let Some(lock) = lock { - debug!("Reverting changes to `uv.lock`"); - fs_err::write(target.lock_path(), lock)?; - } else { - debug!("Removing `uv.lock`"); - fs_err::remove_file(target.lock_path())?; - } - Ok(()) + debug!("Reverting changes to {}", self.path.user_display()); + if let Some(contents) = &self.contents { + fs_err::write(&self.path, contents) + } else { + match fs_err::remove_file(&self.path) { + Ok(()) => Ok(()), + Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()), + Err(err) => Err(err), } - Self::Project(project, lock) => { - // Write the workspace `pyproject.toml` back to disk. - let workspace = project.workspace(); - if workspace.install_path() != project.root() { - debug!("Reverting changes to workspace `pyproject.toml`"); - fs_err::write( - workspace.install_path().join("pyproject.toml"), - workspace.pyproject_toml().as_ref(), - )?; - } - - // Write the `pyproject.toml` back to disk. - debug!("Reverting changes to `pyproject.toml`"); - fs_err::write( - project.root().join("pyproject.toml"), - project.pyproject_toml().as_ref(), - )?; + } + } +} - // Write the lockfile back to disk. - let target = LockTarget::from(project.workspace()); - if let Some(lock) = lock { - debug!("Reverting changes to `uv.lock`"); - fs_err::write(target.lock_path(), lock)?; - } else { - debug!("Removing `uv.lock`"); - fs_err::remove_file(target.lock_path())?; - } - Ok(()) - } +/// Attempt every restoration even if an earlier file cannot be restored. +fn revert(files: &mut Vec) { + for file in files.drain(..) { + if let Err(err) = file.revert() { + warn!("Failed to restore {}: {err}", file.path.user_display()); } } } + +fn read_file(path: &Path) -> io::Result>> { + match fs_err::read(path) { + Ok(contents) => Ok(Some(contents)), + Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(None), + Err(err) => Err(err), + } +} diff --git a/crates/uv/src/commands/project/lock_target.rs b/crates/uv/src/commands/project/lock_target.rs index d2818016109..c21f9041f66 100644 --- a/crates/uv/src/commands/project/lock_target.rs +++ b/crates/uv/src/commands/project/lock_target.rs @@ -401,15 +401,6 @@ impl<'lock> LockTarget<'lock> { } } - /// Read the lockfile from the workspace as bytes. - pub(crate) async fn read_bytes(self) -> Result>, std::io::Error> { - match fs_err::tokio::read(self.lock_path()).await { - Ok(encoded) => Ok(Some(encoded)), - Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None), - Err(err) => Err(err), - } - } - /// Write the lockfile to disk. pub(crate) async fn commit(self, lock: &Lock) -> Result<(), ProjectError> { let encoded = lock.to_toml()?; diff --git a/crates/uv/tests/project/edit.rs b/crates/uv/tests/project/edit.rs index 354d641b48a..caa78bdf9e2 100644 --- a/crates/uv/tests/project/edit.rs +++ b/crates/uv/tests/project/edit.rs @@ -15,6 +15,8 @@ use indoc::{formatdoc, indoc}; use insta::assert_snapshot; use serde_json::json; use std::path::Path; +#[cfg(unix)] +use std::{fs::Permissions, os::unix::fs::PermissionsExt}; use url::Url; use wiremock::{ Mock, MockServer, ResponseTemplate, @@ -8472,6 +8474,92 @@ fn remove_include_default_groups() -> Result<()> { Ok(()) } +/// An unchanged, read-only workspace manifest must not prevent restoring the member. +#[test] +#[cfg(unix)] +fn add_locked_readonly_workspace() -> Result<()> { + let context = uv_test::test_context!("3.12"); + let workspace = context.temp_dir.child("pyproject.toml"); + workspace.write_str(indoc! {r#" + [tool.uv.workspace] + members = ["member"] + "#})?; + context + .temp_dir + .child("member/pyproject.toml") + .write_str(indoc! {r#" + [project] + name = "member" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [] + "#})?; + context.lock().assert().success(); + let member = context.read("member/pyproject.toml"); + let lock = context.read("uv.lock"); + fs_err::set_permissions(&workspace, Permissions::from_mode(0o444))?; + + uv_snapshot!(context.filters(), context.add().arg("iniconfig").arg("--package").arg("member").arg("--locked").arg("--no-sync"), @" + exit_code: 1 (failure) + ----- stderr ----- + Resolved 2 packages in [TIME] + error: The lockfile at `uv.lock` needs to be updated, but `--locked` was provided. + + hint: To update the lockfile, run `uv lock`. + "); + assert_eq!(context.read("member/pyproject.toml"), member); + assert_eq!(context.read("uv.lock"), lock); + Ok(()) +} + +/// Frozen edits must not read the lockfile. +#[test] +#[cfg(unix)] +fn add_remove_frozen_unreadable_lockfile() -> Result<()> { + let context = uv_test::test_context!("3.12"); + context + .temp_dir + .child("pyproject.toml") + .write_str(indoc! {r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [] + "#})?; + let lock = context.temp_dir.child("uv.lock"); + lock.write_str("unreadable lockfile\n")?; + fs_err::set_permissions(&lock, Permissions::from_mode(0o000))?; + + uv_snapshot!(context.filters(), context.add().arg("iniconfig").arg("--frozen"), @" + exit_code: 0 (success) + "); + assert_snapshot!(context.read("pyproject.toml"), @r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [ + "iniconfig", + ] + "#); + uv_snapshot!(context.filters(), context.remove().arg("iniconfig").arg("--frozen"), @" + exit_code: 0 (success) + "); + assert_snapshot!(context.read("pyproject.toml"), @r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [] + "#); + fs_err::set_permissions(&lock, Permissions::from_mode(0o644))?; + assert_snapshot!(context.read("uv.lock"), @" + unreadable lockfile + "); + Ok(()) +} + /// Revert changes to the `pyproject.toml` and `uv.lock` when the `add` operation fails. #[test] fn fail_to_add_revert_project() -> Result<()> { From 09f725dfd2d7876d82d77336f108eeb0749325e7 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Sun, 20 Sep 2026 12:19:05 -0400 Subject: [PATCH 4/4] Restore project files when remove and version fail (#21856) `uv version --bump minor --locked` leaves the version bumped when it rejects the stale lockfile. `uv remove` can similarly leave the manifest changed after locking or syncing fails. Use the shared project edit guard in `uv remove` and `uv version`, matching `uv add`'s rollback on errors and Ctrl-C. We restore the original manifest or script and lockfile, removing the lockfile if the failed command created it. Frozen edits continue to skip the lockfile. Builds on https://github.com/astral-sh/uv/pull/21860. Closes https://github.com/astral-sh/uv/issues/21854. Co-authored-by: github-actions[bot] Co-authored-by: astral-automations-bot[bot] <305554984+astral-automations-bot[bot]@users.noreply.github.com> --- crates/uv/src/commands/project/remove.rs | 19 +++ crates/uv/src/commands/project/version.rs | 14 +- crates/uv/tests/it/version.rs | 153 ++++++++++++++++++++++ crates/uv/tests/project/edit.rs | 135 +++++++++++++++++++ 4 files changed, 319 insertions(+), 2 deletions(-) diff --git a/crates/uv/src/commands/project/remove.rs b/crates/uv/src/commands/project/remove.rs index 46610e42457..fe3fa0b7cc4 100644 --- a/crates/uv/src/commands/project/remove.rs +++ b/crates/uv/src/commands/project/remove.rs @@ -27,6 +27,7 @@ use uv_workspace::{DiscoveryOptions, VirtualProject, WorkspaceCache}; use crate::commands::pip::loggers::{DefaultInstallLogger, DefaultResolveLogger}; use crate::commands::pip::operations::Modifications; use crate::commands::project::add::{AddTarget, PythonTarget}; +use crate::commands::project::edit::ProjectEdit; use crate::commands::project::install_target::InstallTarget; use crate::commands::project::lock::LockMode; use crate::commands::project::lock_target::LockTarget; @@ -188,12 +189,26 @@ pub(crate) async fn remove( let content = toml.to_string(); + let (path, lock_target) = match &target { + RemoveTarget::Script(script) => (script.path.clone(), LockTarget::from(script)), + RemoveTarget::Project(project) => ( + project.root().join("pyproject.toml"), + LockTarget::from(project.workspace()), + ), + }; + let edit = ProjectEdit::new( + [path] + .into_iter() + .chain(frozen.is_none().then(|| lock_target.lock_path())), + )?; + // Save the modified `pyproject.toml` or script. target.write(&content)?; // If `--frozen`, exit early. There's no reason to lock and sync, since we don't need a `uv.lock` // to exist at all. if frozen.is_some() { + edit.commit(); return Ok(ExitStatus::Success); } @@ -205,6 +220,7 @@ pub(crate) async fn remove( "Updated `{}`", script.path.user_display().cyan() )?; + edit.commit(); return Ok(ExitStatus::Success); } } @@ -338,11 +354,13 @@ pub(crate) async fn remove( let AddTarget::Project(project, environment) = target else { // If we're not adding to a project, exit early. + edit.commit(); return Ok(ExitStatus::Success); }; let PythonTarget::Environment(venv) = &*environment else { // If we're not syncing, exit early. + edit.commit(); return Ok(ExitStatus::Success); }; @@ -389,6 +407,7 @@ pub(crate) async fn remove( Err(err) => return Err(UvError::from(err).into()), } + edit.commit(); Ok(ExitStatus::Success) } diff --git a/crates/uv/src/commands/project/version.rs b/crates/uv/src/commands/project/version.rs index bee8a2990ce..cd879d59734 100644 --- a/crates/uv/src/commands/project/version.rs +++ b/crates/uv/src/commands/project/version.rs @@ -32,6 +32,7 @@ use uv_workspace::{ use crate::commands::pip::loggers::{DefaultInstallLogger, DefaultResolveLogger}; use crate::commands::pip::operations::Modifications; use crate::commands::project::add::{AddTarget, PythonTarget}; +use crate::commands::project::edit::ProjectEdit; use crate::commands::project::install_target::InstallTarget; use crate::commands::project::lock::LockMode; use crate::commands::project::lock_target::LockTarget; @@ -331,6 +332,13 @@ pub(crate) async fn project_version( let status = if dry_run { ExitStatus::Success } else if let Some(new_version) = &new_version { + let edit = ProjectEdit::new( + [pyproject_path.clone()].into_iter().chain( + frozen + .is_none() + .then(|| LockTarget::from(project.workspace()).lock_path()), + ), + )?; let project = update_project( project, new_version, @@ -338,7 +346,7 @@ pub(crate) async fn project_version( &pyproject_path, workspace_cache, )?; - Box::pin(lock_and_sync( + let status = Box::pin(lock_and_sync( project, project_dir, lock_check, @@ -359,7 +367,9 @@ pub(crate) async fn project_version( preview, &malware_settings, )) - .await? + .await?; + edit.commit(); + status } else { debug!("No changes to version; skipping update"); ExitStatus::Success diff --git a/crates/uv/tests/it/version.rs b/crates/uv/tests/it/version.rs index e5b3ec7bdeb..53b202b454c 100644 --- a/crates/uv/tests/it/version.rs +++ b/crates/uv/tests/it/version.rs @@ -1,3 +1,7 @@ +#[cfg(unix)] +use std::fs::Permissions; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; use std::path::Path; use anyhow::{Ok, Result}; @@ -2559,6 +2563,155 @@ fn version_get_frozen_workspace_without_python() -> Result<()> { Ok(()) } +#[test] +fn version_bump_locked_preserves_pyproject() -> Result<()> { + let context = uv_test::test_context!("3.12"); + + context + .temp_dir + .child("pyproject.toml") + .write_str(indoc! {r#" + [project] + name = "myproject" + version = "0.1.0" + requires-python = ">=3.12" + "#})?; + + uv_snapshot!(context.filters(), context.lock(), @" + exit_code: 0 (success) + ----- stderr ----- + Resolved 1 package in [TIME] + "); + + uv_snapshot!(context.filters(), context.version() + .arg("--bump").arg("minor") + .arg("--locked"), @" + exit_code: 1 (failure) + ----- stderr ----- + Resolved 1 package in [TIME] + error: The lockfile at `uv.lock` needs to be updated, but `--locked` was provided. + + hint: To update the lockfile, run `uv lock`. + "); + + // A failed version change should leave the project and lockfile consistent. + assert_snapshot!(context.read("pyproject.toml"), @r#" + [project] + name = "myproject" + version = "0.1.0" + requires-python = ">=3.12" + "#); + assert_snapshot!(context.read("uv.lock"), @r#" + version = 1 + revision = 3 + requires-python = ">=3.12" + + [options] + exclude-newer = "2024-03-25T00:00:00Z" + + [[package]] + name = "myproject" + version = "0.1.0" + source = { virtual = "." } + "#); + + Ok(()) +} + +#[test] +#[cfg(unix)] +fn version_bump_locked_readonly_workspace() -> Result<()> { + let context = uv_test::test_context!("3.12"); + + let workspace = context.temp_dir.child("pyproject.toml"); + workspace.write_str(indoc! {r#" + [tool.uv.workspace] + members = ["member"] + "#})?; + context + .temp_dir + .child("member/pyproject.toml") + .write_str(indoc! {r#" + [project] + name = "myproject" + version = "0.1.0" + requires-python = ">=3.12" + "#})?; + + uv_snapshot!(context.filters(), context.lock(), @" + exit_code: 0 (success) + ----- stderr ----- + Resolved 1 package in [TIME] + "); + let lock = context.read("uv.lock"); + + // Updating a member does not require writing to the workspace's metadata. + fs_err::set_permissions(&workspace, Permissions::from_mode(0o444))?; + + uv_snapshot!(context.filters(), context.version() + .arg("--package").arg("myproject") + .arg("--bump").arg("minor") + .arg("--locked") + .arg("--no-sync"), @" + exit_code: 1 (failure) + ----- stderr ----- + Resolved 1 package in [TIME] + error: The lockfile at `uv.lock` needs to be updated, but `--locked` was provided. + + hint: To update the lockfile, run `uv lock`. + "); + + assert_snapshot!(context.read("member/pyproject.toml"), @r#" + [project] + name = "myproject" + version = "0.1.0" + requires-python = ">=3.12" + "#); + assert_eq!(context.read("uv.lock"), lock); + + Ok(()) +} + +#[test] +#[cfg(unix)] +fn version_bump_frozen_unreadable_lockfile() -> Result<()> { + let context = uv_test::test_context!("3.12"); + + context + .temp_dir + .child("pyproject.toml") + .write_str(indoc! {r#" + [project] + name = "myproject" + version = "0.1.0" + requires-python = ">=3.12" + "#})?; + let lock = context.temp_dir.child("uv.lock"); + lock.write_str("unreadable lockfile\n")?; + fs_err::set_permissions(&lock, Permissions::from_mode(0o000))?; + + uv_snapshot!(context.filters(), context.version() + .arg("--bump").arg("minor") + .arg("--frozen"), @" + exit_code: 0 (success) + ----- stdout ----- + myproject 0.1.0 => 0.2.0 + "); + + assert_snapshot!(context.read("pyproject.toml"), @r#" + [project] + name = "myproject" + version = "0.2.0" + requires-python = ">=3.12" + "#); + fs_err::set_permissions(&lock, Permissions::from_mode(0o644))?; + assert_snapshot!(context.read("uv.lock"), @" + unreadable lockfile + "); + + Ok(()) +} + /// Edit the version of a workspace member /// /// Also check that --locked/--frozen/--no-sync do what they say diff --git a/crates/uv/tests/project/edit.rs b/crates/uv/tests/project/edit.rs index caa78bdf9e2..ef8edb80e03 100644 --- a/crates/uv/tests/project/edit.rs +++ b/crates/uv/tests/project/edit.rs @@ -8474,6 +8474,37 @@ fn remove_include_default_groups() -> Result<()> { Ok(()) } +/// A failed removal must not leave the manifest inconsistent with its lockfile. +#[test] +fn remove_locked_reverts_project() -> Result<()> { + let context = uv_test::test_context!("3.12"); + context + .temp_dir + .child("pyproject.toml") + .write_str(indoc! {r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["iniconfig"] + "#})?; + context.lock().assert().success(); + let pyproject = context.read("pyproject.toml"); + let lock = context.read("uv.lock"); + + uv_snapshot!(context.filters(), context.remove().arg("iniconfig").arg("--locked").arg("--no-sync"), @" + exit_code: 1 (failure) + ----- stderr ----- + Resolved 1 package in [TIME] + error: The lockfile at `uv.lock` needs to be updated, but `--locked` was provided. + + hint: To update the lockfile, run `uv lock`. + "); + assert_eq!(context.read("pyproject.toml"), pyproject); + assert_eq!(context.read("uv.lock"), lock); + Ok(()) +} + /// An unchanged, read-only workspace manifest must not prevent restoring the member. #[test] #[cfg(unix)] @@ -8560,6 +8591,110 @@ fn add_remove_frozen_unreadable_lockfile() -> Result<()> { Ok(()) } +/// Restore both files when syncing fails after resolution has written a lockfile. +#[test] +fn remove_version_build_failure_reverts_project() -> Result<()> { + for args in [ + &["remove", "iniconfig"][..], + &["version", "--bump", "minor"][..], + ] { + for locked in [false, true] { + let context = uv_test::test_context!("3.12"); + context + .temp_dir + .child("pyproject.toml") + .write_str(indoc! {r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["iniconfig"] + + [build-system] + requires = [] + build-backend = "backend" + backend-path = ["."] + "#})?; + context.temp_dir.child("backend.py").write_str(indoc! {r#" + from pathlib import Path + + def build_editable(*args, **kwargs): + Path(__file__).with_name("built").touch() + raise RuntimeError("build failed") + "#})?; + if locked { + context.lock().assert().success(); + } + let pyproject = context.read("pyproject.toml"); + let lock = locked.then(|| context.read("uv.lock")); + + context.command().args(args).assert().code(1); + assert!(context.temp_dir.join("built").exists(), "{args:?}"); + assert_eq!(context.read("pyproject.toml"), pyproject, "{args:?}"); + assert_eq!( + fs_err::read_to_string(context.temp_dir.join("uv.lock")).ok(), + lock, + "{args:?}" + ); + } + } + Ok(()) +} + +/// Interrupt during a build, after the manifest and lockfile have both been written. +#[test] +#[cfg(unix)] +fn edit_interrupt_reverts_project() -> Result<()> { + for args in [ + &["add", "iniconfig", "--dev"][..], + &["remove", "iniconfig"][..], + &["version", "--bump", "minor"][..], + ] { + for locked in [false, true] { + let context = uv_test::test_context!("3.12"); + context + .temp_dir + .child("pyproject.toml") + .write_str(indoc! {r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["iniconfig"] + + [build-system] + requires = [] + build-backend = "backend" + backend-path = ["."] + "#})?; + context.temp_dir.child("backend.py").write_str(indoc! {r#" + import os + import signal + import time + + def build_editable(*args, **kwargs): + os.kill(os.getppid(), signal.SIGINT) + time.sleep(1) + raise RuntimeError("build interrupted") + "#})?; + if locked { + context.lock().assert().success(); + } + let pyproject = context.read("pyproject.toml"); + let lock = locked.then(|| context.read("uv.lock")); + + context.command().args(args).assert().code(130); + assert_eq!(context.read("pyproject.toml"), pyproject, "{args:?}"); + assert_eq!( + fs_err::read_to_string(context.temp_dir.join("uv.lock")).ok(), + lock, + "{args:?}" + ); + } + } + Ok(()) +} + /// Revert changes to the `pyproject.toml` and `uv.lock` when the `add` operation fails. #[test] fn fail_to_add_revert_project() -> Result<()> {