Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 0 additions & 16 deletions crates/uv-workspace/src/workspace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
159 changes: 36 additions & 123 deletions crates/uv/src/commands/project/add.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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");
};
Expand Down Expand Up @@ -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);
}

Expand All @@ -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;
Expand Down Expand Up @@ -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()),
},
}
}

Expand Down Expand Up @@ -1459,86 +1452,6 @@ impl AddTarget {
}
}
}

/// Take a snapshot of the target.
async fn snapshot(&self) -> Result<AddTargetSnapshot, io::Error> {
// 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<Vec<u8>>),
Project(VirtualProject, Option<Vec<u8>>),
}

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)]
Expand Down
107 changes: 107 additions & 0 deletions crates/uv/src/commands/project/edit.rs
Original file line number Diff line number Diff line change
@@ -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<Mutex<Vec<FileSnapshot>>>,
}

impl ProjectEdit {
/// Snapshot the files an operation can modify and install its Ctrl-C handler.
pub(super) fn new(paths: impl IntoIterator<Item = PathBuf>) -> Result<Self> {
let files = paths
.into_iter()
.collect::<BTreeSet<_>>()
.into_iter()
.map(|path| {
let contents = read_file(&path)?;
Ok(FileSnapshot { path, contents })
})
.collect::<io::Result<Vec<_>>>()?;
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<Vec<u8>>,
}

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<FileSnapshot>) {
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<Option<Vec<u8>>> {
match fs_err::read(path) {
Ok(contents) => Ok(Some(contents)),
Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(None),
Err(err) => Err(err),
}
}
9 changes: 0 additions & 9 deletions crates/uv/src/commands/project/lock_target.rs
Original file line number Diff line number Diff line change
Expand Up @@ -401,15 +401,6 @@ impl<'lock> LockTarget<'lock> {
}
}

/// Read the lockfile from the workspace as bytes.
pub(crate) async fn read_bytes(self) -> Result<Option<Vec<u8>>, 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()?;
Expand Down
1 change: 1 addition & 0 deletions crates/uv/src/commands/project/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading