diff --git a/crates/uv-build/src/error.rs b/crates/uv-build/src/error.rs new file mode 100644 index 000000000..de2475c38 --- /dev/null +++ b/crates/uv-build/src/error.rs @@ -0,0 +1,369 @@ +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 thiserror::Error; +use tracing::error; + +use crate::PythonRunnerOutput; +use uv_configuration::BuildOutput; +use uv_fs::Simplified; + +/// 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( + r".*\.(?:c|c..|h|h..):\d+:\d+: fatal error: (.*\.(?:h|h..)): No such file or directory", + ) + .unwrap() +}); + +/// e.g. `pygraphviz/graphviz_wrap.c:3023:10: fatal error: 'graphviz/cgraph.h' file not found` +static MISSING_HEADER_RE_CLANG: LazyLock = LazyLock::new(|| { + Regex::new(r".*\.(?:c|c..|h|h..):\d+:\d+: fatal error: '(.*\.(?:h|h..))' file not found") + .unwrap() +}); + +/// e.g. `pygraphviz/graphviz_wrap.c(3023): fatal error C1083: Cannot open include file: 'graphviz/cgraph.h': No such file or directory` +static MISSING_HEADER_RE_MSVC: LazyLock = LazyLock::new(|| { + Regex::new(r".*\.(?:c|c..|h|h..)\(\d+\): fatal error C1083: Cannot open include file: '(.*\.(?:h|h..))': No such file or directory") + .unwrap() +}); + +/// e.g. `/usr/bin/ld: cannot find -lncurses: No such file or directory` +static LD_NOT_FOUND_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"/usr/bin/ld: cannot find -l([a-zA-Z10-9]+): No such file or directory").unwrap() +}); + +/// e.g. `error: invalid command 'bdist_wheel'` +static WHEEL_NOT_FOUND_RE: LazyLock = + LazyLock::new(|| Regex::new(r"error: invalid command 'bdist_wheel'").unwrap()); + +/// e.g. `ModuleNotFoundError: No module named 'torch'` +static TORCH_NOT_FOUND_RE: LazyLock = + LazyLock::new(|| Regex::new(r"ModuleNotFoundError: No module named 'torch'").unwrap()); + +#[derive(Error, Debug)] +pub enum Error { + #[error(transparent)] + Io(#[from] io::Error), + #[error("{} does not appear to be a Python project, as neither `pyproject.toml` nor `setup.py` are present in the directory", _0.simplified_display())] + InvalidSourceDist(PathBuf), + #[error("Invalid `pyproject.toml`")] + InvalidPyprojectToml(#[from] toml::de::Error), + #[error("Editable installs with setup.py legacy builds are unsupported, please specify a build backend in pyproject.toml")] + EditableSetupPy, + #[error("Failed to install requirements from {0}")] + RequirementsInstall(&'static str, #[source] anyhow::Error), + #[error("Failed to create temporary virtualenv")] + Virtualenv(#[from] uv_virtualenv::Error), + #[error("Failed to run `{0}`")] + CommandFailed(PathBuf, #[source] io::Error), + #[error("{message} with {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} with {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} with {exit_code}")] + BuildBackend { + message: String, + exit_code: ExitStatus, + }, + #[error("{message} with {exit_code}")] + MissingHeader { + message: String, + exit_code: ExitStatus, + #[source] + missing_header_cause: MissingHeaderCause, + }, + #[error("Failed to build PATH for build script")] + BuildScriptPath(#[source] env::JoinPathsError), +} + +#[derive(Debug)] +enum MissingLibrary { + Header(String), + Linker(String), + PythonPackage(String), +} + +#[derive(Debug, Error)] +pub struct MissingHeaderCause { + missing_library: MissingLibrary, + version_id: String, +} + +impl Display for MissingHeaderCause { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match &self.missing_library { + MissingLibrary::Header(header) => { + write!( + f, + "This error likely indicates that you need to install a library that provides \"{}\" for {}", + header, self.version_id + ) + } + MissingLibrary::Linker(library) => { + write!( + f, + "This error likely indicates that you need to install the library that provides a shared library \ + for {library} for {version_id} (e.g. lib{library}-dev)", + library = library, version_id = self.version_id + ) + } + MissingLibrary::PythonPackage(package) => { + write!( + f, + "This error likely indicates that {version_id} depends on {package}, but doesn't declare it as a build dependency. \ + If {version_id} is a first-party package, consider adding {package} to its `build-system.requires`. \ + Otherwise, `uv pip install {package}` into the environment and re-run with `--no-build-isolation`.", + package = package, version_id = self.version_id + ) + } + } + } +} + +impl Error { + pub(crate) fn from_command_output( + message: String, + output: &PythonRunnerOutput, + level: BuildOutput, + version_id: impl Into, + ) -> Self { + // In the cases I've seen it was the 5th and 3rd last line (see test case), 10 seems like a reasonable cutoff. + let missing_library = output.stderr.iter().rev().take(10).find_map(|line| { + if let Some((_, [header])) = MISSING_HEADER_RE_GCC + .captures(line.trim()) + .or(MISSING_HEADER_RE_CLANG.captures(line.trim())) + .or(MISSING_HEADER_RE_MSVC.captures(line.trim())) + .map(|c| c.extract()) + { + Some(MissingLibrary::Header(header.to_string())) + } else if let Some((_, [library])) = + LD_NOT_FOUND_RE.captures(line.trim()).map(|c| c.extract()) + { + Some(MissingLibrary::Linker(library.to_string())) + } else if WHEEL_NOT_FOUND_RE.is_match(line.trim()) { + Some(MissingLibrary::PythonPackage("wheel".to_string())) + } else if TORCH_NOT_FOUND_RE.is_match(line.trim()) { + Some(MissingLibrary::PythonPackage("torch".to_string())) + } else { + None + } + }); + + if let Some(missing_library) = missing_library { + return match level { + BuildOutput::Stderr => Self::MissingHeader { + message, + exit_code: output.status, + missing_header_cause: MissingHeaderCause { + missing_library, + version_id: version_id.into(), + }, + }, + BuildOutput::Debug => Self::MissingHeaderOutput { + message, + exit_code: output.status, + stdout: output.stdout.iter().join("\n"), + stderr: output.stderr.iter().join("\n"), + missing_header_cause: MissingHeaderCause { + missing_library, + version_id: version_id.into(), + }, + }, + }; + } + + match level { + BuildOutput::Stderr => Self::BuildBackend { + message, + exit_code: output.status, + }, + BuildOutput::Debug => Self::BuildBackendOutput { + message, + exit_code: output.status, + stdout: output.stdout.iter().join("\n"), + stderr: output.stderr.iter().join("\n"), + }, + } + } +} + +#[cfg(test)] +mod test { + use std::process::ExitStatus; + + use crate::{Error, PythonRunnerOutput}; + use indoc::indoc; + use uv_configuration::BuildOutput; + + #[test] + fn missing_header() { + let output = PythonRunnerOutput { + status: ExitStatus::default(), // This is wrong but `from_raw` is platform-gated. + stdout: indoc!(r" + 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 + " + ).lines().map(ToString::to_string).collect(), + stderr: indoc!(r#" + warning: no files found matching '*.png' under directory 'doc' + warning: no files found matching '*.txt' under directory 'doc' + [...] + no previously-included directories found matching 'doc/build' + pygraphviz/graphviz_wrap.c:3020:10: fatal error: graphviz/cgraph.h: No such file or directory + 3020 | #include "graphviz/cgraph.h" + | ^~~~~~~~~~~~~~~~~~~ + compilation terminated. + error: command '/usr/bin/gcc' failed with exit code 1 + "# + ).lines().map(ToString::to_string).collect(), + }; + + let err = Error::from_command_output( + "Failed building wheel through setup.py".to_string(), + &output, + BuildOutput::Debug, + "pygraphviz-1.11", + ); + assert!(matches!(err, Error::MissingHeaderOutput { .. })); + // Unix uses exit status, Windows uses exit code. + let formatted = err.to_string().replace("exit status: ", "exit code: "); + insta::assert_snapshot!(formatted, @r###" + Failed building wheel through setup.py with exit code: 0 + --- 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: + warning: no files found matching '*.png' under directory 'doc' + warning: no files found matching '*.txt' under directory 'doc' + [...] + no previously-included directories found matching 'doc/build' + pygraphviz/graphviz_wrap.c:3020:10: fatal error: graphviz/cgraph.h: No such file or directory + 3020 | #include "graphviz/cgraph.h" + | ^~~~~~~~~~~~~~~~~~~ + compilation terminated. + error: command '/usr/bin/gcc' failed with exit code 1 + --- + "###); + insta::assert_snapshot!( + std::error::Error::source(&err).unwrap(), + @r###"This error likely indicates that you need to install a library that provides "graphviz/cgraph.h" for pygraphviz-1.11"### + ); + } + + #[test] + fn missing_linker_library() { + let output = PythonRunnerOutput { + status: ExitStatus::default(), // This is wrong but `from_raw` is platform-gated. + stdout: Vec::new(), + stderr: indoc!( + r" + 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" + ) + .lines() + .map(ToString::to_string) + .collect(), + }; + + let err = Error::from_command_output( + "Failed building wheel through setup.py".to_string(), + &output, + BuildOutput::Debug, + "pygraphviz-1.11", + ); + assert!(matches!(err, Error::MissingHeaderOutput { .. })); + // Unix uses exit status, Windows uses exit code. + let formatted = err.to_string().replace("exit status: ", "exit code: "); + insta::assert_snapshot!(formatted, @r###" + Failed building wheel through setup.py with exit code: 0 + --- stdout: + + --- 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(), + @"This error likely indicates that you need to install the library that provides a shared library for ncurses for pygraphviz-1.11 (e.g. libncurses-dev)" + ); + } + + #[test] + fn missing_wheel_package() { + let output = PythonRunnerOutput { + status: ExitStatus::default(), // This is wrong but `from_raw` is platform-gated. + stdout: Vec::new(), + stderr: indoc!( + r" + 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'" + ) + .lines() + .map(ToString::to_string) + .collect(), + }; + + let err = Error::from_command_output( + "Failed building wheel through setup.py".to_string(), + &output, + BuildOutput::Debug, + "pygraphviz-1.11", + ); + assert!(matches!(err, Error::MissingHeaderOutput { .. })); + // Unix uses exit status, Windows uses exit code. + let formatted = err.to_string().replace("exit status: ", "exit code: "); + insta::assert_snapshot!(formatted, @r###" + Failed building wheel through setup.py with exit code: 0 + --- stdout: + + --- 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(), + @"This error likely indicates that pygraphviz-1.11 depends on wheel, but doesn't declare it as a build dependency. If pygraphviz-1.11 is a first-party package, consider adding wheel to its `build-system.requires`. Otherwise, `uv pip install wheel` into the environment and re-run with `--no-build-isolation`." + ); + } +} diff --git a/crates/uv-build/src/lib.rs b/crates/uv-build/src/lib.rs index 16717615e..c0cb48bed 100644 --- a/crates/uv-build/src/lib.rs +++ b/crates/uv-build/src/lib.rs @@ -1,17 +1,18 @@ -//! Build wheels from source distributions +//! Build wheels from source distributions. //! //! +mod error; + use fs_err as fs; use indoc::formatdoc; use itertools::Itertools; -use regex::Regex; use rustc_hash::FxHashMap; use serde::de::{value, SeqAccess, Visitor}; use serde::{de, Deserialize, Deserializer}; use std::ffi::OsString; +use std::fmt::Formatter; use std::fmt::Write; -use std::fmt::{Display, Formatter}; use std::io; use std::path::{Path, PathBuf}; use std::process::ExitStatus; @@ -20,12 +21,12 @@ use std::str::FromStr; use std::sync::LazyLock; use std::{env, iter}; use tempfile::{tempdir_in, TempDir}; -use thiserror::Error; use tokio::io::AsyncBufReadExt; use tokio::process::Command; use tokio::sync::{Mutex, Semaphore}; -use tracing::{debug, error, info_span, instrument, Instrument}; +use tracing::{debug, info_span, instrument, Instrument}; +pub use crate::error::{Error, MissingHeaderCause}; use distribution_types::Resolution; use pep440_rs::Version; use pep508_rs::PackageName; @@ -35,39 +36,6 @@ use uv_fs::{rename_with_retry, PythonExt, Simplified}; use uv_python::{Interpreter, PythonEnvironment}; use uv_types::{BuildContext, BuildIsolation, SourceBuildTrait}; -/// 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( - r".*\.(?:c|c..|h|h..):\d+:\d+: fatal error: (.*\.(?:h|h..)): No such file or directory", - ) - .unwrap() -}); - -/// e.g. `pygraphviz/graphviz_wrap.c:3023:10: fatal error: 'graphviz/cgraph.h' file not found` -static MISSING_HEADER_RE_CLANG: LazyLock = LazyLock::new(|| { - Regex::new(r".*\.(?:c|c..|h|h..):\d+:\d+: fatal error: '(.*\.(?:h|h..))' file not found") - .unwrap() -}); - -/// e.g. `pygraphviz/graphviz_wrap.c(3023): fatal error C1083: Cannot open include file: 'graphviz/cgraph.h': No such file or directory` -static MISSING_HEADER_RE_MSVC: LazyLock = LazyLock::new(|| { - Regex::new(r".*\.(?:c|c..|h|h..)\(\d+\): fatal error C1083: Cannot open include file: '(.*\.(?:h|h..))': No such file or directory") - .unwrap() -}); - -/// e.g. `/usr/bin/ld: cannot find -lncurses: No such file or directory` -static LD_NOT_FOUND_RE: LazyLock = LazyLock::new(|| { - Regex::new(r"/usr/bin/ld: cannot find -l([a-zA-Z10-9]+): No such file or directory").unwrap() -}); - -/// e.g. `error: invalid command 'bdist_wheel'` -static WHEEL_NOT_FOUND_RE: LazyLock = - LazyLock::new(|| Regex::new(r"error: invalid command 'bdist_wheel'").unwrap()); - -/// e.g. `ModuleNotFoundError: No module named 'torch'` -static TORCH_NOT_FOUND_RE: LazyLock = - LazyLock::new(|| Regex::new(r"ModuleNotFoundError: No module named 'torch'").unwrap()); - /// The default backend to use when PEP 517 is used without a `build-system` section. static DEFAULT_BACKEND: LazyLock = LazyLock::new(|| Pep517Backend { backend: "setuptools.build_meta:__legacy__".to_string(), @@ -77,197 +45,6 @@ static DEFAULT_BACKEND: LazyLock = LazyLock::new(|| Pep517Backend )], }); -#[derive(Error, Debug)] -pub enum Error { - #[error(transparent)] - Io(#[from] io::Error), - #[error("{} does not appear to be a Python project, as neither `pyproject.toml` nor `setup.py` are present in the directory", _0.simplified_display())] - InvalidSourceDist(PathBuf), - #[error("Invalid `pyproject.toml`")] - InvalidPyprojectToml(#[from] toml::de::Error), - #[error("Editable installs with setup.py legacy builds are unsupported, please specify a build backend in pyproject.toml")] - EditableSetupPy, - #[error("Failed to install requirements from {0}")] - RequirementsInstall(&'static str, #[source] anyhow::Error), - #[error("Failed to create temporary virtualenv")] - Virtualenv(#[from] uv_virtualenv::Error), - #[error("Failed to run `{0}`")] - CommandFailed(PathBuf, #[source] io::Error), - #[error("{message} with {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} with {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} with {exit_code}")] - BuildBackend { - message: String, - exit_code: ExitStatus, - }, - #[error("{message} with {exit_code}")] - MissingHeader { - message: String, - exit_code: ExitStatus, - #[source] - missing_header_cause: MissingHeaderCause, - }, - #[error("Failed to build PATH for build script")] - BuildScriptPath(#[source] env::JoinPathsError), -} - -#[derive(Debug)] -enum MissingLibrary { - Header(String), - Linker(String), - PythonPackage(String), -} - -#[derive(Debug, Error)] -pub struct MissingHeaderCause { - missing_library: MissingLibrary, - version_id: String, -} - -impl Display for MissingHeaderCause { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - match &self.missing_library { - MissingLibrary::Header(header) => { - write!( - f, - "This error likely indicates that you need to install a library that provides \"{}\" for {}", - header, self.version_id - ) - } - MissingLibrary::Linker(library) => { - write!( - f, - "This error likely indicates that you need to install the library that provides a shared library \ - for {library} for {version_id} (e.g. lib{library}-dev)", - library = library, version_id = self.version_id - ) - } - MissingLibrary::PythonPackage(package) => { - write!( - f, - "This error likely indicates that {version_id} depends on {package}, but doesn't declare it as a build dependency. \ - If {version_id} is a first-party package, consider adding {package} to its `build-system.requires`. \ - Otherwise, `uv pip install {package}` into the environment and re-run with `--no-build-isolation`.", - package = package, version_id = self.version_id - ) - } - } - } -} - -impl Error { - fn from_command_output( - message: String, - output: &PythonRunnerOutput, - level: BuildOutput, - version_id: impl Into, - ) -> Self { - // In the cases I've seen it was the 5th and 3rd last line (see test case), 10 seems like a reasonable cutoff. - let missing_library = output.stderr.iter().rev().take(10).find_map(|line| { - if let Some((_, [header])) = MISSING_HEADER_RE_GCC - .captures(line.trim()) - .or(MISSING_HEADER_RE_CLANG.captures(line.trim())) - .or(MISSING_HEADER_RE_MSVC.captures(line.trim())) - .map(|c| c.extract()) - { - Some(MissingLibrary::Header(header.to_string())) - } else if let Some((_, [library])) = - LD_NOT_FOUND_RE.captures(line.trim()).map(|c| c.extract()) - { - Some(MissingLibrary::Linker(library.to_string())) - } else if WHEEL_NOT_FOUND_RE.is_match(line.trim()) { - Some(MissingLibrary::PythonPackage("wheel".to_string())) - } else if TORCH_NOT_FOUND_RE.is_match(line.trim()) { - Some(MissingLibrary::PythonPackage("torch".to_string())) - } else { - None - } - }); - - if let Some(missing_library) = missing_library { - return match level { - BuildOutput::Stderr => Self::MissingHeader { - message, - exit_code: output.status, - missing_header_cause: MissingHeaderCause { - missing_library, - version_id: version_id.into(), - }, - }, - BuildOutput::Debug => Self::MissingHeaderOutput { - message, - exit_code: output.status, - stdout: output.stdout.iter().join("\n"), - stderr: output.stderr.iter().join("\n"), - missing_header_cause: MissingHeaderCause { - missing_library, - version_id: version_id.into(), - }, - }, - }; - } - - match level { - BuildOutput::Stderr => Self::BuildBackend { - message, - exit_code: output.status, - }, - BuildOutput::Debug => Self::BuildBackendOutput { - message, - exit_code: output.status, - stdout: output.stdout.iter().join("\n"), - stderr: output.stderr.iter().join("\n"), - }, - } - } -} - -#[derive(Debug, Copy, Clone, PartialEq, Eq)] -pub enum Printer { - /// Send the build backend output to `stderr`. - Stderr, - /// Send the build backend output to `tracing`. - Debug, -} - -impl From for Printer { - fn from(output: BuildOutput) -> Self { - match output { - BuildOutput::Stderr => Self::Stderr, - BuildOutput::Debug => Self::Debug, - } - } -} - -impl Write for Printer { - fn write_str(&mut self, s: &str) -> std::fmt::Result { - match self { - Self::Stderr => { - anstream::eprintln!("{s}"); - } - Self::Debug => { - debug!("{s}"); - } - } - Ok(()) - } -} - /// A `pyproject.toml` as specified in PEP 517. #[derive(Deserialize, Debug, Clone, PartialEq, Eq)] #[serde(rename_all = "kebab-case")] @@ -1166,164 +943,33 @@ impl PythonRunner { } } -#[cfg(test)] -mod test { - use std::process::ExitStatus; +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub enum Printer { + /// Send the build backend output to `stderr`. + Stderr, + /// Send the build backend output to `tracing`. + Debug, +} - use crate::{Error, PythonRunnerOutput}; - use indoc::indoc; - use uv_configuration::BuildOutput; - - #[test] - fn missing_header() { - let output = PythonRunnerOutput { - status: ExitStatus::default(), // This is wrong but `from_raw` is platform-gated. - stdout: indoc!(r" - 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 - " - ).lines().map(ToString::to_string).collect(), - stderr: indoc!(r#" - warning: no files found matching '*.png' under directory 'doc' - warning: no files found matching '*.txt' under directory 'doc' - [...] - no previously-included directories found matching 'doc/build' - pygraphviz/graphviz_wrap.c:3020:10: fatal error: graphviz/cgraph.h: No such file or directory - 3020 | #include "graphviz/cgraph.h" - | ^~~~~~~~~~~~~~~~~~~ - compilation terminated. - error: command '/usr/bin/gcc' failed with exit code 1 - "# - ).lines().map(ToString::to_string).collect(), - }; - - let err = Error::from_command_output( - "Failed building wheel through setup.py".to_string(), - &output, - BuildOutput::Debug, - "pygraphviz-1.11", - ); - assert!(matches!(err, Error::MissingHeaderOutput { .. })); - // Unix uses exit status, Windows uses exit code. - let formatted = err.to_string().replace("exit status: ", "exit code: "); - insta::assert_snapshot!(formatted, @r###" - Failed building wheel through setup.py with exit code: 0 - --- 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: - warning: no files found matching '*.png' under directory 'doc' - warning: no files found matching '*.txt' under directory 'doc' - [...] - no previously-included directories found matching 'doc/build' - pygraphviz/graphviz_wrap.c:3020:10: fatal error: graphviz/cgraph.h: No such file or directory - 3020 | #include "graphviz/cgraph.h" - | ^~~~~~~~~~~~~~~~~~~ - compilation terminated. - error: command '/usr/bin/gcc' failed with exit code 1 - --- - "###); - insta::assert_snapshot!( - std::error::Error::source(&err).unwrap(), - @r###"This error likely indicates that you need to install a library that provides "graphviz/cgraph.h" for pygraphviz-1.11"### - ); - } - - #[test] - fn missing_linker_library() { - let output = PythonRunnerOutput { - status: ExitStatus::default(), // This is wrong but `from_raw` is platform-gated. - stdout: Vec::new(), - stderr: indoc!( - r" - 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" - ) - .lines() - .map(ToString::to_string) - .collect(), - }; - - let err = Error::from_command_output( - "Failed building wheel through setup.py".to_string(), - &output, - BuildOutput::Debug, - "pygraphviz-1.11", - ); - assert!(matches!(err, Error::MissingHeaderOutput { .. })); - // Unix uses exit status, Windows uses exit code. - let formatted = err.to_string().replace("exit status: ", "exit code: "); - insta::assert_snapshot!(formatted, @r###" - Failed building wheel through setup.py with exit code: 0 - --- stdout: - - --- 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(), - @"This error likely indicates that you need to install the library that provides a shared library for ncurses for pygraphviz-1.11 (e.g. libncurses-dev)" - ); - } - - #[test] - fn missing_wheel_package() { - let output = PythonRunnerOutput { - status: ExitStatus::default(), // This is wrong but `from_raw` is platform-gated. - stdout: Vec::new(), - stderr: indoc!( - r" - 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'" - ) - .lines() - .map(ToString::to_string) - .collect(), - }; - - let err = Error::from_command_output( - "Failed building wheel through setup.py".to_string(), - &output, - BuildOutput::Debug, - "pygraphviz-1.11", - ); - assert!(matches!(err, Error::MissingHeaderOutput { .. })); - // Unix uses exit status, Windows uses exit code. - let formatted = err.to_string().replace("exit status: ", "exit code: "); - insta::assert_snapshot!(formatted, @r###" - Failed building wheel through setup.py with exit code: 0 - --- stdout: - - --- 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(), - @"This error likely indicates that pygraphviz-1.11 depends on wheel, but doesn't declare it as a build dependency. If pygraphviz-1.11 is a first-party package, consider adding wheel to its `build-system.requires`. Otherwise, `uv pip install wheel` into the environment and re-run with `--no-build-isolation`." - ); +impl From for Printer { + fn from(output: BuildOutput) -> Self { + match output { + BuildOutput::Stderr => Self::Stderr, + BuildOutput::Debug => Self::Debug, + } + } +} + +impl Write for Printer { + fn write_str(&mut self, s: &str) -> std::fmt::Result { + match self { + Self::Stderr => { + anstream::eprintln!("{s}"); + } + Self::Debug => { + debug!("{s}"); + } + } + Ok(()) } }