diff --git a/Cargo.lock b/Cargo.lock index 00d2d1969ba28..e0add76a3e103 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4267,6 +4267,7 @@ dependencies = [ "indoc", "insta", "itertools 0.13.0", + "owo-colors", "regex", "rustc-hash", "serde", diff --git a/crates/uv-build-frontend/Cargo.toml b/crates/uv-build-frontend/Cargo.toml index cb9d05329e895..8ad038c623fa0 100644 --- a/crates/uv-build-frontend/Cargo.toml +++ b/crates/uv-build-frontend/Cargo.toml @@ -29,6 +29,7 @@ anyhow = { workspace = true } fs-err = { workspace = true } indoc = { workspace = true } itertools = { workspace = true } +owo-colors = { workspace = true } regex = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/uv-build-frontend/src/error.rs b/crates/uv-build-frontend/src/error.rs index 4cfe63376cadb..969018dcdccb5 100644 --- a/crates/uv-build-frontend/src/error.rs +++ b/crates/uv-build-frontend/src/error.rs @@ -1,12 +1,12 @@ -use crate::PythonRunnerOutput; -use itertools::Itertools; -use regex::Regex; use std::env; use std::fmt::{Display, Formatter}; use std::io; use std::path::PathBuf; use std::process::ExitStatus; use std::sync::LazyLock; + +use owo_colors::OwoColorize; +use regex::Regex; use thiserror::Error; use tracing::error; use uv_configuration::BuildOutput; @@ -14,6 +14,8 @@ use uv_fs::Simplified; use uv_pep440::Version; use uv_pep508::PackageName; +use crate::PythonRunnerOutput; + /// e.g. `pygraphviz/graphviz_wrap.c:3020:10: fatal error: graphviz/cgraph.h: No such file or directory` static MISSING_HEADER_RE_GCC: LazyLock = LazyLock::new(|| { Regex::new( @@ -71,35 +73,10 @@ pub enum Error { Virtualenv(#[from] uv_virtualenv::Error), #[error("Failed to run `{0}`")] CommandFailed(PathBuf, #[source] io::Error), - #[error("{message} ({exit_code})\n--- stdout:\n{stdout}\n--- stderr:\n{stderr}\n---")] - BuildBackendOutput { - message: String, - exit_code: ExitStatus, - stdout: String, - stderr: String, - }, - /// Nudge the user towards installing the missing dev library - #[error("{message} ({exit_code})\n--- stdout:\n{stdout}\n--- stderr:\n{stderr}\n---")] - MissingHeaderOutput { - message: String, - exit_code: ExitStatus, - stdout: String, - stderr: String, - #[source] - missing_header_cause: MissingHeaderCause, - }, - #[error("{message} ({exit_code})")] - BuildBackend { - message: String, - exit_code: ExitStatus, - }, - #[error("{message} ({exit_code})")] - MissingHeader { - message: String, - exit_code: ExitStatus, - #[source] - missing_header_cause: MissingHeaderCause, - }, + #[error(transparent)] + BuildBackend(#[from] BuildBackendError), + #[error(transparent)] + MissingHeader(#[from] MissingHeaderError), #[error("Failed to build PATH for build script")] BuildScriptPath(#[source] env::JoinPathsError), } @@ -202,6 +179,72 @@ impl Display for MissingHeaderCause { } } +#[derive(Debug, Error)] +pub struct BuildBackendError { + message: String, + exit_code: ExitStatus, + stdout: Vec, + stderr: Vec, +} + +impl Display for BuildBackendError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{} ({})", self.message, self.exit_code)?; + + let mut non_empty = false; + + if self.stdout.iter().any(|line| !line.trim().is_empty()) { + write!(f, "\n\n{}\n{}", "[stdout]".red(), self.stdout.join("\n"))?; + non_empty = true; + } + + if self.stderr.iter().any(|line| !line.trim().is_empty()) { + write!(f, "\n\n{}\n{}", "[stderr]".red(), self.stderr.join("\n"))?; + non_empty = true; + } + + if non_empty { + writeln!(f)?; + } + + Ok(()) + } +} + +#[derive(Debug, Error)] +pub struct MissingHeaderError { + message: String, + exit_code: ExitStatus, + stdout: Vec, + stderr: Vec, + #[source] + cause: MissingHeaderCause, +} + +impl Display for MissingHeaderError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{} ({})", self.message, self.exit_code)?; + + let mut non_empty = false; + + if self.stdout.iter().any(|line| !line.trim().is_empty()) { + write!(f, "\n\n{}\n{}", "[stdout]".red(), self.stdout.join("\n"))?; + non_empty = true; + } + + if self.stderr.iter().any(|line| !line.trim().is_empty()) { + write!(f, "\n\n{}\n{}", "[stderr]".red(), self.stderr.join("\n"))?; + non_empty = true; + } + + if non_empty { + writeln!(f)?; + } + + Ok(()) + } +} + impl Error { /// Construct an [`Error`] from the output of a failed command. pub(crate) fn from_command_output( @@ -241,42 +284,48 @@ impl Error { if let Some(missing_library) = missing_library { return match level { - BuildOutput::Stderr | BuildOutput::Quiet => Self::MissingHeader { - message, - exit_code: output.status, - missing_header_cause: MissingHeaderCause { - missing_library, - package_name: name.cloned(), - package_version: version.cloned(), - version_id: version_id.map(ToString::to_string), - }, - }, - BuildOutput::Debug => Self::MissingHeaderOutput { + BuildOutput::Stderr | BuildOutput::Quiet => { + Self::MissingHeader(MissingHeaderError { + message, + exit_code: output.status, + stdout: vec![], + stderr: vec![], + cause: MissingHeaderCause { + missing_library, + package_name: name.cloned(), + package_version: version.cloned(), + version_id: version_id.map(ToString::to_string), + }, + }) + } + BuildOutput::Debug => Self::MissingHeader(MissingHeaderError { message, exit_code: output.status, - stdout: output.stdout.iter().join("\n"), - stderr: output.stderr.iter().join("\n"), - missing_header_cause: MissingHeaderCause { + stdout: output.stdout.clone(), + stderr: output.stderr.clone(), + cause: MissingHeaderCause { missing_library, package_name: name.cloned(), package_version: version.cloned(), version_id: version_id.map(ToString::to_string), }, - }, + }), }; } match level { - BuildOutput::Stderr | BuildOutput::Quiet => Self::BuildBackend { + BuildOutput::Stderr | BuildOutput::Quiet => Self::BuildBackend(BuildBackendError { message, exit_code: output.status, - }, - BuildOutput::Debug => Self::BuildBackendOutput { + stdout: vec![], + stderr: vec![], + }), + BuildOutput::Debug => Self::BuildBackend(BuildBackendError { message, exit_code: output.status, - stdout: output.stdout.iter().join("\n"), - stderr: output.stderr.iter().join("\n"), - }, + stdout: output.stdout.clone(), + stderr: output.stderr.clone(), + }), } } } @@ -325,18 +374,22 @@ mod test { None, Some("pygraphviz-1.11"), ); - assert!(matches!(err, Error::MissingHeaderOutput { .. })); + + 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 = anstream::adapter::strip_str(&formatted); insta::assert_snapshot!(formatted, @r###" Failed building wheel through setup.py (exit code: 0) - --- stdout: + + [stdout] running bdist_wheel running build [...] creating build/temp.linux-x86_64-cpython-39/pygraphviz gcc -Wno-unused-result -Wsign-compare -DNDEBUG -g -fwrapv -O3 -Wall -DOPENSSL_NO_SSL3 -fPIC -DSWIG_PYTHON_STRICT_BYTE_CHAR -I/tmp/.tmpy6vVes/.venv/include -I/home/konsti/.pyenv/versions/3.9.18/include/python3.9 -c pygraphviz/graphviz_wrap.c -o build/temp.linux-x86_64-cpython-39/pygraphviz/graphviz_wrap.o - --- stderr: + + [stderr] warning: no files found matching '*.png' under directory 'doc' warning: no files found matching '*.txt' under directory 'doc' [...] @@ -346,7 +399,6 @@ mod test { | ^~~~~~~~~~~~~~~~~~~ compilation terminated. error: command '/usr/bin/gcc' failed with exit code 1 - --- "###); insta::assert_snapshot!( std::error::Error::source(&err).unwrap(), @@ -380,20 +432,19 @@ mod test { None, Some("pygraphviz-1.11"), ); - assert!(matches!(err, Error::MissingHeaderOutput { .. })); + 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 = anstream::adapter::strip_str(&formatted); insta::assert_snapshot!(formatted, @r###" Failed building wheel through setup.py (exit code: 0) - --- stdout: - --- stderr: + [stderr] 1099 | n = strlen(p); | ^~~~~~~~~ /usr/bin/ld: cannot find -lncurses: No such file or directory collect2: error: ld returned 1 exit status error: command '/usr/bin/x86_64-linux-gnu-gcc' failed with exit code 1 - --- "###); insta::assert_snapshot!( std::error::Error::source(&err).unwrap(), @@ -428,21 +479,20 @@ mod test { None, Some("pygraphviz-1.11"), ); - assert!(matches!(err, Error::MissingHeaderOutput { .. })); + 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 = anstream::adapter::strip_str(&formatted); insta::assert_snapshot!(formatted, @r###" Failed building wheel through setup.py (exit code: 0) - --- stdout: - --- stderr: + [stderr] usage: setup.py [global_opts] cmd1 [cmd1_opts] [cmd2 [cmd2_opts] ...] or: setup.py --help [cmd1 cmd2 ...] or: setup.py --help-commands or: setup.py cmd --help error: invalid command 'bdist_wheel' - --- "###); insta::assert_snapshot!( std::error::Error::source(&err).unwrap(), @@ -474,17 +524,16 @@ mod test { Some(&Version::new([1, 11])), Some("pygraphviz-1.11"), ); - assert!(matches!(err, Error::MissingHeaderOutput { .. })); + 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 = anstream::adapter::strip_str(&formatted); insta::assert_snapshot!(formatted, @r###" Failed building wheel through setup.py (exit code: 0) - --- stdout: - --- stderr: + [stderr] import distutils.core ModuleNotFoundError: No module named 'distutils' - --- "###); insta::assert_snapshot!( std::error::Error::source(&err).unwrap(), diff --git a/crates/uv-build-frontend/src/lib.rs b/crates/uv-build-frontend/src/lib.rs index 334be03e052dc..b59edbb2d7304 100644 --- a/crates/uv-build-frontend/src/lib.rs +++ b/crates/uv-build-frontend/src/lib.rs @@ -7,6 +7,7 @@ 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}; @@ -564,7 +565,10 @@ impl SourceBuild { .await?; if !output.status.success() { return Err(Error::from_command_output( - format!("Build backend failed to determine metadata through `prepare_metadata_for_build_{}`", self.build_kind), + format!( + "Build backend failed to determine metadata through `{}`", + format!("prepare_metadata_for_build_{}", self.build_kind).green() + ), &output, self.level, self.package_name.as_ref(), @@ -694,8 +698,9 @@ impl SourceBuild { if !output.status.success() { return Err(Error::from_command_output( format!( - "Build backend failed to build {} through `build_{}()`", - self.build_kind, self.build_kind, + "Build backend failed to build {} through `{}`", + self.build_kind, + format!("build_{}", self.build_kind).green(), ), &output, self.level, @@ -709,8 +714,8 @@ impl SourceBuild { if !output_dir.join(&distribution_filename).is_file() { return Err(Error::from_command_output( format!( - "Build backend failed to produce {} through `build_{}()`: `{distribution_filename}` not found", - self.build_kind, self.build_kind, + "Build backend failed to produce {} through `{}`: `{distribution_filename}` not found", + self.build_kind, format!("build_{}", self.build_kind).green(), ), &output, self.level, @@ -802,7 +807,10 @@ async fn create_pep517_build_environment( .await?; if !output.status.success() { return Err(Error::from_command_output( - format!("Build backend failed to determine requirements with `build_{build_kind}()`"), + format!( + "Build backend failed to determine requirements with `{}`", + format!("build_{build_kind}()").green() + ), &output, level, package_name, @@ -815,7 +823,8 @@ async fn create_pep517_build_environment( let contents = fs_err::read(&outfile).map_err(|err| { Error::from_command_output( format!( - "Build backend failed to read requirements from `get_requires_for_build_{build_kind}`: {err}" + "Build backend failed to read requirements from `{}`: {err}", + format!("get_requires_for_build_{build_kind}").green(), ), &output, level, @@ -826,18 +835,21 @@ async fn create_pep517_build_environment( })?; // Deserialize the requirements from the output file. - let extra_requires: Vec> = serde_json::from_slice::>>(&contents).map_err(|err| { - Error::from_command_output( - format!( - "Build backend failed to return requirements from `get_requires_for_build_{build_kind}`: {err}" - ), - &output, - level, - package_name, - package_version, - version_id, - ) - })?; + let extra_requires: Vec> = + serde_json::from_slice::>>(&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, + ) + })?; let extra_requires: Vec<_> = extra_requires.into_iter().map(Requirement::from).collect(); // Some packages (such as tqdm 4.66.1) list only extra requires that have already been part of diff --git a/crates/uv/tests/edit.rs b/crates/uv/tests/edit.rs index f8132ca4d3846..263cd6ab60342 100644 --- a/crates/uv/tests/edit.rs +++ b/crates/uv/tests/edit.rs @@ -4594,10 +4594,9 @@ fn fail_to_add_revert_project() -> Result<()> { Resolved 2 packages in [TIME] error: Failed to prepare distributions Caused by: Failed to fetch wheel: pytorch==1.0.2 - Caused by: Build backend failed to build wheel through `build_wheel()` (exit status: 1) - --- stdout: + Caused by: Build backend failed to build wheel through `build_wheel` (exit status: 1) - --- stderr: + [stderr] Traceback (most recent call last): File "", line 11, in File "[CACHE_DIR]/builds-v0/[TMP]/build_meta.py", line 410, in build_wheel @@ -4611,7 +4610,7 @@ fn fail_to_add_revert_project() -> Result<()> { exec(code, locals()) File "", line 15, in Exception: You tried to install "pytorch". The package named for PyTorch is "torch" - --- + "###); let pyproject_toml = context.read("pyproject.toml"); diff --git a/crates/uv/tests/pip_install.rs b/crates/uv/tests/pip_install.rs index e8a8895125991..3d83da11d4ea3 100644 --- a/crates/uv/tests/pip_install.rs +++ b/crates/uv/tests/pip_install.rs @@ -240,7 +240,8 @@ dependencies = ["flask==1.0.x"] ----- stderr ----- error: Failed to build: `project @ file://[TEMP_DIR]/path_dep` Caused by: Build backend failed to determine requirements with `build_wheel()` (exit status: 1) - --- stdout: + + [stdout] configuration error: `project.dependencies[0]` must be pep508 DESCRIPTION: Project dependency specification according to PEP 508 @@ -257,7 +258,8 @@ dependencies = ["flask==1.0.x"] "type": "string", "format": "pep508" } - --- stderr: + + [stderr] Traceback (most recent call last): File "", line 14, in File "[CACHE_DIR]/builds-v0/[TMP]/build_meta.py", line 325, in get_requires_for_build_wheel @@ -289,7 +291,7 @@ dependencies = ["flask==1.0.x"] raise ValueError(f"{error}/n{summary}") from None ValueError: invalid pyproject.toml config: `project.dependencies[0]`. configuration error: `project.dependencies[0]` must be pep508 - --- + "### ); @@ -3819,13 +3821,12 @@ fn no_build_isolation() -> Result<()> { ----- stderr ----- error: Failed to download and build: `anyio @ https://files.pythonhosted.org/packages/db/4d/3970183622f0330d3c23d9b8a5f52e365e50381fd484d08e3285104333d3/anyio-4.3.0.tar.gz` Caused by: Build backend failed to determine metadata through `prepare_metadata_for_build_wheel` (exit status: 1) - --- stdout: - --- stderr: + [stderr] Traceback (most recent call last): File "", line 8, in ModuleNotFoundError: No module named 'setuptools' - --- + "### ); @@ -3889,13 +3890,12 @@ fn respect_no_build_isolation_env_var() -> Result<()> { ----- stderr ----- error: Failed to download and build: `anyio @ https://files.pythonhosted.org/packages/db/4d/3970183622f0330d3c23d9b8a5f52e365e50381fd484d08e3285104333d3/anyio-4.3.0.tar.gz` Caused by: Build backend failed to determine metadata through `prepare_metadata_for_build_wheel` (exit status: 1) - --- stdout: - --- stderr: + [stderr] Traceback (most recent call last): File "", line 8, in ModuleNotFoundError: No module named 'setuptools' - --- + "### ); @@ -3958,7 +3958,7 @@ fn install_utf16le_requirements() -> Result<()> { Resolved 1 package in [TIME] Prepared 1 package in [TIME] Installed 1 package in [TIME] - + tomli==2.0.1 + + tomli==2.0.2 "### ); Ok(()) @@ -3985,7 +3985,7 @@ fn install_utf16be_requirements() -> Result<()> { Resolved 1 package in [TIME] Prepared 1 package in [TIME] Installed 1 package in [TIME] - + tomli==2.0.1 + + tomli==2.0.2 "### ); Ok(()) @@ -6834,13 +6834,12 @@ fn install_build_isolation_package() -> Result<()> { ----- stderr ----- error: Failed to download and build: `iniconfig @ https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz` Caused by: Build backend failed to determine metadata through `prepare_metadata_for_build_wheel` (exit status: 1) - --- stdout: - --- stderr: + [stderr] Traceback (most recent call last): File "", line 8, in ModuleNotFoundError: No module named 'hatchling' - --- + "### ); @@ -7095,9 +7094,8 @@ fn sklearn() { ----- stderr ----- × Failed to download and build `sklearn==0.0.post12` ╰─▶ Build backend failed to determine requirements with `build_wheel()` (exit status: 1) - --- stdout: - --- stderr: + [stderr] The 'sklearn' PyPI package is deprecated, use 'scikit-learn' rather than 'sklearn' for pip commands. @@ -7113,7 +7111,7 @@ fn sklearn() { More information is available at https://github.com/scikit-learn/sklearn-pypi-package - --- + help: `sklearn` is often confused for `scikit-learn` Did you mean to install `scikit-learn` instead? "### ); diff --git a/crates/uv/tests/sync.rs b/crates/uv/tests/sync.rs index d5076da029115..37046134a0da2 100644 --- a/crates/uv/tests/sync.rs +++ b/crates/uv/tests/sync.rs @@ -577,14 +577,13 @@ fn sync_build_isolation_package() -> Result<()> { Resolved 2 packages in [TIME] error: Failed to prepare distributions Caused by: Failed to fetch wheel: source-distribution @ https://files.pythonhosted.org/packages/10/1f/57aa4cce1b1abf6b433106676e15f9fa2c92ed2bd4cf77c3b50a9e9ac773/source_distribution-0.0.1.tar.gz - Caused by: Build backend failed to build wheel through `build_wheel()` (exit status: 1) - --- stdout: + Caused by: Build backend failed to build wheel through `build_wheel` (exit status: 1) - --- stderr: + [stderr] Traceback (most recent call last): File "", line 8, in ModuleNotFoundError: No module named 'hatchling' - --- + "###); // Install `hatchling` for `source-distribution`. @@ -669,14 +668,13 @@ fn sync_build_isolation_extra() -> Result<()> { Resolved [N] packages in [TIME] error: Failed to prepare distributions Caused by: Failed to fetch wheel: source-distribution @ https://files.pythonhosted.org/packages/10/1f/57aa4cce1b1abf6b433106676e15f9fa2c92ed2bd4cf77c3b50a9e9ac773/source_distribution-0.0.1.tar.gz - Caused by: Build backend failed to build wheel through `build_wheel()` (exit status: 1) - --- stdout: + Caused by: Build backend failed to build wheel through `build_wheel` (exit status: 1) - --- stderr: + [stderr] Traceback (most recent call last): File "", line 8, in ModuleNotFoundError: No module named 'hatchling' - --- + "###); // Running `uv sync` with `--all-extras` should also fail. @@ -689,14 +687,13 @@ fn sync_build_isolation_extra() -> Result<()> { Resolved [N] packages in [TIME] error: Failed to prepare distributions Caused by: Failed to fetch wheel: source-distribution @ https://files.pythonhosted.org/packages/10/1f/57aa4cce1b1abf6b433106676e15f9fa2c92ed2bd4cf77c3b50a9e9ac773/source_distribution-0.0.1.tar.gz - Caused by: Build backend failed to build wheel through `build_wheel()` (exit status: 1) - --- stdout: + Caused by: Build backend failed to build wheel through `build_wheel` (exit status: 1) - --- stderr: + [stderr] Traceback (most recent call last): File "", line 8, in ModuleNotFoundError: No module named 'hatchling' - --- + "###); // Install the build dependencies.