mirror of
https://github.com/astral-sh/uv.git
synced 2025-10-17 05:47:45 +00:00
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:
parent
9af989e30c
commit
fb1b3232e8
3 changed files with 187 additions and 13 deletions
|
@ -7,15 +7,19 @@ pub use metadata::{check_direct_build, PyProjectToml};
|
||||||
pub use source_dist::{build_source_dist, list_source_dist};
|
pub use source_dist::{build_source_dist, list_source_dist};
|
||||||
pub use wheel::{build_editable, build_wheel, list_wheel, metadata};
|
pub use wheel::{build_editable, build_wheel, list_wheel, metadata};
|
||||||
|
|
||||||
use crate::metadata::ValidationError;
|
|
||||||
use std::fs::FileType;
|
use std::fs::FileType;
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use itertools::Itertools;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use tracing::debug;
|
use tracing::debug;
|
||||||
|
|
||||||
use uv_fs::Simplified;
|
use uv_fs::Simplified;
|
||||||
use uv_globfilter::PortableGlobError;
|
use uv_globfilter::PortableGlobError;
|
||||||
use uv_pypi_types::IdentifierParseError;
|
use uv_pypi_types::{Identifier, IdentifierParseError};
|
||||||
|
|
||||||
|
use crate::metadata::ValidationError;
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
pub enum Error {
|
pub enum Error {
|
||||||
|
@ -54,8 +58,25 @@ pub enum Error {
|
||||||
Zip(#[from] zip::result::ZipError),
|
Zip(#[from] zip::result::ZipError),
|
||||||
#[error("Failed to write RECORD file")]
|
#[error("Failed to write RECORD file")]
|
||||||
Csv(#[from] csv::Error),
|
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),
|
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())]
|
#[error("Absolute module root is not allowed: `{}`", _0.display())]
|
||||||
AbsoluteModuleRoot(PathBuf),
|
AbsoluteModuleRoot(PathBuf),
|
||||||
#[error("Inconsistent metadata between prepare and build step: `{0}`")]
|
#[error("Inconsistent metadata between prepare and build step: `{0}`")]
|
||||||
|
|
|
@ -128,7 +128,7 @@ fn write_wheel(
|
||||||
if settings.module_root.is_absolute() {
|
if settings.module_root.is_absolute() {
|
||||||
return Err(Error::AbsoluteModuleRoot(settings.module_root.clone()));
|
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 {
|
let module_name = if let Some(module_name) = settings.module_name {
|
||||||
module_name
|
module_name
|
||||||
|
@ -139,10 +139,8 @@ fn write_wheel(
|
||||||
};
|
};
|
||||||
debug!("Module name: `{:?}`", module_name);
|
debug!("Module name: `{:?}`", module_name);
|
||||||
|
|
||||||
let module_root = strip_root.join(module_name.as_ref());
|
let module_root = find_module_root(&src_root, module_name)?;
|
||||||
if !module_root.join("__init__.py").is_file() {
|
|
||||||
return Err(Error::MissingModule(module_root));
|
|
||||||
}
|
|
||||||
let mut files_visited = 0;
|
let mut files_visited = 0;
|
||||||
for entry in WalkDir::new(module_root)
|
for entry in WalkDir::new(module_root)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
@ -169,7 +167,7 @@ fn write_wheel(
|
||||||
.expect("walkdir starts with root");
|
.expect("walkdir starts with root");
|
||||||
let wheel_path = entry
|
let wheel_path = entry
|
||||||
.path()
|
.path()
|
||||||
.strip_prefix(&strip_root)
|
.strip_prefix(&src_root)
|
||||||
.expect("walkdir starts with root");
|
.expect("walkdir starts with root");
|
||||||
if exclude_matcher.is_match(match_path) {
|
if exclude_matcher.is_match(match_path) {
|
||||||
trace!("Excluding from module: `{}`", match_path.user_display());
|
trace!("Excluding from module: `{}`", match_path.user_display());
|
||||||
|
@ -243,6 +241,46 @@ fn write_wheel(
|
||||||
Ok(())
|
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.
|
/// Build a wheel from the source tree and place it in the output directory.
|
||||||
pub fn build_editable(
|
pub fn build_editable(
|
||||||
source_tree: &Path,
|
source_tree: &Path,
|
||||||
|
@ -292,10 +330,9 @@ pub fn build_editable(
|
||||||
};
|
};
|
||||||
debug!("Module name: `{:?}`", module_name);
|
debug!("Module name: `{:?}`", module_name);
|
||||||
|
|
||||||
let module_root = src_root.join(module_name.as_ref());
|
// Check that a module root exists in the directory we're linking from the `.pth` file
|
||||||
if !module_root.join("__init__.py").is_file() {
|
find_module_root(&src_root, module_name)?;
|
||||||
return Err(Error::MissingModule(module_root));
|
|
||||||
}
|
|
||||||
wheel_writer.write_bytes(
|
wheel_writer.write_bytes(
|
||||||
&format!("{}.pth", pyproject_toml.name().as_dist_info_name()),
|
&format!("{}.pth", pyproject_toml.name().as_dist_info_name()),
|
||||||
src_root.as_os_str().as_encoded_bytes(),
|
src_root.as_os_str().as_encoded_bytes(),
|
||||||
|
|
|
@ -431,3 +431,119 @@ fn rename_module_editable_build() -> Result<()> {
|
||||||
|
|
||||||
Ok(())
|
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 an `__init__.py` at `django_plugin`, found multiple:
|
||||||
|
* `src/Django_plugin`
|
||||||
|
* `src/django_plugin`
|
||||||
|
");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue