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
19static 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
27static 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
33static 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
39static 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
44static WHEEL_NOT_FOUND_RE: LazyLock<Regex> =
46 LazyLock::new(|| Regex::new(r"error: invalid command 'bdist_wheel'").unwrap());
47
48static MODULE_NOT_FOUND: LazyLock<Regex> = LazyLock::new(|| {
50 Regex::new("ModuleNotFoundError: No module named ['\"]([^'\"]+)['\"]").unwrap()
51});
52
53static 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 #[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 #[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
138fn extract_package_name(version_id: &str) -> &str {
141 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
157fn 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 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 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(), 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 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(), 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 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(), 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 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(), 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 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}