Skip to content

Commit 30b0f60

Browse files
committed
Support modules with different casing in build backend
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 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
1 parent fe06f1a commit 30b0f60

File tree

3 files changed

+160
-13
lines changed

3 files changed

+160
-13
lines changed

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

+20-3
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,15 @@ pub use source_dist::{build_source_dist, list_source_dist};
88
pub use wheel::{build_editable, build_wheel, list_wheel, metadata};
99

1010
use crate::metadata::ValidationError;
11+
use itertools::Itertools;
1112
use std::fs::FileType;
1213
use std::io;
1314
use std::path::{Path, PathBuf};
1415
use thiserror::Error;
1516
use tracing::debug;
1617
use uv_fs::Simplified;
1718
use uv_globfilter::PortableGlobError;
18-
use uv_pypi_types::IdentifierParseError;
19+
use uv_pypi_types::{Identifier, IdentifierParseError};
1920

2021
#[derive(Debug, Error)]
2122
pub enum Error {
@@ -54,8 +55,24 @@ pub enum Error {
5455
Zip(#[from] zip::result::ZipError),
5556
#[error("Failed to write RECORD file")]
5657
Csv(#[from] csv::Error),
57-
#[error("Expected a Python module with an `__init__.py` at: `{}`", _0.user_display())]
58-
MissingModule(PathBuf),
58+
#[error(
59+
"Expected a Python module for `{}` with an `__init__.py` at: `{}`",
60+
module_name,
61+
project_src.user_display()
62+
)]
63+
MissingModule {
64+
module_name: Identifier,
65+
project_src: PathBuf,
66+
},
67+
#[error(
68+
"Expected a single Python module for `{}` with an `__init__.py`, found multiple:\n* `{}`",
69+
module_name,
70+
paths.iter().map(Simplified::user_display).join("`\n* `")
71+
)]
72+
MultipleModules {
73+
module_name: Identifier,
74+
paths: Vec<PathBuf>,
75+
},
5976
#[error("Absolute module root is not allowed: `{}`", _0.display())]
6077
AbsoluteModuleRoot(PathBuf),
6178
#[error("Inconsistent metadata between prepare and build step: `{0}`")]

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

+38-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,37 @@ 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.file_name().to_string_lossy().to_lowercase() == normalized
258+
&& entry.path().join("__init__.py").is_file()
259+
})
260+
.map_ok(|entry| entry.path())
261+
.collect::<Result<Vec<_>, _>>()?;
262+
match modules.as_slice() {
263+
[] => Err(Error::MissingModule {
264+
module_name,
265+
project_src: src_root.to_path_buf(),
266+
}),
267+
[module_root] => Ok(module_root.clone()),
268+
multiple => Err(Error::MultipleModules {
269+
module_name,
270+
paths: multiple.to_vec(),
271+
}),
272+
}
273+
}
274+
246275
/// Build a wheel from the source tree and place it in the output directory.
247276
pub fn build_editable(
248277
source_tree: &Path,
@@ -292,10 +321,9 @@ pub fn build_editable(
292321
};
293322
debug!("Module name: `{:?}`", module_name);
294323

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-
}
324+
// Check that a module root exists in the directory we're linking from the `.pth` file
325+
find_module_root(&src_root, module_name)?;
326+
299327
wheel_writer.write_bytes(
300328
&format!("{}.pth", pyproject_toml.name().as_dist_info_name()),
301329
src_root.as_os_str().as_encoded_bytes(),

crates/uv/tests/it/build_backend.rs

+102
Original file line numberDiff line numberDiff line change
@@ -431,3 +431,105 @@ 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 for `django_plugin` with an `__init__.py` at: `src`
468+
");
469+
470+
// Use `Django_plugin` instead of `django_plugin`
471+
context
472+
.temp_dir
473+
.child("src/Django_plugin/__init__.py")
474+
.write_str(r#"print("Hi from bar")"#)?;
475+
476+
uv_snapshot!(context
477+
.build_backend()
478+
.arg("build-wheel")
479+
.arg(&wheel_dir), @r"
480+
success: true
481+
exit_code: 0
482+
----- stdout -----
483+
django_plugin-1.0.0-py3-none-any.whl
484+
485+
----- stderr -----
486+
");
487+
488+
context
489+
.pip_install()
490+
.arg("--no-index")
491+
.arg("--find-links")
492+
.arg(&wheel_dir)
493+
.arg("django-plugin")
494+
.assert()
495+
.success();
496+
497+
uv_snapshot!(Command::new(context.interpreter())
498+
.arg("-c")
499+
.arg("import Django_plugin")
500+
// Python on windows
501+
.env(EnvVars::PYTHONUTF8, "1"), @r"
502+
success: true
503+
exit_code: 0
504+
----- stdout -----
505+
Hi from bar
506+
507+
----- stderr -----
508+
");
509+
510+
// Error case 2: Multiple modules a matching name.
511+
// Requires a case-sensitive filesystem.
512+
#[cfg(unix)]
513+
{
514+
context
515+
.temp_dir
516+
.child("src/django_plugin/__init__.py")
517+
.write_str(r#"print("Hi from bar")"#)?;
518+
519+
uv_snapshot!(context
520+
.build_backend()
521+
.arg("build-wheel")
522+
.arg(&wheel_dir), @r"
523+
success: false
524+
exit_code: 2
525+
----- stdout -----
526+
527+
----- stderr -----
528+
error: Expected a single Python module for `django_plugin` with an `__init__.py`, found multiple:
529+
* `src/Django_plugin`
530+
* `src/django_plugin`
531+
");
532+
}
533+
534+
Ok(())
535+
}

0 commit comments

Comments
 (0)