From 5b4e315e2ff5a5c5c630050ab21c143b95a51bbd Mon Sep 17 00:00:00 2001 From: konstin <konstin@mailbox.org> Date: Mon, 17 Mar 2025 15:35:17 +0100 Subject: [PATCH 1/6] 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 --- crates/uv-build-backend/src/lib.rs | 23 +++++- crates/uv-build-backend/src/wheel.rs | 48 ++++++++++--- crates/uv/tests/it/build_backend.rs | 102 +++++++++++++++++++++++++++ 3 files changed, 160 insertions(+), 13 deletions(-) diff --git a/crates/uv-build-backend/src/lib.rs b/crates/uv-build-backend/src/lib.rs index 4f038256d017..14dc63cfb531 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,24 @@ 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())] - MissingModule(PathBuf), + #[error( + "Expected a Python module for `{}` with an `__init__.py` at: `{}`", + module_name, + project_src.user_display() + )] + MissingModule { + module_name: Identifier, + project_src: PathBuf, + }, + #[error( + "Expected a single Python module for `{}` with an `__init__.py`, 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..0647bde26c03 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,37 @@ 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_string_lossy().to_lowercase() == normalized + && entry.path().join("__init__.py").is_file() + }) + .map_ok(|entry| entry.path()) + .collect::<Result<Vec<_>, _>>()?; + match modules.as_slice() { + [] => Err(Error::MissingModule { + module_name, + project_src: src_root.to_path_buf(), + }), + [module_root] => Ok(module_root.clone()), + multiple => Err(Error::MultipleModules { + module_name, + paths: multiple.to_vec(), + }), + } +} + /// Build a wheel from the source tree and place it in the output directory. pub fn build_editable( source_tree: &Path, @@ -292,10 +321,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..a1ec7a091d0b 100644 --- a/crates/uv/tests/it/build_backend.rs +++ b/crates/uv/tests/it/build_backend.rs @@ -431,3 +431,105 @@ 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 for `django_plugin` with an `__init__.py` at: `src` + "); + + // 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 2: Multiple modules a matching name. + // Requires a case-sensitive filesystem. + #[cfg(unix)] + { + 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(()) +} From 4cebd3053f169298f81b6dc24f75c233ea98ed7b Mon Sep 17 00:00:00 2001 From: konstin <konstin@mailbox.org> Date: Mon, 17 Mar 2025 16:55:17 +0100 Subject: [PATCH 2/6] Sort error outputs --- crates/uv-build-backend/src/wheel.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/uv-build-backend/src/wheel.rs b/crates/uv-build-backend/src/wheel.rs index 0647bde26c03..769b1bfc1458 100644 --- a/crates/uv-build-backend/src/wheel.rs +++ b/crates/uv-build-backend/src/wheel.rs @@ -265,10 +265,11 @@ fn find_module_root(src_root: &Path, module_name: Identifier) -> Result<PathBuf, project_src: src_root.to_path_buf(), }), [module_root] => Ok(module_root.clone()), - multiple => Err(Error::MultipleModules { - module_name, - paths: multiple.to_vec(), - }), + multiple => { + let mut paths = multiple.to_vec(); + paths.sort(); + Err(Error::MultipleModules { module_name, paths }) + } } } From 4b839baef8913a0bd86a8797c98eb516766aa5d2 Mon Sep 17 00:00:00 2001 From: konstin <konstin@mailbox.org> Date: Mon, 17 Mar 2025 17:11:38 +0100 Subject: [PATCH 3/6] . --- crates/uv/tests/it/build_backend.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/uv/tests/it/build_backend.rs b/crates/uv/tests/it/build_backend.rs index a1ec7a091d0b..31db8d604fd4 100644 --- a/crates/uv/tests/it/build_backend.rs +++ b/crates/uv/tests/it/build_backend.rs @@ -509,7 +509,7 @@ fn build_module_name_normalization() -> Result<()> { // Error case 2: Multiple modules a matching name. // Requires a case-sensitive filesystem. - #[cfg(unix)] + #[cfg(linux)] { context .temp_dir From f9dbfbd7f4fbcccec652908ab7cd66a1603015bf Mon Sep 17 00:00:00 2001 From: konstin <konstin@mailbox.org> Date: Mon, 17 Mar 2025 18:30:08 +0100 Subject: [PATCH 4/6] . --- crates/uv/tests/it/build_backend.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/uv/tests/it/build_backend.rs b/crates/uv/tests/it/build_backend.rs index 31db8d604fd4..f561988c5118 100644 --- a/crates/uv/tests/it/build_backend.rs +++ b/crates/uv/tests/it/build_backend.rs @@ -509,7 +509,7 @@ fn build_module_name_normalization() -> Result<()> { // Error case 2: Multiple modules a matching name. // Requires a case-sensitive filesystem. - #[cfg(linux)] + #[cfg(target_os = "linux")] { context .temp_dir From a4a350a6fd436a3d3974ecbcbf344778e1e4befe Mon Sep 17 00:00:00 2001 From: konstin <konstin@mailbox.org> Date: Fri, 21 Mar 2025 18:10:18 -0700 Subject: [PATCH 5/6] Review --- crates/uv-build-backend/src/lib.rs | 17 +++++++++-------- crates/uv-build-backend/src/wheel.rs | 22 +++++++++++++++------- crates/uv/tests/it/build_backend.rs | 22 ++++++++++++++++++---- 3 files changed, 42 insertions(+), 19 deletions(-) diff --git a/crates/uv-build-backend/src/lib.rs b/crates/uv-build-backend/src/lib.rs index 14dc63cfb531..bd77661a22ad 100644 --- a/crates/uv-build-backend/src/lib.rs +++ b/crates/uv-build-backend/src/lib.rs @@ -56,16 +56,17 @@ pub enum Error { #[error("Failed to write RECORD file")] Csv(#[from] csv::Error), #[error( - "Expected a Python module for `{}` with an `__init__.py` at: `{}`", - module_name, - project_src.user_display() + "Expected a Python module directory at: `{}`", + _0.user_display() )] - MissingModule { - module_name: Identifier, - project_src: PathBuf, - }, + MissingModule(PathBuf), + #[error( + "Expected an `__init__.py` at: `{}`", + _0.user_display() + )] + MissingInitPy(PathBuf), #[error( - "Expected a single Python module for `{}` with an `__init__.py`, found multiple:\n* `{}`", + "Expected an `__init__.py` at `{}`, found multiple:\n* `{}`", module_name, paths.iter().map(Simplified::user_display).join("`\n* `") )] diff --git a/crates/uv-build-backend/src/wheel.rs b/crates/uv-build-backend/src/wheel.rs index 769b1bfc1458..5a3f27765eaa 100644 --- a/crates/uv-build-backend/src/wheel.rs +++ b/crates/uv-build-backend/src/wheel.rs @@ -254,17 +254,25 @@ fn find_module_root(src_root: &Path, module_name: Identifier) -> Result<PathBuf, let normalized = module_name.to_string(); let modules = fs_err::read_dir(src_root)? .filter_ok(|entry| { - entry.file_name().to_string_lossy().to_lowercase() == normalized - && entry.path().join("__init__.py").is_file() + 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() { - [] => Err(Error::MissingModule { - module_name, - project_src: src_root.to_path_buf(), - }), - [module_root] => Ok(module_root.clone()), + [] => { + // 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(); diff --git a/crates/uv/tests/it/build_backend.rs b/crates/uv/tests/it/build_backend.rs index f561988c5118..2d5556bfbad4 100644 --- a/crates/uv/tests/it/build_backend.rs +++ b/crates/uv/tests/it/build_backend.rs @@ -458,14 +458,28 @@ fn build_module_name_normalization() -> Result<()> { uv_snapshot!(context .build_backend() .arg("build-wheel") - .arg(&wheel_dir), @r" + .arg(&wheel_dir), @r###" success: false exit_code: 2 ----- stdout ----- ----- stderr ----- - error: Expected a Python module for `django_plugin` with an `__init__.py` at: `src` - "); + 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 @@ -507,7 +521,7 @@ fn build_module_name_normalization() -> Result<()> { ----- stderr ----- "); - // Error case 2: Multiple modules a matching name. + // Error case 3: Multiple modules a matching name. // Requires a case-sensitive filesystem. #[cfg(target_os = "linux")] { From 2db6c32ecdd3070d11433940afaf7986988adebf Mon Sep 17 00:00:00 2001 From: Charlie Marsh <charlie.r.marsh@gmail.com> Date: Sun, 23 Mar 2025 09:19:38 -0400 Subject: [PATCH 6/6] Fixture --- crates/uv-build-backend/src/lib.rs | 7 +++++-- crates/uv/tests/it/build_backend.rs | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/uv-build-backend/src/lib.rs b/crates/uv-build-backend/src/lib.rs index bd77661a22ad..abb240d13c7f 100644 --- a/crates/uv-build-backend/src/lib.rs +++ b/crates/uv-build-backend/src/lib.rs @@ -7,17 +7,20 @@ 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 itertools::Itertools; 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::{Identifier, IdentifierParseError}; +use crate::metadata::ValidationError; + #[derive(Debug, Error)] pub enum Error { #[error(transparent)] diff --git a/crates/uv/tests/it/build_backend.rs b/crates/uv/tests/it/build_backend.rs index 2d5556bfbad4..cebdbed01e20 100644 --- a/crates/uv/tests/it/build_backend.rs +++ b/crates/uv/tests/it/build_backend.rs @@ -539,7 +539,7 @@ fn build_module_name_normalization() -> Result<()> { ----- stdout ----- ----- stderr ----- - error: Expected a single Python module for `django_plugin` with an `__init__.py`, found multiple: + error: Expected an `__init__.py` at `django_plugin`, found multiple: * `src/Django_plugin` * `src/django_plugin` ");