Better build error messages (#9660)

Build failures are one of the most common user facing failures that
aren't "obivous" errors (such as typos) or resolver errors. Currently,
they show more technical details than being focussed on this being an
error in a subprocess that is either on the side of the package or -
more likely - in the build environment, e.g. the user needs to install a
dev package or their python version is incompatible.

The new error message clearly delineates the part that's important (this
is a build backend problem) from the internals (we called this hook) and
is consistent about which part of the dist building stage failed. We
have to calibrate the exact wording of the error message some more. Most
of the implementation is working around the orphan rule, (this)error
rules and trait rules, so it came out more of a refactoring than
intended.

Example:


![image](https://github.com/user-attachments/assets/2bc12992-db79-4362-a444-fd0d94594b77)
This commit is contained in:
konsti 2024-12-17 16:44:32 +01:00 committed by GitHub
parent b7df5dbaf3
commit ebc6d20d9d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 426 additions and 262 deletions

View file

@ -5,16 +5,17 @@ use std::path::PathBuf;
use std::process::ExitStatus;
use std::sync::LazyLock;
use crate::PythonRunnerOutput;
use owo_colors::OwoColorize;
use regex::Regex;
use thiserror::Error;
use tracing::error;
use uv_configuration::BuildOutput;
use uv_distribution_types::IsBuildBackendError;
use uv_fs::Simplified;
use uv_pep440::Version;
use uv_pep508::PackageName;
use crate::PythonRunnerOutput;
use uv_types::AnyErrorBuild;
/// e.g. `pygraphviz/graphviz_wrap.c:3020:10: fatal error: graphviz/cgraph.h: No such file or directory`
static MISSING_HEADER_RE_GCC: LazyLock<Regex> = LazyLock::new(|| {
@ -68,19 +69,47 @@ pub enum Error {
#[error("Editable installs with setup.py legacy builds are unsupported, please specify a build backend in pyproject.toml")]
EditableSetupPy,
#[error("Failed to resolve requirements from {0}")]
RequirementsResolve(&'static str, #[source] anyhow::Error),
RequirementsResolve(&'static str, #[source] AnyErrorBuild),
#[error("Failed to install requirements from {0}")]
RequirementsInstall(&'static str, #[source] anyhow::Error),
RequirementsInstall(&'static str, #[source] AnyErrorBuild),
#[error("Failed to create temporary virtualenv")]
Virtualenv(#[from] uv_virtualenv::Error),
// Build backend errors
#[error("Failed to run `{0}`")]
CommandFailed(PathBuf, #[source] io::Error),
#[error(transparent)]
#[error("The build backend returned an error")]
BuildBackend(#[from] BuildBackendError),
#[error(transparent)]
#[error("The build backend returned an error")]
MissingHeader(#[from] MissingHeaderError),
#[error("Failed to build PATH for build script")]
BuildScriptPath(#[source] env::JoinPathsError),
// For the convenience of typing `setup_build` properly.
#[error("Building source distributions for {0} is disabled")]
NoSourceDistBuild(PackageName),
#[error("Building source distributions is disabled")]
NoSourceDistBuilds,
}
impl IsBuildBackendError for Error {
fn is_build_backend_error(&self) -> bool {
match self {
Self::Io(_)
| Self::Lowering(_)
| Self::InvalidSourceDist(_)
| Self::InvalidPyprojectTomlSyntax(_)
| Self::InvalidPyprojectTomlSchema(_)
| Self::EditableSetupPy
| Self::RequirementsResolve(_, _)
| Self::RequirementsInstall(_, _)
| Self::Virtualenv(_)
| Self::NoSourceDistBuild(_)
| Self::NoSourceDistBuilds => false,
Self::CommandFailed(_, _)
| Self::BuildBackend(_)
| Self::MissingHeader(_)
| Self::BuildScriptPath(_) => true,
}
}
}
#[derive(Debug)]
@ -247,6 +276,13 @@ impl Display for BuildBackendError {
writeln!(f)?;
}
write!(
f,
"\n{}{} This usually indicates a problem with the package or the build environment.",
"hint".bold().cyan(),
":".bold()
)?;
Ok(())
}
}
@ -416,7 +452,10 @@ mod test {
assert!(matches!(err, Error::MissingHeader { .. }));
// Unix uses exit status, Windows uses exit code.
let formatted = err.to_string().replace("exit status: ", "exit code: ");
let formatted = std::error::Error::source(&err)
.unwrap()
.to_string()
.replace("exit status: ", "exit code: ");
let formatted = anstream::adapter::strip_str(&formatted);
insta::assert_snapshot!(formatted, @r###"
Failed building wheel through setup.py (exit code: 0)
@ -471,7 +510,10 @@ mod test {
);
assert!(matches!(err, Error::MissingHeader { .. }));
// Unix uses exit status, Windows uses exit code.
let formatted = err.to_string().replace("exit status: ", "exit code: ");
let formatted = std::error::Error::source(&err)
.unwrap()
.to_string()
.replace("exit status: ", "exit code: ");
let formatted = anstream::adapter::strip_str(&formatted);
insta::assert_snapshot!(formatted, @r###"
Failed building wheel through setup.py (exit code: 0)
@ -516,7 +558,10 @@ mod test {
);
assert!(matches!(err, Error::MissingHeader { .. }));
// Unix uses exit status, Windows uses exit code.
let formatted = err.to_string().replace("exit status: ", "exit code: ");
let formatted = std::error::Error::source(&err)
.unwrap()
.to_string()
.replace("exit status: ", "exit code: ");
let formatted = anstream::adapter::strip_str(&formatted);
insta::assert_snapshot!(formatted, @r###"
Failed building wheel through setup.py (exit code: 0)
@ -559,7 +604,10 @@ mod test {
);
assert!(matches!(err, Error::MissingHeader { .. }));
// Unix uses exit status, Windows uses exit code.
let formatted = err.to_string().replace("exit status: ", "exit code: ");
let formatted = std::error::Error::source(&err)
.unwrap()
.to_string()
.replace("exit status: ", "exit code: ");
let formatted = anstream::adapter::strip_str(&formatted);
insta::assert_snapshot!(formatted, @r###"
Failed building wheel through setup.py (exit code: 0)

View file

@ -7,7 +7,6 @@ mod error;
use fs_err as fs;
use indoc::formatdoc;
use itertools::Itertools;
use owo_colors::OwoColorize;
use rustc_hash::FxHashMap;
use serde::de::{value, IntoDeserializer, SeqAccess, Visitor};
use serde::{de, Deserialize, Deserializer};
@ -36,7 +35,7 @@ use uv_pep508::PackageName;
use uv_pypi_types::{Requirement, VerbatimParsedUrl};
use uv_python::{Interpreter, PythonEnvironment};
use uv_static::EnvVars;
use uv_types::{BuildContext, BuildIsolation, SourceBuildTrait};
use uv_types::{AnyErrorBuild, BuildContext, BuildIsolation, SourceBuildTrait};
pub use crate::error::{Error, MissingHeaderCause};
@ -325,7 +324,7 @@ impl SourceBuild {
build_context
.install(&resolved_requirements, &venv)
.await
.map_err(|err| Error::RequirementsInstall("`build-system.requires`", err))?;
.map_err(|err| Error::RequirementsInstall("`build-system.requires`", err.into()))?;
} else {
debug!("Proceeding without build isolation");
}
@ -423,7 +422,9 @@ impl SourceBuild {
let resolved_requirements = build_context
.resolve(&default_backend.requirements)
.await
.map_err(|err| Error::RequirementsResolve("`setup.py` build", err))?;
.map_err(|err| {
Error::RequirementsResolve("`setup.py` build", err.into())
})?;
*resolution = Some(resolved_requirements.clone());
resolved_requirements
}
@ -431,7 +432,9 @@ impl SourceBuild {
build_context
.resolve(&pep517_backend.requirements)
.await
.map_err(|err| Error::RequirementsResolve("`build-system.requires`", err))?
.map_err(|err| {
Error::RequirementsResolve("`build-system.requires`", err.into())
})?
},
)
}
@ -622,8 +625,8 @@ impl SourceBuild {
if !output.status.success() {
return Err(Error::from_command_output(
format!(
"Build backend failed to determine metadata through `{}`",
format!("prepare_metadata_for_build_{}", self.build_kind).green()
"Call to `{}.prepare_metadata_for_build_{}` failed",
self.pep517_backend.backend, self.build_kind
),
&output,
self.level,
@ -745,9 +748,8 @@ impl SourceBuild {
if !output.status.success() {
return Err(Error::from_command_output(
format!(
"Build backend failed to build {} through `{}`",
self.build_kind,
format!("build_{}", self.build_kind).green(),
"Call to `{}.build_{}` failed",
pep517_backend.backend, self.build_kind
),
&output,
self.level,
@ -761,8 +763,8 @@ impl SourceBuild {
if !output_dir.join(&distribution_filename).is_file() {
return Err(Error::from_command_output(
format!(
"Build backend failed to produce {} through `{}`: `{distribution_filename}` not found",
self.build_kind, format!("build_{}", self.build_kind).green(),
"Call to `{}.build_{}` failed",
pep517_backend.backend, self.build_kind
),
&output,
self.level,
@ -776,11 +778,11 @@ impl SourceBuild {
}
impl SourceBuildTrait for SourceBuild {
async fn metadata(&mut self) -> anyhow::Result<Option<PathBuf>> {
async fn metadata(&mut self) -> Result<Option<PathBuf>, AnyErrorBuild> {
Ok(self.get_metadata_without_build().await?)
}
async fn wheel<'a>(&'a self, wheel_dir: &'a Path) -> anyhow::Result<String> {
async fn wheel<'a>(&'a self, wheel_dir: &'a Path) -> Result<String, AnyErrorBuild> {
Ok(self.build(wheel_dir).await?)
}
}
@ -858,8 +860,8 @@ async fn create_pep517_build_environment(
if !output.status.success() {
return Err(Error::from_command_output(
format!(
"Build backend failed to determine requirements with `{}`",
format!("build_{build_kind}()").green()
"Call to `{}.build_{}` failed",
pep517_backend.backend, build_kind
),
&output,
level,
@ -869,37 +871,27 @@ async fn create_pep517_build_environment(
));
}
// Read the requirements from the output file.
let contents = fs_err::read(&outfile).map_err(|err| {
Error::from_command_output(
format!(
"Build backend failed to read requirements from `{}`: {err}",
format!("get_requires_for_build_{build_kind}").green(),
),
&output,
level,
package_name,
package_version,
version_id,
)
})?;
// Deserialize the requirements from the output file.
let extra_requires: Vec<uv_pep508::Requirement<VerbatimParsedUrl>> =
serde_json::from_slice::<Vec<uv_pep508::Requirement<VerbatimParsedUrl>>>(&contents)
.map_err(|err| {
Error::from_command_output(
format!(
"Build backend failed to return requirements from `{}`: {err}",
format!("get_requires_for_build_{build_kind}").green(),
),
&output,
level,
package_name,
package_version,
version_id,
)
})?;
// Read and deserialize the requirements from the output file.
let read_requires_result = fs_err::read(&outfile)
.map_err(|err| err.to_string())
.and_then(|contents| serde_json::from_slice(&contents).map_err(|err| err.to_string()));
let extra_requires: Vec<uv_pep508::Requirement<VerbatimParsedUrl>> = match read_requires_result
{
Ok(extra_requires) => extra_requires,
Err(err) => {
return Err(Error::from_command_output(
format!(
"Call to `{}.get_requires_for_build_{}` failed: {}",
pep517_backend.backend, build_kind, err
),
&output,
level,
package_name,
package_version,
version_id,
))
}
};
// If necessary, lower the requirements.
let extra_requires = match source_strategy {
@ -937,15 +929,16 @@ async fn create_pep517_build_environment(
.cloned()
.chain(extra_requires)
.collect();
let resolution = build_context
.resolve(&requirements)
.await
.map_err(|err| Error::RequirementsResolve("`build-system.requires`", err))?;
let resolution = build_context.resolve(&requirements).await.map_err(|err| {
Error::RequirementsResolve("`build-system.requires`", AnyErrorBuild::from(err))
})?;
build_context
.install(&resolution, venv)
.await
.map_err(|err| Error::RequirementsInstall("`build-system.requires`", err))?;
.map_err(|err| {
Error::RequirementsInstall("`build-system.requires`", AnyErrorBuild::from(err))
})?;
}
Ok(())