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 <charlie.r.marsh@gmail.com>
This commit is contained in:
konsti 2025-03-23 14:29:21 +01:00 committed by GitHub
parent 9af989e30c
commit fb1b3232e8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 187 additions and 13 deletions

View file

@ -7,15 +7,19 @@ pub use metadata::{check_direct_build, PyProjectToml};
pub use source_dist::{build_source_dist, list_source_dist};
pub use wheel::{build_editable, build_wheel, list_wheel, metadata};
use crate::metadata::ValidationError;
use std::fs::FileType;
use std::io;
use std::path::{Path, PathBuf};
use itertools::Itertools;
use thiserror::Error;
use tracing::debug;
use uv_fs::Simplified;
use uv_globfilter::PortableGlobError;
use uv_pypi_types::IdentifierParseError;
use uv_pypi_types::{Identifier, IdentifierParseError};
use crate::metadata::ValidationError;
#[derive(Debug, Error)]
pub enum Error {
@ -54,8 +58,25 @@ pub enum Error {
Zip(#[from] zip::result::ZipError),
#[error("Failed to write RECORD file")]
Csv(#[from] csv::Error),
#[error("Expected a Python module with an `__init__.py` at: `{}`", _0.user_display())]
#[error(
"Expected a Python module directory at: `{}`",
_0.user_display()
)]
MissingModule(PathBuf),
#[error(
"Expected an `__init__.py` at: `{}`",
_0.user_display()
)]
MissingInitPy(PathBuf),
#[error(
"Expected an `__init__.py` at `{}`, found multiple:\n* `{}`",
module_name,
paths.iter().map(Simplified::user_display).join("`\n* `")
)]
MultipleModules {
module_name: Identifier,
paths: Vec<PathBuf>,
},
#[error("Absolute module root is not allowed: `{}`", _0.display())]
AbsoluteModuleRoot(PathBuf),
#[error("Inconsistent metadata between prepare and build step: `{0}`")]

View file

@ -128,7 +128,7 @@ fn write_wheel(
if settings.module_root.is_absolute() {
return Err(Error::AbsoluteModuleRoot(settings.module_root.clone()));
}
let strip_root = source_tree.join(settings.module_root);
let src_root = source_tree.join(settings.module_root);
let module_name = if let Some(module_name) = settings.module_name {
module_name
@ -139,10 +139,8 @@ fn write_wheel(
};
debug!("Module name: `{:?}`", module_name);
let module_root = strip_root.join(module_name.as_ref());
if !module_root.join("__init__.py").is_file() {
return Err(Error::MissingModule(module_root));
}
let module_root = find_module_root(&src_root, module_name)?;
let mut files_visited = 0;
for entry in WalkDir::new(module_root)
.into_iter()
@ -169,7 +167,7 @@ fn write_wheel(
.expect("walkdir starts with root");
let wheel_path = entry
.path()
.strip_prefix(&strip_root)
.strip_prefix(&src_root)
.expect("walkdir starts with root");
if exclude_matcher.is_match(match_path) {
trace!("Excluding from module: `{}`", match_path.user_display());
@ -243,6 +241,46 @@ fn write_wheel(
Ok(())
}
/// 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.
fn find_module_root(src_root: &Path, module_name: Identifier) -> Result<PathBuf, Error> {
let normalized = module_name.to_string();
let modules = fs_err::read_dir(src_root)?
.filter_ok(|entry| {
entry
.file_name()
.to_str()
.is_some_and(|file_name| file_name.to_lowercase() == normalized)
})
.map_ok(|entry| entry.path())
.collect::<Result<Vec<_>, _>>()?;
match modules.as_slice() {
[] => {
// Show the normalized path in the error message, as representative example.
Err(Error::MissingModule(src_root.join(module_name.as_ref())))
}
[module_root] => {
if module_root.join("__init__.py").is_file() {
Ok(module_root.clone())
} else {
Err(Error::MissingInitPy(module_root.join("__init__.py")))
}
}
multiple => {
let mut paths = multiple.to_vec();
paths.sort();
Err(Error::MultipleModules { module_name, paths })
}
}
}
/// Build a wheel from the source tree and place it in the output directory.
pub fn build_editable(
source_tree: &Path,
@ -292,10 +330,9 @@ pub fn build_editable(
};
debug!("Module name: `{:?}`", module_name);
let module_root = src_root.join(module_name.as_ref());
if !module_root.join("__init__.py").is_file() {
return Err(Error::MissingModule(module_root));
}
// Check that a module root exists in the directory we're linking from the `.pth` file
find_module_root(&src_root, module_name)?;
wheel_writer.write_bytes(
&format!("{}.pth", pyproject_toml.name().as_dist_info_name()),
src_root.as_os_str().as_encoded_bytes(),