diff --git a/crates/uv-build-backend/src/lib.rs b/crates/uv-build-backend/src/lib.rs index 4f038256d017..bd77661a22ad 100644 --- a/crates/uv-build-backend/src/lib.rs +++ b/crates/uv-build-backend/src/lib.rs @@ -8,6 +8,7 @@ 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 itertools::Itertools; use std::fs::FileType; use std::io; use std::path::{Path, PathBuf}; @@ -15,7 +16,7 @@ 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}; #[derive(Debug, Error)] pub enum Error { @@ -54,8 +55,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}`")] diff --git a/crates/uv-build-backend/src/wheel.rs b/crates/uv-build-backend/src/wheel.rs index 08e7c434fea6..5a3f27765eaa 100644 --- a/crates/uv-build-backend/src/wheel.rs +++ b/crates/uv-build-backend/src/wheel.rs @@ -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(), diff --git a/crates/uv/tests/it/build_backend.rs b/crates/uv/tests/it/build_backend.rs index d5e455c3e4c2..2d5556bfbad4 100644 --- a/crates/uv/tests/it/build_backend.rs +++ b/crates/uv/tests/it/build_backend.rs @@ -431,3 +431,119 @@ fn rename_module_editable_build() -> Result<()> { Ok(()) } + +/// Check that the build succeeds even if the module name mismatches by case. +#[test] +fn build_module_name_normalization() -> Result<()> { + let context = TestContext::new("3.12"); + + let wheel_dir = context.temp_dir.path().join("dist"); + fs_err::create_dir(&wheel_dir)?; + + context + .temp_dir + .child("pyproject.toml") + .write_str(indoc! {r#" + [project] + name = "django-plugin" + version = "1.0.0" + + [build-system] + requires = ["uv_build>=0.5,<0.7"] + build-backend = "uv_build" + "#})?; + fs_err::create_dir_all(context.temp_dir.join("src"))?; + + // Error case 1: No matching module. + uv_snapshot!(context + .build_backend() + .arg("build-wheel") + .arg(&wheel_dir), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Expected a Python module directory at: `src/django_plugin` + "###); + + fs_err::create_dir_all(context.temp_dir.join("src/Django_plugin"))?; + // Error case 2: A matching module, but no `__init__.py`. + uv_snapshot!(context + .build_backend() + .arg("build-wheel") + .arg(&wheel_dir), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Expected an `__init__.py` at: `src/Django_plugin/__init__.py` + "###); + + // Use `Django_plugin` instead of `django_plugin` + context + .temp_dir + .child("src/Django_plugin/__init__.py") + .write_str(r#"print("Hi from bar")"#)?; + + uv_snapshot!(context + .build_backend() + .arg("build-wheel") + .arg(&wheel_dir), @r" + success: true + exit_code: 0 + ----- stdout ----- + django_plugin-1.0.0-py3-none-any.whl + + ----- stderr ----- + "); + + context + .pip_install() + .arg("--no-index") + .arg("--find-links") + .arg(&wheel_dir) + .arg("django-plugin") + .assert() + .success(); + + uv_snapshot!(Command::new(context.interpreter()) + .arg("-c") + .arg("import Django_plugin") + // Python on windows + .env(EnvVars::PYTHONUTF8, "1"), @r" + success: true + exit_code: 0 + ----- stdout ----- + Hi from bar + + ----- stderr ----- + "); + + // Error case 3: Multiple modules a matching name. + // Requires a case-sensitive filesystem. + #[cfg(target_os = "linux")] + { + context + .temp_dir + .child("src/django_plugin/__init__.py") + .write_str(r#"print("Hi from bar")"#)?; + + uv_snapshot!(context + .build_backend() + .arg("build-wheel") + .arg(&wheel_dir), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Expected a single Python module for `django_plugin` with an `__init__.py`, found multiple: + * `src/Django_plugin` + * `src/django_plugin` + "); + } + + Ok(()) +}