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`
         ");