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 caad6bf9217..b123b78387b 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; use crate::commands::project::install_target::InstallTarget; use crate::commands::project::lock::LockMode; use crate::commands::project::lock_target::LockTarget; @@ -542,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 @@ -554,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, @@ -586,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"); }; @@ -737,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); } @@ -757,23 +770,6 @@ 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 - }); - } - }); - // Use separate state for locking and syncing. let lock_state = state.fork(); let sync_state = state; @@ -811,28 +807,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()), + }, } } @@ -1459,86 +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(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(()) - } - } - } } #[derive(Debug, Clone)] diff --git a/crates/uv/src/commands/project/edit.rs b/crates/uv/src/commands/project/edit.rs new file mode 100644 index 00000000000..8921fc4569f --- /dev/null +++ b/crates/uv/src/commands/project/edit.rs @@ -0,0 +1,107 @@ +use std::collections::BTreeSet; +use std::io; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, PoisonError}; + +use anyhow::Result; +use tracing::{debug, warn}; +use uv_fs::Simplified; + +/// 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 { + files: Arc>>, +} + +impl ProjectEdit { + /// 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 files = Arc::clone(&files); + move || { + revert(&mut files.lock().unwrap_or_else(PoisonError::into_inner)); + + #[expect(clippy::cast_possible_wrap)] + std::process::exit(if cfg!(windows) { + 0xC000_013A_u32 as i32 + } else { + 130 + }); + } + }); + + Ok(Self { files }) + } + + /// Keep the edited files when the operation succeeds. + pub(super) fn commit(self) { + self.files + .lock() + .unwrap_or_else(PoisonError::into_inner) + .clear(); + } +} + +impl Drop for ProjectEdit { + fn drop(&mut self) { + revert(&mut self.files.lock().unwrap_or_else(PoisonError::into_inner)); + } +} + +struct FileSnapshot { + path: PathBuf, + contents: Option>, +} + +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(()); + } + + 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), + } + } + } +} + +/// 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/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; 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 354d641b48a..ef8edb80e03 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,227 @@ 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)] +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(()) +} + +/// 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<()> { 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(()) }