Skip to content

Commit e7b55fe

Browse files
Add extra-build-dependencies hint for any missing module on build failure (#15252)
Alternative to #15251. As suggested in #15118 (comment) ## Test Plan `cargo test` --------- Co-authored-by: Charlie Marsh <[email protected]>
1 parent 4bc6c77 commit e7b55fe

File tree

10 files changed

+1687
-40
lines changed

10 files changed

+1687
-40
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

_typos.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ extend-exclude = [
33
"**/snapshots/",
44
"ecosystem/**",
55
"scripts/**/*.in",
6+
"crates/uv-build-frontend/src/pipreqs/mapping",
67
]
78
ignore-hidden = false
89

crates/uv-build-frontend/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ uv-configuration = { workspace = true }
2222
uv-distribution = { workspace = true }
2323
uv-distribution-types = { workspace = true }
2424
uv-fs = { workspace = true }
25+
uv-normalize = { workspace = true }
2526
uv-pep440 = { workspace = true }
2627
uv-pep508 = { workspace = true }
2728
uv-pypi-types = { workspace = true }

crates/uv-build-frontend/src/error.rs

Lines changed: 82 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,10 @@ static LD_NOT_FOUND_RE: LazyLock<Regex> = LazyLock::new(|| {
4646
static WHEEL_NOT_FOUND_RE: LazyLock<Regex> =
4747
LazyLock::new(|| Regex::new(r"error: invalid command 'bdist_wheel'").unwrap());
4848

49-
/// e.g. `ModuleNotFoundError: No module named 'torch'`
50-
static TORCH_NOT_FOUND_RE: LazyLock<Regex> =
51-
LazyLock::new(|| Regex::new(r"ModuleNotFoundError: No module named 'torch'").unwrap());
49+
/// e.g. `ModuleNotFoundError`
50+
static MODULE_NOT_FOUND: LazyLock<Regex> = LazyLock::new(|| {
51+
Regex::new("ModuleNotFoundError: No module named ['\"]([^'\"]+)['\"]").unwrap()
52+
});
5253

5354
/// e.g. `ModuleNotFoundError: No module named 'distutils'`
5455
static DISTUTILS_NOT_FOUND_RE: LazyLock<Regex> =
@@ -130,6 +131,59 @@ pub struct MissingHeaderCause {
130131
version_id: Option<String>,
131132
}
132133

134+
/// Extract the package name from a version specifier string.
135+
/// Uses PEP 508 naming rules but more lenient for hinting purposes.
136+
fn extract_package_name(version_id: &str) -> &str {
137+
// https://peps.python.org/pep-0508/#names
138+
// ^([A-Z0-9]|[A-Z0-9][A-Z0-9._-]*[A-Z0-9])$ with re.IGNORECASE
139+
// 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
140+
let end = version_id
141+
.char_indices()
142+
.take_while(|(_, char)| matches!(char, 'A'..='Z' | 'a'..='z' | '0'..='9' | '.' | '-' | '_'))
143+
.last()
144+
.map_or(0, |(i, c)| i + c.len_utf8());
145+
146+
if end == 0 {
147+
version_id
148+
} else {
149+
&version_id[..end]
150+
}
151+
}
152+
153+
/// Write a hint about missing build dependencies.
154+
fn hint_build_dependency(
155+
f: &mut std::fmt::Formatter<'_>,
156+
display_name: &str,
157+
package_name: &str,
158+
package: &str,
159+
) -> std::fmt::Result {
160+
let table_key = if package_name.contains('.') {
161+
format!("\"{package_name}\"")
162+
} else {
163+
package_name.to_string()
164+
};
165+
write!(
166+
f,
167+
"This error likely indicates that `{}` depends on `{}`, but doesn't declare it as a build dependency. \
168+
If `{}` is a first-party package, consider adding `{}` to its `{}`. \
169+
Otherwise, either add it to your `pyproject.toml` under:\n\
170+
\n\
171+
[tool.uv.extra-build-dependencies]\n\
172+
{} = [\"{}\"]\n\
173+
\n\
174+
or `{}` into the environment and re-run with `{}`.",
175+
display_name.cyan(),
176+
package.cyan(),
177+
package_name.cyan(),
178+
package.cyan(),
179+
"build-system.requires".green(),
180+
table_key.cyan(),
181+
package.cyan(),
182+
format!("uv pip install {package}").green(),
183+
"--no-build-isolation".green(),
184+
)
185+
}
186+
133187
impl Display for MissingHeaderCause {
134188
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
135189
match &self.missing_library {
@@ -190,29 +244,15 @@ impl Display for MissingHeaderCause {
190244
if let (Some(package_name), Some(package_version)) =
191245
(&self.package_name, &self.package_version)
192246
{
193-
write!(
247+
hint_build_dependency(
194248
f,
195-
"This error likely indicates that `{}` depends on `{}`, but doesn't declare it as a build dependency. If `{}` is a first-party package, consider adding `{}` to its `{}`. Otherwise, `{}` into the environment and re-run with `{}`.",
196-
format!("{package_name}@{package_version}").cyan(),
197-
package.cyan(),
198-
package_name.cyan(),
199-
package.cyan(),
200-
"build-system.requires".green(),
201-
format!("uv pip install {package}").green(),
202-
"--no-build-isolation".green(),
249+
&format!("{package_name}@{package_version}"),
250+
package_name.as_str(),
251+
package,
203252
)
204253
} else if let Some(version_id) = &self.version_id {
205-
write!(
206-
f,
207-
"This error likely indicates that `{}` depends on `{}`, but doesn't declare it as a build dependency. If `{}` is a first-party package, consider adding `{}` to its `{}`. Otherwise, `{}` into the environment and re-run with `{}`.",
208-
version_id.cyan(),
209-
package.cyan(),
210-
version_id.cyan(),
211-
package.cyan(),
212-
"build-system.requires".green(),
213-
format!("uv pip install {package}").green(),
214-
"--no-build-isolation".green(),
215-
)
254+
let package_name = extract_package_name(version_id);
255+
hint_build_dependency(f, package_name, package_name, package)
216256
} else {
217257
write!(
218258
f,
@@ -347,13 +387,22 @@ impl Error {
347387
Some(MissingLibrary::Linker(library.to_string()))
348388
} else if WHEEL_NOT_FOUND_RE.is_match(line.trim()) {
349389
Some(MissingLibrary::BuildDependency("wheel".to_string()))
350-
} else if TORCH_NOT_FOUND_RE.is_match(line.trim()) {
351-
Some(MissingLibrary::BuildDependency("torch".to_string()))
352390
} else if DISTUTILS_NOT_FOUND_RE.is_match(line.trim()) {
353391
Some(MissingLibrary::DeprecatedModule(
354392
"distutils".to_string(),
355393
Version::new([3, 12]),
356394
))
395+
} else if let Some(caps) = MODULE_NOT_FOUND.captures(line.trim()) {
396+
if let Some(module_match) = caps.get(1) {
397+
let module_name = module_match.as_str();
398+
let package_name = match crate::pipreqs::MODULE_MAPPING.lookup(module_name) {
399+
Some(package) => package.to_string(),
400+
None => module_name.to_string(),
401+
};
402+
Some(MissingLibrary::BuildDependency(package_name))
403+
} else {
404+
None
405+
}
357406
} else {
358407
None
359408
}
@@ -565,7 +614,7 @@ mod test {
565614
.to_string()
566615
.replace("exit status: ", "exit code: ");
567616
let formatted = anstream::adapter::strip_str(&formatted);
568-
insta::assert_snapshot!(formatted, @r###"
617+
insta::assert_snapshot!(formatted, @r#"
569618
Failed building wheel through setup.py (exit code: 0)
570619
571620
[stderr]
@@ -576,8 +625,13 @@ mod test {
576625
577626
error: invalid command 'bdist_wheel'
578627
579-
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, `uv pip install wheel` into the environment and re-run with `--no-build-isolation`.
580-
"###);
628+
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:
629+
630+
[tool.uv.extra-build-dependencies]
631+
"pygraphviz-1.11" = ["wheel"]
632+
633+
or `uv pip install wheel` into the environment and re-run with `--no-build-isolation`.
634+
"#);
581635
}
582636

583637
#[test]

crates/uv-build-frontend/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
//! <https://packaging.python.org/en/latest/specifications/source-distribution-format/>
44
55
mod error;
6+
mod pipreqs;
67

78
use std::borrow::Cow;
89
use std::ffi::OsString;
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
use std::str::FromStr;
2+
use std::sync::LazyLock;
3+
4+
use rustc_hash::FxHashMap;
5+
use uv_normalize::PackageName;
6+
7+
/// A mapping from module name to PyPI package name.
8+
pub(crate) struct ModuleMap<'a>(FxHashMap<&'a str, PackageName>);
9+
10+
impl<'a> ModuleMap<'a> {
11+
/// Generate a [`ModuleMap`] from a string representation, encoded in `${module}:{package}` format.
12+
fn from_str(source: &'a str) -> Self {
13+
let mut mapping = FxHashMap::default();
14+
for line in source.lines() {
15+
if let Some((module, package)) = line.split_once(':') {
16+
let module = module.trim();
17+
let package = PackageName::from_str(package.trim()).unwrap();
18+
mapping.insert(module, package);
19+
}
20+
}
21+
Self(mapping)
22+
}
23+
24+
/// Look up a PyPI package name for a given module name.
25+
pub(crate) fn lookup(&self, module: &str) -> Option<&PackageName> {
26+
self.0.get(module)
27+
}
28+
}
29+
30+
/// A mapping from module name to PyPI package name.
31+
pub(crate) static MODULE_MAPPING: LazyLock<ModuleMap> =
32+
LazyLock::new(|| ModuleMap::from_str(include_str!("pipreqs/mapping")));

0 commit comments

Comments
 (0)