Skip to main content

uv_build_frontend/
error.rs

1use std::env;
2use std::fmt::{Display, Formatter};
3use std::io;
4use std::path::PathBuf;
5use std::process::ExitStatus;
6use std::sync::LazyLock;
7
8use crate::PythonRunnerOutput;
9use owo_colors::OwoColorize;
10use regex::Regex;
11use thiserror::Error;
12use uv_configuration::BuildOutput;
13use uv_distribution_types::IsBuildBackendError;
14use uv_fs::Simplified;
15use uv_normalize::PackageName;
16use uv_pep440::Version;
17use uv_types::AnyErrorBuild;
18
19/// e.g. `pygraphviz/graphviz_wrap.c:3020:10: fatal error: graphviz/cgraph.h: No such file or directory`
20static MISSING_HEADER_RE_GCC: LazyLock<Regex> = LazyLock::new(|| {
21    Regex::new(
22        r".*\.(?:c|c..|h|h..):\d+:\d+: fatal error: (.*\.(?:h|h..)): No such file or directory",
23    )
24    .unwrap()
25});
26
27/// e.g. `pygraphviz/graphviz_wrap.c:3023:10: fatal error: 'graphviz/cgraph.h' file not found`
28static MISSING_HEADER_RE_CLANG: LazyLock<Regex> = LazyLock::new(|| {
29    Regex::new(r".*\.(?:c|c..|h|h..):\d+:\d+: fatal error: '(.*\.(?:h|h..))' file not found")
30        .unwrap()
31});
32
33/// e.g. `pygraphviz/graphviz_wrap.c(3023): fatal error C1083: Cannot open include file: 'graphviz/cgraph.h': No such file or directory`
34static MISSING_HEADER_RE_MSVC: LazyLock<Regex> = LazyLock::new(|| {
35    Regex::new(r".*\.(?:c|c..|h|h..)\(\d+\): fatal error C1083: Cannot open include file: '(.*\.(?:h|h..))': No such file or directory")
36        .unwrap()
37});
38
39/// e.g. `/usr/bin/ld: cannot find -lncurses: No such file or directory`
40static LD_NOT_FOUND_RE: LazyLock<Regex> = LazyLock::new(|| {
41    Regex::new(r"/usr/bin/ld: cannot find -l([a-zA-Z10-9]+): No such file or directory").unwrap()
42});
43
44/// e.g. `error: invalid command 'bdist_wheel'`
45static WHEEL_NOT_FOUND_RE: LazyLock<Regex> =
46    LazyLock::new(|| Regex::new(r"error: invalid command 'bdist_wheel'").unwrap());
47
48/// e.g. `ModuleNotFoundError`
49static MODULE_NOT_FOUND: LazyLock<Regex> = LazyLock::new(|| {
50    Regex::new("ModuleNotFoundError: No module named ['\"]([^'\"]+)['\"]").unwrap()
51});
52
53/// e.g. `ModuleNotFoundError: No module named 'distutils'`
54static DISTUTILS_NOT_FOUND_RE: LazyLock<Regex> =
55    LazyLock::new(|| Regex::new(r"ModuleNotFoundError: No module named 'distutils'").unwrap());
56
57#[derive(Error, Debug)]
58pub enum Error {
59    #[error(transparent)]
60    Io(#[from] io::Error),
61    #[error(transparent)]
62    Lowering(#[from] uv_distribution::MetadataError),
63    #[error("{} does not appear to be a Python project, as neither `pyproject.toml` nor `setup.py` are present in the directory", _0.simplified_display())]
64    InvalidSourceDist(PathBuf),
65    #[error("Invalid `pyproject.toml`")]
66    InvalidPyprojectTomlSyntax(#[from] toml_edit::TomlError),
67    #[error(
68        "`pyproject.toml` does not match the required schema. When the `[project]` table is present, `project.name` must be present and non-empty."
69    )]
70    InvalidPyprojectTomlSchema(#[from] toml_edit::de::Error),
71    #[error("Failed to resolve requirements from {0}")]
72    RequirementsResolve(&'static str, #[source] AnyErrorBuild),
73    #[error("Failed to install requirements from {0}")]
74    RequirementsInstall(&'static str, #[source] AnyErrorBuild),
75    #[error("Failed to create temporary virtualenv")]
76    Virtualenv(#[from] uv_virtualenv::Error),
77    // Build backend errors
78    #[error("Failed to run `{0}`")]
79    CommandFailed(PathBuf, #[source] io::Error),
80    #[error("The build backend returned an error")]
81    BuildBackend(#[from] BuildBackendError),
82    #[error("The build backend returned an error")]
83    MissingHeader(#[from] MissingHeaderError),
84    #[error("Failed to build PATH for build script")]
85    BuildScriptPath(#[source] env::JoinPathsError),
86    // For the convenience of typing `setup_build` properly.
87    #[error("Building source distributions for `{0}` is disabled")]
88    NoSourceDistBuild(PackageName),
89    #[error("Building source distributions is disabled")]
90    NoSourceDistBuilds,
91    #[error("Cyclic build dependency detected for `{0}`")]
92    CyclicBuildDependency(PackageName),
93    #[error(
94        "Extra build requirement `{0}` was declared with `match-runtime = true`, but `{1}` does not declare static metadata, making runtime-matching impossible"
95    )]
96    UnmatchedRuntime(PackageName, PackageName),
97}
98
99impl IsBuildBackendError for Error {
100    fn is_build_backend_error(&self) -> bool {
101        match self {
102            Self::Io(_)
103            | Self::Lowering(_)
104            | Self::InvalidSourceDist(_)
105            | Self::InvalidPyprojectTomlSyntax(_)
106            | Self::InvalidPyprojectTomlSchema(_)
107            | Self::RequirementsResolve(_, _)
108            | Self::RequirementsInstall(_, _)
109            | Self::Virtualenv(_)
110            | Self::NoSourceDistBuild(_)
111            | Self::NoSourceDistBuilds
112            | Self::CyclicBuildDependency(_)
113            | Self::UnmatchedRuntime(_, _) => false,
114            Self::CommandFailed(_, _)
115            | Self::BuildBackend(_)
116            | Self::MissingHeader(_)
117            | Self::BuildScriptPath(_) => true,
118        }
119    }
120}
121
122#[derive(Debug)]
123enum MissingLibrary {
124    Header(String),
125    Linker(String),
126    BuildDependency(String),
127    DeprecatedModule(String, Version),
128}
129
130#[derive(Debug, Error)]
131pub struct MissingHeaderCause {
132    missing_library: MissingLibrary,
133    package_name: Option<PackageName>,
134    package_version: Option<Version>,
135    version_id: Option<String>,
136}
137
138/// Extract the package name from a version specifier string.
139/// Uses PEP 508 naming rules but more lenient for hinting purposes.
140fn extract_package_name(version_id: &str) -> &str {
141    // https://peps.python.org/pep-0508/#names
142    // ^([A-Z0-9]|[A-Z0-9][A-Z0-9._-]*[A-Z0-9])$ with re.IGNORECASE
143    // Since we're only using this for a hint, we're more lenient than what we would be doing if this was used for parsing
144    let end = version_id
145        .char_indices()
146        .take_while(|(_, char)| matches!(char, 'A'..='Z' | 'a'..='z' | '0'..='9' | '.' | '-' | '_'))
147        .last()
148        .map_or(0, |(i, c)| i + c.len_utf8());
149
150    if end == 0 {
151        version_id
152    } else {
153        &version_id[..end]
154    }
155}
156
157/// Write a hint about missing build dependencies.
158fn hint_build_dependency(
159    f: &mut std::fmt::Formatter<'_>,
160    display_name: &str,
161    package_name: &str,
162    package: &str,
163) -> std::fmt::Result {
164    let table_key = if package_name.contains('.') {
165        format!("\"{package_name}\"")
166    } else {
167        package_name.to_string()
168    };
169    write!(
170        f,
171        "This error likely indicates that `{}` depends on `{}`, but doesn't declare it as a build dependency. \
172        If `{}` is a first-party package, consider adding `{}` to its `{}`. \
173        Otherwise, either add it to your `pyproject.toml` under:\n\
174        \n\
175            [tool.uv.extra-build-dependencies]\n\
176            {} = [\"{}\"]\n\
177        \n\
178        or `{}` into the environment and re-run with `{}`.",
179        display_name.cyan(),
180        package.cyan(),
181        package_name.cyan(),
182        package.cyan(),
183        "build-system.requires".green(),
184        table_key.cyan(),
185        package.cyan(),
186        format!("uv pip install {package}").green(),
187        "--no-build-isolation".green(),
188    )
189}
190
191impl Display for MissingHeaderCause {
192    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
193        match &self.missing_library {
194            MissingLibrary::Header(header) => {
195                if let (Some(package_name), Some(package_version)) =
196                    (&self.package_name, &self.package_version)
197                {
198                    write!(
199                        f,
200                        "This error likely indicates that you need to install a library that provides \"{}\" for `{}`",
201                        header.cyan(),
202                        format!("{package_name}@{package_version}").cyan(),
203                    )
204                } else if let Some(version_id) = &self.version_id {
205                    write!(
206                        f,
207                        "This error likely indicates that you need to install a library that provides \"{}\" for `{}`",
208                        header.cyan(),
209                        version_id.cyan(),
210                    )
211                } else {
212                    write!(
213                        f,
214                        "This error likely indicates that you need to install a library that provides \"{}\"",
215                        header.cyan(),
216                    )
217                }
218            }
219            MissingLibrary::Linker(library) => {
220                if let (Some(package_name), Some(package_version)) =
221                    (&self.package_name, &self.package_version)
222                {
223                    write!(
224                        f,
225                        "This error likely indicates that you need to install the library that provides a shared library for `{}` for `{}` (e.g., `{}`)",
226                        library.cyan(),
227                        format!("{package_name}@{package_version}").cyan(),
228                        format!("lib{library}-dev").cyan(),
229                    )
230                } else if let Some(version_id) = &self.version_id {
231                    write!(
232                        f,
233                        "This error likely indicates that you need to install the library that provides a shared library for `{}` for `{}` (e.g., `{}`)",
234                        library.cyan(),
235                        version_id.cyan(),
236                        format!("lib{library}-dev").cyan(),
237                    )
238                } else {
239                    write!(
240                        f,
241                        "This error likely indicates that you need to install the library that provides a shared library for `{}` (e.g., `{}`)",
242                        library.cyan(),
243                        format!("lib{library}-dev").cyan(),
244                    )
245                }
246            }
247            MissingLibrary::BuildDependency(package) => {
248                if let (Some(package_name), Some(package_version)) =
249                    (&self.package_name, &self.package_version)
250                {
251                    hint_build_dependency(
252                        f,
253                        &format!("{package_name}@{package_version}"),
254                        package_name.as_str(),
255                        package,
256                    )
257                } else if let Some(version_id) = &self.version_id {
258                    let package_name = extract_package_name(version_id);
259                    hint_build_dependency(f, package_name, package_name, package)
260                } else {
261                    write!(
262                        f,
263                        "This error likely indicates that a package depends on `{}`, but doesn't declare it as a build dependency. If the package is a first-party package, consider adding `{}` to its `{}`. Otherwise, `{}` into the environment and re-run with `{}`.",
264                        package.cyan(),
265                        package.cyan(),
266                        "build-system.requires".green(),
267                        format!("uv pip install {package}").green(),
268                        "--no-build-isolation".green(),
269                    )
270                }
271            }
272            MissingLibrary::DeprecatedModule(package, version) => {
273                if let (Some(package_name), Some(package_version)) =
274                    (&self.package_name, &self.package_version)
275                {
276                    write!(
277                        f,
278                        "`{}` was removed from the standard library in Python {version}. Consider adding a constraint (like `{}`) to avoid building a version of `{}` that depends on `{}`.",
279                        package.cyan(),
280                        format!("{package_name} >{package_version}").green(),
281                        package_name.cyan(),
282                        package.cyan(),
283                    )
284                } else {
285                    write!(
286                        f,
287                        "`{}` was removed from the standard library in Python {version}. Consider adding a constraint to avoid building a package that depends on `{}`.",
288                        package.cyan(),
289                        package.cyan(),
290                    )
291                }
292            }
293        }
294    }
295}
296
297#[derive(Debug, Error)]
298pub struct BuildBackendError {
299    message: String,
300    exit_code: ExitStatus,
301    stdout: Vec<String>,
302    stderr: Vec<String>,
303}
304
305impl Display for BuildBackendError {
306    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
307        write!(f, "{} ({})", self.message, self.exit_code)?;
308
309        let mut non_empty = false;
310
311        if self.stdout.iter().any(|line| !line.trim().is_empty()) {
312            write!(f, "\n\n{}\n{}", "[stdout]".red(), self.stdout.join("\n"))?;
313            non_empty = true;
314        }
315
316        if self.stderr.iter().any(|line| !line.trim().is_empty()) {
317            write!(f, "\n\n{}\n{}", "[stderr]".red(), self.stderr.join("\n"))?;
318            non_empty = true;
319        }
320
321        if non_empty {
322            writeln!(f)?;
323        }
324
325        write!(
326            f,
327            "\n{}{} This usually indicates a problem with the package or the build environment.",
328            "hint".bold().cyan(),
329            ":".bold()
330        )?;
331
332        Ok(())
333    }
334}
335
336#[derive(Debug, Error)]
337pub struct MissingHeaderError {
338    message: String,
339    exit_code: ExitStatus,
340    stdout: Vec<String>,
341    stderr: Vec<String>,
342    cause: MissingHeaderCause,
343}
344
345impl Display for MissingHeaderError {
346    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
347        write!(f, "{} ({})", self.message, self.exit_code)?;
348
349        if self.stdout.iter().any(|line| !line.trim().is_empty()) {
350            write!(f, "\n\n{}\n{}", "[stdout]".red(), self.stdout.join("\n"))?;
351        }
352
353        if self.stderr.iter().any(|line| !line.trim().is_empty()) {
354            write!(f, "\n\n{}\n{}", "[stderr]".red(), self.stderr.join("\n"))?;
355        }
356
357        write!(
358            f,
359            "\n\n{}{} {}",
360            "hint".bold().cyan(),
361            ":".bold(),
362            self.cause
363        )?;
364
365        Ok(())
366    }
367}
368
369impl Error {
370    /// Construct an [`Error`] from the output of a failed command.
371    pub(crate) fn from_command_output(
372        message: String,
373        output: &PythonRunnerOutput,
374        level: BuildOutput,
375        name: Option<&PackageName>,
376        version: Option<&Version>,
377        version_id: Option<&str>,
378    ) -> Self {
379        // In the cases I've seen it was the 5th and 3rd last line (see test case), 10 seems like a reasonable cutoff.
380        let missing_library = output.stderr.iter().rev().take(10).find_map(|line| {
381            if let Some((_, [header])) = MISSING_HEADER_RE_GCC
382                .captures(line.trim())
383                .or(MISSING_HEADER_RE_CLANG.captures(line.trim()))
384                .or(MISSING_HEADER_RE_MSVC.captures(line.trim()))
385                .map(|c| c.extract())
386            {
387                Some(MissingLibrary::Header(header.to_string()))
388            } else if let Some((_, [library])) =
389                LD_NOT_FOUND_RE.captures(line.trim()).map(|c| c.extract())
390            {
391                Some(MissingLibrary::Linker(library.to_string()))
392            } else if WHEEL_NOT_FOUND_RE.is_match(line.trim()) {
393                Some(MissingLibrary::BuildDependency("wheel".to_string()))
394            } else if DISTUTILS_NOT_FOUND_RE.is_match(line.trim()) {
395                Some(MissingLibrary::DeprecatedModule(
396                    "distutils".to_string(),
397                    Version::new([3, 12]),
398                ))
399            } else if let Some(caps) = MODULE_NOT_FOUND.captures(line.trim()) {
400                if let Some(module_match) = caps.get(1) {
401                    let module_name = module_match.as_str();
402                    let package_name = match crate::pipreqs::MODULE_MAPPING.lookup(module_name) {
403                        Some(package) => package.to_string(),
404                        None => module_name.to_string(),
405                    };
406                    Some(MissingLibrary::BuildDependency(package_name))
407                } else {
408                    None
409                }
410            } else {
411                None
412            }
413        });
414
415        if let Some(missing_library) = missing_library {
416            return match level {
417                BuildOutput::Stderr | BuildOutput::Quiet => {
418                    Self::MissingHeader(MissingHeaderError {
419                        message,
420                        exit_code: output.status,
421                        stdout: vec![],
422                        stderr: vec![],
423                        cause: MissingHeaderCause {
424                            missing_library,
425                            package_name: name.cloned(),
426                            package_version: version.cloned(),
427                            version_id: version_id.map(ToString::to_string),
428                        },
429                    })
430                }
431                BuildOutput::Debug => Self::MissingHeader(MissingHeaderError {
432                    message,
433                    exit_code: output.status,
434                    stdout: output.stdout.clone(),
435                    stderr: output.stderr.clone(),
436                    cause: MissingHeaderCause {
437                        missing_library,
438                        package_name: name.cloned(),
439                        package_version: version.cloned(),
440                        version_id: version_id.map(ToString::to_string),
441                    },
442                }),
443            };
444        }
445
446        match level {
447            BuildOutput::Stderr | BuildOutput::Quiet => Self::BuildBackend(BuildBackendError {
448                message,
449                exit_code: output.status,
450                stdout: vec![],
451                stderr: vec![],
452            }),
453            BuildOutput::Debug => Self::BuildBackend(BuildBackendError {
454                message,
455                exit_code: output.status,
456                stdout: output.stdout.clone(),
457                stderr: output.stderr.clone(),
458            }),
459        }
460    }
461}
462
463#[cfg(test)]
464mod test {
465    use crate::{Error, PythonRunnerOutput};
466    use indoc::indoc;
467    use std::process::ExitStatus;
468    use std::str::FromStr;
469    use uv_configuration::BuildOutput;
470    use uv_normalize::PackageName;
471    use uv_pep440::Version;
472
473    #[test]
474    fn missing_header() {
475        let output = PythonRunnerOutput {
476            status: ExitStatus::default(), // This is wrong but `from_raw` is platform-gated.
477            stdout: indoc!(r"
478                running bdist_wheel
479                running build
480                [...]
481                creating build/temp.linux-x86_64-cpython-39/pygraphviz
482                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
483                "
484            ).lines().map(ToString::to_string).collect(),
485            stderr: indoc!(r#"
486                warning: no files found matching '*.png' under directory 'doc'
487                warning: no files found matching '*.txt' under directory 'doc'
488                [...]
489                no previously-included directories found matching 'doc/build'
490                pygraphviz/graphviz_wrap.c:3020:10: fatal error: graphviz/cgraph.h: No such file or directory
491                 3020 | #include "graphviz/cgraph.h"
492                      |          ^~~~~~~~~~~~~~~~~~~
493                compilation terminated.
494                error: command '/usr/bin/gcc' failed with exit code 1
495                "#
496            ).lines().map(ToString::to_string).collect(),
497        };
498
499        let err = Error::from_command_output(
500            "Failed building wheel through setup.py".to_string(),
501            &output,
502            BuildOutput::Debug,
503            None,
504            None,
505            Some("pygraphviz-1.11"),
506        );
507
508        assert!(matches!(err, Error::MissingHeader { .. }));
509        // Unix uses exit status, Windows uses exit code.
510        let formatted = std::error::Error::source(&err)
511            .unwrap()
512            .to_string()
513            .replace("exit status: ", "exit code: ");
514        let formatted = anstream::adapter::strip_str(&formatted);
515        insta::assert_snapshot!(formatted, @r#"
516        Failed building wheel through setup.py (exit code: 0)
517
518        [stdout]
519        running bdist_wheel
520        running build
521        [...]
522        creating build/temp.linux-x86_64-cpython-39/pygraphviz
523        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
524
525        [stderr]
526        warning: no files found matching '*.png' under directory 'doc'
527        warning: no files found matching '*.txt' under directory 'doc'
528        [...]
529        no previously-included directories found matching 'doc/build'
530        pygraphviz/graphviz_wrap.c:3020:10: fatal error: graphviz/cgraph.h: No such file or directory
531         3020 | #include "graphviz/cgraph.h"
532              |          ^~~~~~~~~~~~~~~~~~~
533        compilation terminated.
534        error: command '/usr/bin/gcc' failed with exit code 1
535
536        hint: This error likely indicates that you need to install a library that provides "graphviz/cgraph.h" for `pygraphviz-1.11`
537        "#);
538    }
539
540    #[test]
541    fn missing_linker_library() {
542        let output = PythonRunnerOutput {
543            status: ExitStatus::default(), // This is wrong but `from_raw` is platform-gated.
544            stdout: Vec::new(),
545            stderr: indoc!(
546                r"
547               1099 |     n = strlen(p);
548                    |         ^~~~~~~~~
549               /usr/bin/ld: cannot find -lncurses: No such file or directory
550               collect2: error: ld returned 1 exit status
551               error: command '/usr/bin/x86_64-linux-gnu-gcc' failed with exit code 1"
552            )
553            .lines()
554            .map(ToString::to_string)
555            .collect(),
556        };
557
558        let err = Error::from_command_output(
559            "Failed building wheel through setup.py".to_string(),
560            &output,
561            BuildOutput::Debug,
562            None,
563            None,
564            Some("pygraphviz-1.11"),
565        );
566        assert!(matches!(err, Error::MissingHeader { .. }));
567        // Unix uses exit status, Windows uses exit code.
568        let formatted = std::error::Error::source(&err)
569            .unwrap()
570            .to_string()
571            .replace("exit status: ", "exit code: ");
572        let formatted = anstream::adapter::strip_str(&formatted);
573        insta::assert_snapshot!(formatted, @"
574        Failed building wheel through setup.py (exit code: 0)
575
576        [stderr]
577        1099 |     n = strlen(p);
578             |         ^~~~~~~~~
579        /usr/bin/ld: cannot find -lncurses: No such file or directory
580        collect2: error: ld returned 1 exit status
581        error: command '/usr/bin/x86_64-linux-gnu-gcc' failed with exit code 1
582
583        hint: 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`)
584        ");
585    }
586
587    #[test]
588    fn missing_wheel_package() {
589        let output = PythonRunnerOutput {
590            status: ExitStatus::default(), // This is wrong but `from_raw` is platform-gated.
591            stdout: Vec::new(),
592            stderr: indoc!(
593                r"
594            usage: setup.py [global_opts] cmd1 [cmd1_opts] [cmd2 [cmd2_opts] ...]
595               or: setup.py --help [cmd1 cmd2 ...]
596               or: setup.py --help-commands
597               or: setup.py cmd --help
598
599            error: invalid command 'bdist_wheel'"
600            )
601            .lines()
602            .map(ToString::to_string)
603            .collect(),
604        };
605
606        let err = Error::from_command_output(
607            "Failed building wheel through setup.py".to_string(),
608            &output,
609            BuildOutput::Debug,
610            None,
611            None,
612            Some("pygraphviz-1.11"),
613        );
614        assert!(matches!(err, Error::MissingHeader { .. }));
615        // Unix uses exit status, Windows uses exit code.
616        let formatted = std::error::Error::source(&err)
617            .unwrap()
618            .to_string()
619            .replace("exit status: ", "exit code: ");
620        let formatted = anstream::adapter::strip_str(&formatted);
621        insta::assert_snapshot!(formatted, @r#"
622        Failed building wheel through setup.py (exit code: 0)
623
624        [stderr]
625        usage: setup.py [global_opts] cmd1 [cmd1_opts] [cmd2 [cmd2_opts] ...]
626           or: setup.py --help [cmd1 cmd2 ...]
627           or: setup.py --help-commands
628           or: setup.py cmd --help
629
630        error: invalid command 'bdist_wheel'
631
632        hint: 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, either add it to your `pyproject.toml` under:
633
634        [tool.uv.extra-build-dependencies]
635        "pygraphviz-1.11" = ["wheel"]
636
637        or `uv pip install wheel` into the environment and re-run with `--no-build-isolation`.
638        "#);
639    }
640
641    #[test]
642    fn missing_distutils() {
643        let output = PythonRunnerOutput {
644            status: ExitStatus::default(), // This is wrong but `from_raw` is platform-gated.
645            stdout: Vec::new(),
646            stderr: indoc!(
647                r"
648                import distutils.core
649                ModuleNotFoundError: No module named 'distutils'
650                "
651            )
652            .lines()
653            .map(ToString::to_string)
654            .collect(),
655        };
656
657        let err = Error::from_command_output(
658            "Failed building wheel through setup.py".to_string(),
659            &output,
660            BuildOutput::Debug,
661            Some(&PackageName::from_str("pygraphviz").unwrap()),
662            Some(&Version::new([1, 11])),
663            Some("pygraphviz-1.11"),
664        );
665        assert!(matches!(err, Error::MissingHeader { .. }));
666        // Unix uses exit status, Windows uses exit code.
667        let formatted = std::error::Error::source(&err)
668            .unwrap()
669            .to_string()
670            .replace("exit status: ", "exit code: ");
671        let formatted = anstream::adapter::strip_str(&formatted);
672        insta::assert_snapshot!(formatted, @"
673        Failed building wheel through setup.py (exit code: 0)
674
675        [stderr]
676        import distutils.core
677        ModuleNotFoundError: No module named 'distutils'
678
679        hint: `distutils` was removed from the standard library in Python 3.12. Consider adding a constraint (like `pygraphviz >1.11`) to avoid building a version of `pygraphviz` that depends on `distutils`.
680        ");
681    }
682}