diff --git a/crates/uv-resolver/src/lib.rs b/crates/uv-resolver/src/lib.rs index db790c35a527..8131804c32c0 100644 --- a/crates/uv-resolver/src/lib.rs +++ b/crates/uv-resolver/src/lib.rs @@ -1,5 +1,5 @@ pub use dependency_mode::DependencyMode; -pub use error::ResolveError; +pub use error::{NoSolutionError, ResolveError}; pub use exclude_newer::ExcludeNewer; pub use exclusions::Exclusions; pub use flat_index::FlatIndex; diff --git a/crates/uv-workspace/src/pyproject.rs b/crates/uv-workspace/src/pyproject.rs index 501761fa049a..e6126ab08fbc 100644 --- a/crates/uv-workspace/src/pyproject.rs +++ b/crates/uv-workspace/src/pyproject.rs @@ -50,6 +50,12 @@ impl PartialEq for PyProjectToml { impl Eq for PyProjectToml {} +impl AsRef<[u8]> for PyProjectToml { + fn as_ref(&self) -> &[u8] { + self.raw.as_bytes() + } +} + /// PEP 621 project metadata (`project`). /// /// See . diff --git a/crates/uv/src/commands/project/add.rs b/crates/uv/src/commands/project/add.rs index 1e4da31e1796..e7a432aa9d31 100644 --- a/crates/uv/src/commands/project/add.rs +++ b/crates/uv/src/commands/project/add.rs @@ -20,8 +20,9 @@ use uv_workspace::{DiscoveryOptions, ProjectWorkspace, VirtualProject, Workspace use crate::commands::pip::operations::Modifications; use crate::commands::pip::resolution_environment; +use crate::commands::project::ProjectError; use crate::commands::reporters::ResolverReporter; -use crate::commands::{project, ExitStatus, SharedState}; +use crate::commands::{pip, project, ExitStatus, SharedState}; use crate::printer::Printer; use crate::settings::ResolverInstallerSettings; @@ -154,7 +155,8 @@ pub(crate) async fn add( .await?; // Add the requirements to the `pyproject.toml`. - let mut pyproject = PyProjectTomlMut::from_toml(project.current_project().pyproject_toml())?; + let existing = project.current_project().pyproject_toml(); + let mut pyproject = PyProjectTomlMut::from_toml(existing)?; for mut req in requirements { // Add the specified extras. req.extras.extend(extras.iter().cloned()); @@ -221,7 +223,7 @@ pub(crate) async fn add( let state = SharedState::default(); // Lock and sync the environment, if necessary. - let lock = project::lock::do_safe_lock( + let lock = match project::lock::do_safe_lock( locked, frozen, project.workspace(), @@ -235,7 +237,26 @@ pub(crate) async fn add( cache, printer, ) - .await?; + .await + { + Ok(lock) => lock, + Err(ProjectError::Operation(pip::operations::Error::Resolve( + uv_resolver::ResolveError::NoSolution(err), + ))) => { + let header = err.header(); + let report = miette::Report::new(WithHelp { header, cause: err, help: Some("If this is intentional, run `uv add --frozen` to skip the lock and sync steps.") }); + anstream::eprint!("{report:?}"); + + // Revert the changes to the `pyproject.toml`. + fs_err::write( + project.current_project().root().join("pyproject.toml"), + existing, + )?; + + return Ok(ExitStatus::Failure); + } + Err(err) => return Err(err.into()), + }; // Perform a full sync, because we don't know what exactly is affected by the removal. // TODO(ibraheem): Should we accept CLI overrides for this? Should we even sync here? @@ -262,3 +283,20 @@ pub(crate) async fn add( Ok(ExitStatus::Success) } + +/// Render a [`uv_resolver::NoSolutionError`] with a help message. +#[derive(Debug, miette::Diagnostic, thiserror::Error)] +#[error("{header}")] +#[diagnostic()] +struct WithHelp { + /// The header to render in the error message. + header: String, + + /// The underlying error. + #[source] + cause: uv_resolver::NoSolutionError, + + /// The help message to display. + #[help] + help: Option<&'static str>, +} diff --git a/crates/uv/tests/edit.rs b/crates/uv/tests/edit.rs index adcf7207f14c..dea610c980c4 100644 --- a/crates/uv/tests/edit.rs +++ b/crates/uv/tests/edit.rs @@ -2101,3 +2101,46 @@ fn add_reject_multiple_git_ref_flags() { "### ); } + +/// Avoiding persisting `add` calls when resolution fails. +#[test] +fn add_error() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(indoc! {r#" + [project] + name = "project" + version = "0.1.0" + # ... + requires-python = ">=3.12" + dependencies = [] + "#})?; + + uv_snapshot!(context.filters(), context.add(&["xyz"]), @r###" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + warning: `uv add` is experimental and may change without warning + × No solution found when resolving dependencies: + ╰─▶ Because there are no versions of xyz and project==0.1.0 depends on xyz, we can conclude that project==0.1.0 cannot be used. + And because only project==0.1.0 is available and you require project, we can conclude that the requirements are unsatisfiable. + help: If this is intentional, run `uv add --frozen` to skip the lock and sync steps. + "###); + + uv_snapshot!(context.filters(), context.add(&["xyz"]).arg("--frozen"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv add` is experimental and may change without warning + "###); + + let lock = context.temp_dir.join("uv.lock"); + assert!(!lock.exists()); + + Ok(()) +}