diff --git a/crates/uv-resolver/src/error.rs b/crates/uv-resolver/src/error.rs index d57a17935..513b99731 100644 --- a/crates/uv-resolver/src/error.rs +++ b/crates/uv-resolver/src/error.rs @@ -128,17 +128,7 @@ pub struct NoSolutionError { } impl NoSolutionError { - pub fn header(&self) -> String { - match &self.markers { - ResolverMarkers::Universal { .. } | ResolverMarkers::SpecificEnvironment(_) => { - "No solution found when resolving dependencies:".to_string() - } - ResolverMarkers::Fork(markers) => { - format!("No solution found when resolving dependencies for split ({markers:?}):") - } - } - } - + /// Create a new [`NoSolutionError`] from a [`pubgrub::NoSolutionError`]. pub(crate) fn new( error: pubgrub::NoSolutionError, available_versions: FxHashMap>, @@ -206,6 +196,11 @@ impl NoSolutionError { collapse(derivation_tree) .expect("derivation tree should contain at least one external term") } + + /// Initialize a [`NoSolutionHeader`] for this error. + pub fn header(&self) -> NoSolutionHeader { + NoSolutionHeader::new(self.markers.clone()) + } } impl std::error::Error for NoSolutionError {} @@ -236,3 +231,58 @@ impl std::fmt::Display for NoSolutionError { Ok(()) } } + +#[derive(Debug)] +pub struct NoSolutionHeader { + /// The [`ResolverMarkers`] that caused the failure. + markers: ResolverMarkers, + /// The additional context for the resolution failure. + context: Option<&'static str>, +} + +impl NoSolutionHeader { + /// Create a new [`NoSolutionHeader`] with the given [`ResolverMarkers`]. + pub fn new(markers: ResolverMarkers) -> Self { + Self { + markers, + context: None, + } + } + + /// Set the context for the resolution failure. + #[must_use] + pub fn with_context(mut self, context: &'static str) -> Self { + self.context = Some(context); + self + } +} + +impl std::fmt::Display for NoSolutionHeader { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match &self.markers { + ResolverMarkers::SpecificEnvironment(_) | ResolverMarkers::Universal { .. } => { + if let Some(context) = self.context { + write!( + f, + "No solution found when resolving {context} dependencies:" + ) + } else { + write!(f, "No solution found when resolving dependencies:") + } + } + ResolverMarkers::Fork(markers) => { + if let Some(context) = self.context { + write!( + f, + "No solution found when resolving {context} dependencies for split ({markers:?}):", + ) + } else { + write!( + f, + "No solution found when resolving dependencies for split ({markers:?}):", + ) + } + } + } + } +} diff --git a/crates/uv-resolver/src/lib.rs b/crates/uv-resolver/src/lib.rs index a111db522..6d9685da5 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::{NoSolutionError, ResolveError}; +pub use error::{NoSolutionError, NoSolutionHeader, ResolveError}; pub use exclude_newer::ExcludeNewer; pub use exclusions::Exclusions; pub use flat_index::FlatIndex; diff --git a/crates/uv/src/commands/project/add.rs b/crates/uv/src/commands/project/add.rs index 727d61f70..30d9a3eaf 100644 --- a/crates/uv/src/commands/project/add.rs +++ b/crates/uv/src/commands/project/add.rs @@ -440,7 +440,7 @@ struct DependencyEdit<'a> { #[diagnostic()] struct WithHelp { /// The header to render in the error message. - header: String, + header: uv_resolver::NoSolutionHeader, /// The underlying error. #[source] diff --git a/crates/uv/src/commands/project/environment.rs b/crates/uv/src/commands/project/environment.rs index 5bb00f28f..a06bae10f 100644 --- a/crates/uv/src/commands/project/environment.rs +++ b/crates/uv/src/commands/project/environment.rs @@ -1,10 +1,5 @@ use tracing::debug; -use crate::commands::pip::loggers::{InstallLogger, ResolveLogger}; -use crate::commands::project::{resolve_environment, sync_environment}; -use crate::commands::SharedState; -use crate::printer::Printer; -use crate::settings::ResolverInstallerSettings; use cache_key::{cache_digest, hash_digest}; use distribution_types::Resolution; use uv_cache::{Cache, CacheBucket}; @@ -13,6 +8,12 @@ use uv_configuration::{Concurrency, PreviewMode}; use uv_python::{Interpreter, PythonEnvironment}; use uv_requirements::RequirementsSpecification; +use crate::commands::pip::loggers::{InstallLogger, ResolveLogger}; +use crate::commands::project::{resolve_environment, sync_environment, ProjectError}; +use crate::commands::SharedState; +use crate::printer::Printer; +use crate::settings::ResolverInstallerSettings; + /// A [`PythonEnvironment`] stored in the cache. #[derive(Debug)] pub(crate) struct CachedEnvironment(PythonEnvironment); @@ -39,7 +40,7 @@ impl CachedEnvironment { native_tls: bool, cache: &Cache, printer: Printer, - ) -> anyhow::Result { + ) -> Result { // When caching, always use the base interpreter, rather than that of the virtual // environment. let interpreter = if let Some(interpreter) = interpreter.to_base_interpreter(cache)? { diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index dd2c1c292..90ec23206 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -90,6 +90,18 @@ pub(crate) enum ProjectError { #[error(transparent)] Lock(#[from] uv_resolver::LockError), + #[error(transparent)] + Operation(#[from] pip::operations::Error), + + #[error(transparent)] + RequiresPython(#[from] uv_resolver::RequiresPythonError), + + #[error(transparent)] + Interpreter(#[from] uv_python::InterpreterError), + + #[error(transparent)] + Tool(#[from] uv_tool::Error), + #[error(transparent)] Fmt(#[from] std::fmt::Error), @@ -98,12 +110,6 @@ pub(crate) enum ProjectError { #[error(transparent)] Anyhow(#[from] anyhow::Error), - - #[error(transparent)] - Operation(#[from] pip::operations::Error), - - #[error(transparent)] - RequiresPython(#[from] uv_resolver::RequiresPythonError), } /// Compute the `Requires-Python` bound for the [`Workspace`]. @@ -500,7 +506,7 @@ pub(crate) async fn resolve_environment<'a>( native_tls: bool, cache: &Cache, printer: Printer, -) -> anyhow::Result { +) -> Result { warn_on_requirements_txt_setting(&spec, settings); let ResolverSettingsRef { diff --git a/crates/uv/src/commands/project/run.rs b/crates/uv/src/commands/project/run.rs index 65958a0c1..e736a9fef 100644 --- a/crates/uv/src/commands/project/run.rs +++ b/crates/uv/src/commands/project/run.rs @@ -4,7 +4,8 @@ use std::ffi::OsString; use std::fmt::Write; use std::path::{Path, PathBuf}; -use anyhow::{anyhow, bail, Context, Result}; +use anstream::eprint; +use anyhow::{anyhow, bail, Context}; use itertools::Itertools; use owo_colors::OwoColorize; use tokio::process::Command; @@ -23,13 +24,14 @@ use uv_python::{ PythonEnvironment, PythonInstallation, PythonPreference, PythonRequest, VersionRequest, }; use uv_requirements::{RequirementsSource, RequirementsSpecification}; -use uv_scripts::Pep723Script; +use uv_scripts::{Pep723Error, Pep723Script}; use uv_warnings::warn_user_once; use uv_workspace::{DiscoveryOptions, VirtualProject, Workspace, WorkspaceError}; use crate::commands::pip::loggers::{ DefaultInstallLogger, DefaultResolveLogger, SummaryInstallLogger, SummaryResolveLogger, }; +use crate::commands::pip::operations; use crate::commands::pip::operations::Modifications; use crate::commands::project::environment::CachedEnvironment; use crate::commands::project::{ProjectError, WorkspacePython}; @@ -62,7 +64,7 @@ pub(crate) async fn run( native_tls: bool, cache: &Cache, printer: Printer, -) -> Result { +) -> anyhow::Result { if preview.is_disabled() { warn_user_once!("`uv run` is experimental and may change without warning"); } @@ -162,7 +164,7 @@ pub(crate) async fn run( }) .collect::>()?; let spec = RequirementsSpecification::from_requirements(requirements); - let environment = CachedEnvironment::get_or_create( + let result = CachedEnvironment::get_or_create( spec, interpreter, &settings, @@ -184,7 +186,20 @@ pub(crate) async fn run( cache, printer, ) - .await?; + .await; + + let environment = match result { + Ok(resolution) => resolution, + Err(ProjectError::Operation(operations::Error::Resolve( + uv_resolver::ResolveError::NoSolution(err), + ))) => { + let report = miette::Report::msg(format!("{err}")) + .context(err.header().with_context("script")); + eprint!("{report:?}"); + return Ok(ExitStatus::Failure); + } + Err(err) => return Err(err.into()), + }; Some(environment.into_interpreter()) } else { @@ -515,7 +530,7 @@ pub(crate) async fn run( Some(spec) => { debug!("Syncing ephemeral requirements"); - CachedEnvironment::get_or_create( + let result = CachedEnvironment::get_or_create( spec, base_interpreter.clone(), &settings, @@ -537,8 +552,22 @@ pub(crate) async fn run( cache, printer, ) - .await? - .into() + .await; + + let environment = match result { + Ok(resolution) => resolution, + Err(ProjectError::Operation(operations::Error::Resolve( + uv_resolver::ResolveError::NoSolution(err), + ))) => { + let report = miette::Report::msg(format!("{err}")) + .context(err.header().with_context("`--with`")); + eprint!("{report:?}"); + return Ok(ExitStatus::Failure); + } + Err(err) => return Err(err.into()), + }; + + environment.into() } }) }; @@ -612,7 +641,9 @@ pub(crate) async fn run( } /// Read a [`Pep723Script`] from the given command. -pub(crate) async fn parse_script(command: &ExternalCommand) -> Result> { +pub(crate) async fn parse_script( + command: &ExternalCommand, +) -> Result, Pep723Error> { // Parse the input command. let command = RunCommand::from(command); @@ -621,7 +652,7 @@ pub(crate) async fn parse_script(command: &ExternalCommand) -> Result Result { +) -> anyhow::Result { if preview.is_disabled() { warn_user_once!("`{invocation_source}` is experimental and may change without warning"); } @@ -98,7 +99,7 @@ pub(crate) async fn run( }; // Get or create a compatible environment in which to execute the tool. - let (from, environment) = get_or_create_environment( + let result = get_or_create_environment( &from, with, show_resolution, @@ -114,7 +115,20 @@ pub(crate) async fn run( cache, printer, ) - .await?; + .await; + + let (from, environment) = match result { + Ok(resolution) => resolution, + Err(ProjectError::Operation(operations::Error::Resolve( + uv_resolver::ResolveError::NoSolution(err), + ))) => { + let report = + miette::Report::msg(format!("{err}")).context(err.header().with_context("tool")); + eprint!("{report:?}"); + return Ok(ExitStatus::Failure); + } + Err(err) => return Err(err.into()), + }; // TODO(zanieb): Determine the executable command via the package entry points let executable = target; @@ -218,7 +232,7 @@ pub(crate) async fn run( fn get_entrypoints( from: &PackageName, site_packages: &SitePackages, -) -> Result> { +) -> anyhow::Result> { let installed = site_packages.get_packages(from); let Some(installed_dist) = installed.first().copied() else { bail!("Expected at least one requirement") @@ -280,6 +294,42 @@ fn warn_executable_not_provided_by_package( } } +/// Parse a target into a command name and a requirement. +fn parse_target(target: &OsString) -> anyhow::Result<(Cow, Cow)> { + let Some(target_str) = target.to_str() else { + return Err(anyhow::anyhow!("Tool command could not be parsed as UTF-8 string. Use `--from` to specify the package name.")); + }; + + // e.g. uv, no special handling + let Some((name, version)) = target_str.split_once('@') else { + return Ok((Cow::Borrowed(target), Cow::Borrowed(target_str))); + }; + + // e.g. `uv@`, warn and treat the whole thing as the command + if version.is_empty() { + debug!("Ignoring empty version request in command"); + return Ok((Cow::Borrowed(target), Cow::Borrowed(target_str))); + } + + // e.g. ignore `git+https://github.com/uv/uv.git@main` + if PackageName::from_str(name).is_err() { + debug!("Ignoring non-package name `{name}` in command"); + return Ok((Cow::Borrowed(target), Cow::Borrowed(target_str))); + } + + // e.g. `uv@0.1.0`, convert to `uv==0.1.0` + if let Ok(version) = Version::from_str(version) { + return Ok(( + Cow::Owned(OsString::from(name)), + Cow::Owned(format!("{name}=={version}")), + )); + } + + // e.g. `uv@invalid`, warn and treat the whole thing as the command + debug!("Ignoring invalid version request `{version}` in command"); + Ok((Cow::Borrowed(target), Cow::Borrowed(target_str))) +} + /// Get or create a [`PythonEnvironment`] in which to run the specified tools. /// /// If the target tool is already installed in a compatible environment, returns that @@ -299,7 +349,7 @@ async fn get_or_create_environment( native_tls: bool, cache: &Cache, printer: Printer, -) -> Result<(Requirement, PythonEnvironment)> { +) -> Result<(Requirement, PythonEnvironment), ProjectError> { let client_builder = BaseClientBuilder::new() .connectivity(connectivity) .native_tls(native_tls); @@ -445,39 +495,3 @@ async fn get_or_create_environment( Ok((from, environment.into())) } - -/// Parse a target into a command name and a requirement. -fn parse_target(target: &OsString) -> Result<(Cow, Cow)> { - let Some(target_str) = target.to_str() else { - return Err(anyhow::anyhow!("Tool command could not be parsed as UTF-8 string. Use `--from` to specify the package name.")); - }; - - // e.g. uv, no special handling - let Some((name, version)) = target_str.split_once('@') else { - return Ok((Cow::Borrowed(target), Cow::Borrowed(target_str))); - }; - - // e.g. `uv@`, warn and treat the whole thing as the command - if version.is_empty() { - debug!("Ignoring empty version request in command"); - return Ok((Cow::Borrowed(target), Cow::Borrowed(target_str))); - } - - // e.g. ignore `git+https://github.com/uv/uv.git@main` - if PackageName::from_str(name).is_err() { - debug!("Ignoring non-package name `{name}` in command"); - return Ok((Cow::Borrowed(target), Cow::Borrowed(target_str))); - } - - // e.g. `uv@0.1.0`, convert to `uv==0.1.0` - if let Ok(version) = Version::from_str(version) { - return Ok(( - Cow::Owned(OsString::from(name)), - Cow::Owned(format!("{name}=={version}")), - )); - } - - // e.g. `uv@invalid`, warn and treat the whole thing as the command - debug!("Ignoring invalid version request `{version}` in command"); - Ok((Cow::Borrowed(target), Cow::Borrowed(target_str))) -} diff --git a/crates/uv/tests/run.rs b/crates/uv/tests/run.rs index caad51f93..3f9d417ef 100644 --- a/crates/uv/tests/run.rs +++ b/crates/uv/tests/run.rs @@ -332,6 +332,29 @@ fn run_pep723_script() -> Result<()> { warning: `--no-project` is a no-op for Python scripts with inline metadata, which always run in isolation "###); + // If the script can't be resolved, we should reference the script. + let test_script = context.temp_dir.child("main.py"); + test_script.write_str(indoc! { r#" + # /// script + # requires-python = ">=3.11" + # dependencies = [ + # "add", + # ] + # /// + "# + })?; + + uv_snapshot!(context.filters(), context.run().arg("--preview").arg("--no-project").arg("main.py"), @r###" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + Reading inline script metadata from: main.py + × No solution found when resolving script dependencies: + ╰─▶ Because there are no versions of add and you require add, we can conclude that the requirements are unsatisfiable. + "###); + Ok(()) } @@ -505,6 +528,20 @@ fn run_with() -> Result<()> { + sniffio==1.3.0 "###); + // If the dependencies can't be resolved, we should reference `--with`. + uv_snapshot!(context.filters(), context.run().arg("--with").arg("add").arg("main.py"), @r###" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + warning: `uv run` is experimental and may change without warning + Resolved 6 packages in [TIME] + Audited 4 packages in [TIME] + × No solution found when resolving `--with` dependencies: + ╰─▶ Because there are no versions of add and you require add, we can conclude that the requirements are unsatisfiable. + "###); + Ok(()) } diff --git a/crates/uv/tests/tool_run.rs b/crates/uv/tests/tool_run.rs index 1f283d6ea..27d3492f8 100644 --- a/crates/uv/tests/tool_run.rs +++ b/crates/uv/tests/tool_run.rs @@ -902,3 +902,25 @@ fn warn_no_executables_found() { warning: Package `requests` does not provide any executables. "###); } + +/// If we fail to resolve the tool, we should include "tool" in the error message. +#[test] +fn tool_run_resolution_error() { + let context = TestContext::new("3.12").with_filtered_counts(); + let tool_dir = context.temp_dir.child("tools"); + let bin_dir = context.temp_dir.child("bin"); + + uv_snapshot!(context.filters(), context.tool_run() + .arg("add") + .env("UV_TOOL_DIR", tool_dir.as_os_str()) + .env("XDG_BIN_HOME", bin_dir.as_os_str()), @r###" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + warning: `uv tool run` is experimental and may change without warning + × No solution found when resolving tool dependencies: + ╰─▶ Because there are no versions of add and you require add, we can conclude that the requirements are unsatisfiable. + "###); +}