Skip to content

Commit fb1b323

Browse files
Support modules with different casing in build backend (#12240)
Match the module name to its module directory with potentially different casing. For example, a package may have the dist-info-normalized package name `pil_util`, but the importable module is named `PIL_util`. We get the module name either as dist-info-normalized package name, or explicitly from the user. For dist-info-normalizing a package name, the rules are lowercasing, replacing `.` with `_` and replace `-` with `_`. Since `.` and `-` are not allowed in module names, we can check whether a directory name matches our expected module name by lowercasing it. Fixes #12187 --------- Co-authored-by: Charlie Marsh <[email protected]>
1 parent 9af989e commit fb1b323

File tree

3 files changed

+187
-13
lines changed

3 files changed

+187
-13
lines changed

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

+24-3
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,19 @@ pub use metadata::{check_direct_build, PyProjectToml};
77
pub use source_dist::{build_source_dist, list_source_dist};
88
pub use wheel::{build_editable, build_wheel, list_wheel, metadata};
99

10-
use crate::metadata::ValidationError;
1110
use std::fs::FileType;
1211
use std::io;
1312
use std::path::{Path, PathBuf};
13+
14+
use itertools::Itertools;
1415
use thiserror::Error;
1516
use tracing::debug;
17+
1618
use uv_fs::Simplified;
1719
use uv_globfilter::PortableGlobError;
18-
use uv_pypi_types::IdentifierParseError;
20+
use uv_pypi_types::{Identifier, IdentifierParseError};
21+
22+
use crate::metadata::ValidationError;
1923

2024
#[derive(Debug, Error)]
2125
pub enum Error {
@@ -54,8 +58,25 @@ pub enum Error {
5458
Zip(#[from] zip::result::ZipError),
5559
#[error("Failed to write RECORD file")]
5660
Csv(#[from] csv::Error),
57-
#[error("Expected a Python module with an `__init__.py` at: `{}`", _0.user_display())]
61+
#[error(
62+
"Expected a Python module directory at: `{}`",
63+
_0.user_display()
64+
)]
5865
MissingModule(PathBuf),
66+
#[error(
67+
"Expected an `__init__.py` at: `{}`",
68+
_0.user_display()
69+
)]
70+
MissingInitPy(PathBuf),
71+
#[error(
72+
"Expected an `__init__.py` at `{}`, found multiple:\n* `{}`",
73+
module_name,
74+
paths.iter().map(Simplified::user_display).join("`\n* `")
75+
)]
76+
MultipleModules {
77+
module_name: Identifier,
78+
paths: Vec<PathBuf>,
79+
},
5980
#[error("Absolute module root is not allowed: `{}`", _0.display())]
6081
AbsoluteModuleRoot(PathBuf),
6182
#[error("Inconsistent metadata between prepare and build step: `{0}`")]

crates/uv-build-backend/src/wheel.rs

+47-10
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ fn write_wheel(
128128
if settings.module_root.is_absolute() {
129129
return Err(Error::AbsoluteModuleRoot(settings.module_root.clone()));
130130
}
131-
let strip_root = source_tree.join(settings.module_root);
131+
let src_root = source_tree.join(settings.module_root);
132132

133133
let module_name = if let Some(module_name) = settings.module_name {
134134
module_name
@@ -139,10 +139,8 @@ fn write_wheel(
139139
};
140140
debug!("Module name: `{:?}`", module_name);
141141

142-
let module_root = strip_root.join(module_name.as_ref());
143-
if !module_root.join("__init__.py").is_file() {
144-
return Err(Error::MissingModule(module_root));
145-
}
142+
let module_root = find_module_root(&src_root, module_name)?;
143+
146144
let mut files_visited = 0;
147145
for entry in WalkDir::new(module_root)
148146
.into_iter()
@@ -169,7 +167,7 @@ fn write_wheel(
169167
.expect("walkdir starts with root");
170168
let wheel_path = entry
171169
.path()
172-
.strip_prefix(&strip_root)
170+
.strip_prefix(&src_root)
173171
.expect("walkdir starts with root");
174172
if exclude_matcher.is_match(match_path) {
175173
trace!("Excluding from module: `{}`", match_path.user_display());
@@ -243,6 +241,46 @@ fn write_wheel(
243241
Ok(())
244242
}
245243

244+
/// Match the module name to its module directory with potentially different casing.
245+
///
246+
/// For example, a package may have the dist-info-normalized package name `pil_util`, but the
247+
/// importable module is named `PIL_util`.
248+
///
249+
/// We get the module either as dist-info-normalized package name, or explicitly from the user.
250+
/// For dist-info-normalizing a package name, the rules are lowercasing, replacing `.` with `_` and
251+
/// replace `-` with `_`. Since `.` and `-` are not allowed in module names, we can check whether a
252+
/// directory name matches our expected module name by lowercasing it.
253+
fn find_module_root(src_root: &Path, module_name: Identifier) -> Result<PathBuf, Error> {
254+
let normalized = module_name.to_string();
255+
let modules = fs_err::read_dir(src_root)?
256+
.filter_ok(|entry| {
257+
entry
258+
.file_name()
259+
.to_str()
260+
.is_some_and(|file_name| file_name.to_lowercase() == normalized)
261+
})
262+
.map_ok(|entry| entry.path())
263+
.collect::<Result<Vec<_>, _>>()?;
264+
match modules.as_slice() {
265+
[] => {
266+
// Show the normalized path in the error message, as representative example.
267+
Err(Error::MissingModule(src_root.join(module_name.as_ref())))
268+
}
269+
[module_root] => {
270+
if module_root.join("__init__.py").is_file() {
271+
Ok(module_root.clone())
272+
} else {
273+
Err(Error::MissingInitPy(module_root.join("__init__.py")))
274+
}
275+
}
276+
multiple => {
277+
let mut paths = multiple.to_vec();
278+
paths.sort();
279+
Err(Error::MultipleModules { module_name, paths })
280+
}
281+
}
282+
}
283+
246284
/// Build a wheel from the source tree and place it in the output directory.
247285
pub fn build_editable(
248286
source_tree: &Path,
@@ -292,10 +330,9 @@ pub fn build_editable(
292330
};
293331
debug!("Module name: `{:?}`", module_name);
294332

295-
let module_root = src_root.join(module_name.as_ref());
296-
if !module_root.join("__init__.py").is_file() {
297-
return Err(Error::MissingModule(module_root));
298-
}
333+
// Check that a module root exists in the directory we're linking from the `.pth` file
334+
find_module_root(&src_root, module_name)?;
335+
299336
wheel_writer.write_bytes(
300337
&format!("{}.pth", pyproject_toml.name().as_dist_info_name()),
301338
src_root.as_os_str().as_encoded_bytes(),

crates/uv/tests/it/build_backend.rs

+116
Original file line numberDiff line numberDiff line change
@@ -431,3 +431,119 @@ fn rename_module_editable_build() -> Result<()> {
431431

432432
Ok(())
433433
}
434+
435+
/// Check that the build succeeds even if the module name mismatches by case.
436+
#[test]
437+
fn build_module_name_normalization() -> Result<()> {
438+
let context = TestContext::new("3.12");
439+
440+
let wheel_dir = context.temp_dir.path().join("dist");
441+
fs_err::create_dir(&wheel_dir)?;
442+
443+
context
444+
.temp_dir
445+
.child("pyproject.toml")
446+
.write_str(indoc! {r#"
447+
[project]
448+
name = "django-plugin"
449+
version = "1.0.0"
450+
451+
[build-system]
452+
requires = ["uv_build>=0.5,<0.7"]
453+
build-backend = "uv_build"
454+
"#})?;
455+
fs_err::create_dir_all(context.temp_dir.join("src"))?;
456+
457+
// Error case 1: No matching module.
458+
uv_snapshot!(context
459+
.build_backend()
460+
.arg("build-wheel")
461+
.arg(&wheel_dir), @r###"
462+
success: false
463+
exit_code: 2
464+
----- stdout -----
465+
466+
----- stderr -----
467+
error: Expected a Python module directory at: `src/django_plugin`
468+
"###);
469+
470+
fs_err::create_dir_all(context.temp_dir.join("src/Django_plugin"))?;
471+
// Error case 2: A matching module, but no `__init__.py`.
472+
uv_snapshot!(context
473+
.build_backend()
474+
.arg("build-wheel")
475+
.arg(&wheel_dir), @r###"
476+
success: false
477+
exit_code: 2
478+
----- stdout -----
479+
480+
----- stderr -----
481+
error: Expected an `__init__.py` at: `src/Django_plugin/__init__.py`
482+
"###);
483+
484+
// Use `Django_plugin` instead of `django_plugin`
485+
context
486+
.temp_dir
487+
.child("src/Django_plugin/__init__.py")
488+
.write_str(r#"print("Hi from bar")"#)?;
489+
490+
uv_snapshot!(context
491+
.build_backend()
492+
.arg("build-wheel")
493+
.arg(&wheel_dir), @r"
494+
success: true
495+
exit_code: 0
496+
----- stdout -----
497+
django_plugin-1.0.0-py3-none-any.whl
498+
499+
----- stderr -----
500+
");
501+
502+
context
503+
.pip_install()
504+
.arg("--no-index")
505+
.arg("--find-links")
506+
.arg(&wheel_dir)
507+
.arg("django-plugin")
508+
.assert()
509+
.success();
510+
511+
uv_snapshot!(Command::new(context.interpreter())
512+
.arg("-c")
513+
.arg("import Django_plugin")
514+
// Python on windows
515+
.env(EnvVars::PYTHONUTF8, "1"), @r"
516+
success: true
517+
exit_code: 0
518+
----- stdout -----
519+
Hi from bar
520+
521+
----- stderr -----
522+
");
523+
524+
// Error case 3: Multiple modules a matching name.
525+
// Requires a case-sensitive filesystem.
526+
#[cfg(target_os = "linux")]
527+
{
528+
context
529+
.temp_dir
530+
.child("src/django_plugin/__init__.py")
531+
.write_str(r#"print("Hi from bar")"#)?;
532+
533+
uv_snapshot!(context
534+
.build_backend()
535+
.arg("build-wheel")
536+
.arg(&wheel_dir), @r"
537+
success: false
538+
exit_code: 2
539+
----- stdout -----
540+
541+
----- stderr -----
542+
error: Expected an `__init__.py` at `django_plugin`, found multiple:
543+
* `src/Django_plugin`
544+
* `src/django_plugin`
545+
");
546+
}
547+
548+
Ok(())
549+
}

0 commit comments

Comments
 (0)