Add resolver error context to `run` and `tool run` (#5991)

## Summary

Closes https://github.com/astral-sh/uv/issues/5530.
This commit is contained in:
Charlie Marsh 2024-08-09 23:21:56 -04:00 committed by GitHub
parent f10c28225c
commit 2822dde8cb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 243 additions and 82 deletions

View File

@ -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<UvDependencyProvider>,
available_versions: FxHashMap<PubGrubPackage, BTreeSet<Version>>,
@ -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:?}):",
)
}
}
}
}
}

View File

@ -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;

View File

@ -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]

View File

@ -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<Self> {
) -> Result<Self, ProjectError> {
// 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)? {

View File

@ -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<ResolutionGraph> {
) -> Result<ResolutionGraph, ProjectError> {
warn_on_requirements_txt_setting(&spec, settings);
let ResolverSettingsRef {

View File

@ -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<ExitStatus> {
) -> anyhow::Result<ExitStatus> {
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::<Result<_, _>>()?;
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<Option<Pep723Script>> {
pub(crate) async fn parse_script(
command: &ExternalCommand,
) -> Result<Option<Pep723Script>, Pep723Error> {
// Parse the input command.
let command = RunCommand::from(command);
@ -621,7 +652,7 @@ pub(crate) async fn parse_script(command: &ExternalCommand) -> Result<Option<Pep
};
// Read the PEP 723 `script` metadata from the target script.
Ok(Pep723Script::read(&target).await?)
Pep723Script::read(&target).await
}
/// Returns `true` if we can skip creating an additional ephemeral environment in `uv run`.

View File

@ -4,15 +4,16 @@ use std::path::PathBuf;
use std::str::FromStr;
use std::{borrow::Cow, fmt::Display};
use anyhow::{bail, Context, Result};
use anstream::eprint;
use anyhow::{bail, Context};
use itertools::Itertools;
use owo_colors::OwoColorize;
use pypi_types::Requirement;
use tokio::process::Command;
use tracing::{debug, warn};
use distribution_types::{Name, UnresolvedRequirementSpecification};
use pep440_rs::Version;
use pypi_types::Requirement;
use uv_cache::Cache;
use uv_cli::ExternalCommand;
use uv_client::{BaseClientBuilder, Connectivity};
@ -27,12 +28,12 @@ use uv_requirements::{RequirementsSource, RequirementsSpecification};
use uv_tool::{entrypoint_paths, InstalledTools};
use uv_warnings::{warn_user, warn_user_once};
use crate::commands::reporters::PythonDownloadReporter;
use crate::commands::pip::loggers::{
DefaultInstallLogger, DefaultResolveLogger, SummaryInstallLogger, SummaryResolveLogger,
};
use crate::commands::project::resolve_names;
use crate::commands::pip::operations;
use crate::commands::project::{resolve_names, ProjectError};
use crate::commands::reporters::PythonDownloadReporter;
use crate::commands::{
project::environment::CachedEnvironment, tool::common::matching_packages, tool_list,
};
@ -76,7 +77,7 @@ pub(crate) async fn run(
native_tls: bool,
cache: &Cache,
printer: Printer,
) -> Result<ExitStatus> {
) -> anyhow::Result<ExitStatus> {
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<Vec<(String, PathBuf)>> {
) -> anyhow::Result<Vec<(String, PathBuf)>> {
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<OsString>, Cow<str>)> {
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<OsString>, Cow<str>)> {
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)))
}

View File

@ -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(())
}

View File

@ -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.
"###);
}