diff --git a/crates/uv-build-backend/src/lib.rs b/crates/uv-build-backend/src/lib.rs index a2ac6c593..22927941f 100644 --- a/crates/uv-build-backend/src/lib.rs +++ b/crates/uv-build-backend/src/lib.rs @@ -14,12 +14,12 @@ use std::io; use std::path::{Path, PathBuf}; use std::str::FromStr; -use itertools::Itertools; use thiserror::Error; use tracing::debug; use uv_fs::Simplified; use uv_globfilter::PortableGlobError; +use uv_normalize::PackageName; use uv_pypi_types::{Identifier, IdentifierParseError}; use crate::metadata::ValidationError; @@ -70,20 +70,17 @@ pub enum 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* `{}`", + "Missing module directory for `{}` in `{}`. Found: `{}`", module_name, - paths.iter().map(Simplified::user_display).join("`\n* `") + src_root.user_display(), + dir_listing.join("`, `") )] - MultipleModules { - module_name: Identifier, - paths: Vec, + MissingModuleDir { + module_name: String, + src_root: PathBuf, + dir_listing: Vec, }, /// Either an absolute path or a parent path through `..`. #[error("Module root must be inside the project: `{}`", _0.user_display())] @@ -197,76 +194,93 @@ fn check_metadata_directory( Ok(()) } -/// Resolve the source root and module root paths. +/// Resolve the source root, module root and the module name. fn find_roots( source_tree: &Path, pyproject_toml: &PyProjectToml, relative_module_root: &Path, module_name: Option<&Identifier>, ) -> Result<(PathBuf, PathBuf), Error> { - let src_root = source_tree.join(uv_fs::normalize_path(relative_module_root)); + let relative_module_root = uv_fs::normalize_path(relative_module_root); + let src_root = source_tree.join(&relative_module_root); if !src_root.starts_with(source_tree) { return Err(Error::InvalidModuleRoot(relative_module_root.to_path_buf())); } - - let module_name = if let Some(module_name) = module_name { - module_name.clone() - } else { - // Should never error, the rules for package names (in dist-info formatting) are stricter - // than those for identifiers - Identifier::from_str(pyproject_toml.name().as_dist_info_name().as_ref())? - }; - debug!("Module name: `{:?}`", module_name); - - let module_root = find_module_root(&src_root, module_name)?; + let src_root = source_tree.join(&relative_module_root); + let module_root = find_module_root(&src_root, module_name, pyproject_toml.name())?; Ok((src_root, module_root)) } /// 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`. +/// Some target platforms have case-sensitive filesystems, while others have case-insensitive +/// filesystems and we always lower case the package name, our default for the module, while some +/// users want uppercase letters in their module names. For example, the package name is `pil_util`, +/// but the module `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 { - let normalized = module_name.to_string(); - let dir_iterator = match fs_err::read_dir(src_root) { - Ok(dir_iterator) => dir_iterator, +/// By default, the dist-info-normalized package name is the module name. For +/// dist-info-normalization, the rules are lowercasing, replacing `.` with `_` and +/// replace `-` with `_`. Since `.` and `-` are not allowed in identifiers, we can use a string +/// comparison with the module name. +/// +/// To make the behavior as consistent as possible across platforms as possible, we require that an +/// upper case name is given explicitly through `tool.uv.module-name`. +/// +/// Returns the module root path, the directory below which the `__init__.py` lives. +fn find_module_root( + src_root: &Path, + module_name: Option<&Identifier>, + package_name: &PackageName, +) -> Result { + let module_name = if let Some(module_name) = module_name { + // This name can be uppercase. + module_name.to_string() + } else { + // Should never error, the rules for package names (in dist-info formatting) are stricter + // than those for identifiers. + // This name is always lowercase. + Identifier::from_str(package_name.as_dist_info_name().as_ref())?.to_string() + }; + + let dir = match fs_err::read_dir(src_root) { + Ok(dir_iterator) => dir_iterator.collect::, _>>()?, Err(err) if err.kind() == io::ErrorKind::NotFound => { return Err(Error::MissingSrc(src_root.to_path_buf())) } Err(err) => return Err(Error::Io(err)), }; - let modules = dir_iterator - .filter_ok(|entry| { - entry - .file_name() - .to_str() - .is_some_and(|file_name| file_name.to_lowercase() == normalized) - }) - .map_ok(|entry| entry.path()) - .collect::, _>>()?; - 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()))) + let module_root = dir.iter().find_map(|entry| { + // TODO(konsti): Do we ever need to check if `dir/{module_name}/__init__.py` exists because + // the wrong casing may be recorded on disk? + if entry + .file_name() + .to_str() + .is_some_and(|file_name| file_name == module_name) + { + Some(entry.path()) + } else { + None } - [module_root] => { - if module_root.join("__init__.py").is_file() { - Ok(module_root.clone()) - } else { - Err(Error::MissingInitPy(module_root.join("__init__.py"))) - } + }); + let module_root = if let Some(module_root) = module_root { + if module_root.join("__init__.py").is_file() { + module_root.clone() + } else { + return Err(Error::MissingInitPy(module_root.join("__init__.py"))); } - multiple => { - let mut paths = multiple.to_vec(); - paths.sort(); - Err(Error::MultipleModules { module_name, paths }) - } - } + } else { + return Err(Error::MissingModuleDir { + module_name, + src_root: src_root.to_path_buf(), + dir_listing: dir + .into_iter() + .filter_map(|entry| Some(entry.file_name().to_str()?.to_string())) + .collect(), + }); + }; + + debug!("Module name: `{}`", module_name); + Ok(module_root) } #[cfg(test)] @@ -299,37 +313,36 @@ mod tests { /// Run both a direct wheel build and an indirect wheel build through a source distribution, /// while checking that directly built wheel and indirectly built wheel are the same. - fn build(source_root: &Path, dist: &Path) -> BuildResults { + fn build(source_root: &Path, dist: &Path) -> Result { // Build a direct wheel, capture all its properties to compare it with the indirect wheel // latest and remove it since it has the same filename as the indirect wheel. - let (_name, direct_wheel_list_files) = list_wheel(source_root, "1.0.0+test").unwrap(); - let direct_wheel_filename = build_wheel(source_root, dist, None, "1.0.0+test").unwrap(); + let (_name, direct_wheel_list_files) = list_wheel(source_root, "1.0.0+test")?; + let direct_wheel_filename = build_wheel(source_root, dist, None, "1.0.0+test")?; let direct_wheel_path = dist.join(direct_wheel_filename.to_string()); let direct_wheel_contents = wheel_contents(&direct_wheel_path); - let direct_wheel_hash = sha2::Sha256::digest(fs_err::read(&direct_wheel_path).unwrap()); - fs_err::remove_file(&direct_wheel_path).unwrap(); + let direct_wheel_hash = sha2::Sha256::digest(fs_err::read(&direct_wheel_path)?); + fs_err::remove_file(&direct_wheel_path)?; // Build a source distribution. - let (_name, source_dist_list_files) = list_source_dist(source_root, "1.0.0+test").unwrap(); + let (_name, source_dist_list_files) = list_source_dist(source_root, "1.0.0+test")?; // TODO(konsti): This should run in the unpacked source dist tempdir, but we need to // normalize the path. - let (_name, wheel_list_files) = list_wheel(source_root, "1.0.0+test").unwrap(); - let source_dist_filename = build_source_dist(source_root, dist, "1.0.0+test").unwrap(); + let (_name, wheel_list_files) = list_wheel(source_root, "1.0.0+test")?; + let source_dist_filename = build_source_dist(source_root, dist, "1.0.0+test")?; let source_dist_path = dist.join(source_dist_filename.to_string()); let source_dist_contents = sdist_contents(&source_dist_path); // Unpack the source distribution and build a wheel from it. - let sdist_tree = TempDir::new().unwrap(); - let sdist_reader = BufReader::new(File::open(&source_dist_path).unwrap()); + let sdist_tree = TempDir::new()?; + let sdist_reader = BufReader::new(File::open(&source_dist_path)?); let mut source_dist = tar::Archive::new(GzDecoder::new(sdist_reader)); - source_dist.unpack(sdist_tree.path()).unwrap(); + source_dist.unpack(sdist_tree.path())?; let sdist_top_level_directory = sdist_tree.path().join(format!( "{}-{}", source_dist_filename.name.as_dist_info_name(), source_dist_filename.version )); - let wheel_filename = - build_wheel(&sdist_top_level_directory, dist, None, "1.0.0+test").unwrap(); + let wheel_filename = build_wheel(&sdist_top_level_directory, dist, None, "1.0.0+test")?; let wheel_contents = wheel_contents(&dist.join(wheel_filename.to_string())); // Check that direct and indirect wheels are identical. @@ -338,17 +351,17 @@ mod tests { assert_eq!(direct_wheel_list_files, wheel_list_files); assert_eq!( direct_wheel_hash, - sha2::Sha256::digest(fs_err::read(dist.join(wheel_filename.to_string())).unwrap()) + sha2::Sha256::digest(fs_err::read(dist.join(wheel_filename.to_string()))?) ); - BuildResults { + Ok(BuildResults { source_dist_list_files, source_dist_filename, source_dist_contents, wheel_list_files, wheel_filename, wheel_contents, - } + }) } fn sdist_contents(source_dist_path: &Path) -> Vec { @@ -453,7 +466,7 @@ mod tests { // Perform both the direct and the indirect build. let dist = TempDir::new().unwrap(); - let build = build(src.path(), dist.path()); + let build = build(src.path(), dist.path()).unwrap(); let source_dist_path = dist.path().join(build.source_dist_filename.to_string()); assert_eq!( @@ -721,7 +734,7 @@ mod tests { File::create(src.path().join("two_step_build").join("__init__.py")).unwrap(); let dist = TempDir::new().unwrap(); - let build1 = build(src.path(), dist.path()); + let build1 = build(src.path(), dist.path()).unwrap(); assert_snapshot!(build1.source_dist_contents.join("\n"), @r" two_step_build-1.0.0/ @@ -760,7 +773,58 @@ mod tests { .unwrap(); let dist = TempDir::new().unwrap(); - let build2 = build(src.path(), dist.path()); + let build2 = build(src.path(), dist.path()).unwrap(); assert_eq!(build1, build2); } + + /// Check that upper case letters in module names work. + #[test] + fn test_camel_case() { + let src = TempDir::new().unwrap(); + let pyproject_toml = indoc! {r#" + [project] + name = "camelcase" + version = "1.0.0" + + [build-system] + requires = ["uv_build>=0.5.15,<0.6"] + build-backend = "uv_build" + + [tool.uv.build-backend] + module-name = "camelCase" + "# + }; + fs_err::write(src.path().join("pyproject.toml"), pyproject_toml).unwrap(); + + fs_err::create_dir_all(src.path().join("src").join("camelCase")).unwrap(); + File::create(src.path().join("src").join("camelCase").join("__init__.py")).unwrap(); + + let dist = TempDir::new().unwrap(); + let build1 = build(src.path(), dist.path()).unwrap(); + + assert_snapshot!(build1.wheel_contents.join("\n"), @r" + camelCase/ + camelCase/__init__.py + camelcase-1.0.0.dist-info/ + camelcase-1.0.0.dist-info/METADATA + camelcase-1.0.0.dist-info/RECORD + camelcase-1.0.0.dist-info/WHEEL + "); + + // Check that an explicit wrong casing fails to build. + fs_err::write( + src.path().join("pyproject.toml"), + pyproject_toml.replace("camelCase", "camel_case"), + ) + .unwrap(); + let build_err = build(src.path(), dist.path()).unwrap_err(); + let err_message = build_err + .to_string() + .replace(&src.path().user_display().to_string(), "[TEMP_PATH]") + .replace('\\', "/"); + assert_snapshot!( + err_message, + @"Missing module directory for `camel_case` in `[TEMP_PATH]/src`. Found: `camelCase`" + ); + } } diff --git a/crates/uv-build-backend/src/source_dist.rs b/crates/uv-build-backend/src/source_dist.rs index 2b2b69589..9cc890a8a 100644 --- a/crates/uv-build-backend/src/source_dist.rs +++ b/crates/uv-build-backend/src/source_dist.rs @@ -10,13 +10,11 @@ use globset::{Glob, GlobSet}; use std::io; use std::io::{BufReader, Cursor}; use std::path::{Path, PathBuf}; -use std::str::FromStr; use tar::{EntryType, Header}; use tracing::{debug, trace}; use uv_distribution_filename::{SourceDistExtension, SourceDistFilename}; use uv_fs::Simplified; use uv_globfilter::{GlobDirFilter, PortableGlobParser}; -use uv_pypi_types::Identifier; use uv_warnings::warn_user_once; use walkdir::WalkDir; @@ -59,6 +57,7 @@ pub fn list_source_dist( /// Build includes and excludes for source tree walking for source dists. fn source_dist_matcher( + source_tree: &Path, pyproject_toml: &PyProjectToml, settings: BuildBackendSettings, ) -> Result<(GlobDirFilter, GlobSet), Error> { @@ -68,20 +67,20 @@ fn source_dist_matcher( // pyproject.toml is always included. includes.push(globset::escape("pyproject.toml")); - let module_name = if let Some(module_name) = settings.module_name { - module_name - } else { - // Should never error, the rules for package names (in dist-info formatting) are stricter - // than those for identifiers - Identifier::from_str(pyproject_toml.name().as_dist_info_name().as_ref())? - }; - debug!("Module name: `{:?}`", module_name); - + // Check that the source tree contains a module. + let (_, module_root) = find_roots( + source_tree, + pyproject_toml, + &settings.module_root, + settings.module_name.as_ref(), + )?; // The wheel must not include any files included by the source distribution (at least until we // have files generated in the source dist -> wheel build step). - let import_path = uv_fs::normalize_path(&settings.module_root.join(module_name.as_ref())) - .portable_display() - .to_string(); + let import_path = uv_fs::normalize_path( + &uv_fs::relative_to(module_root, source_tree).expect("module root is inside source tree"), + ) + .portable_display() + .to_string(); includes.push(format!("{}/**", globset::escape(&import_path))); for include in includes { let glob = PortableGlobParser::Uv @@ -136,6 +135,13 @@ fn source_dist_matcher( include_globs.push(glob); } + debug!( + "Source distribution includes: `{:?}`", + include_globs + .iter() + .map(ToString::to_string) + .collect::>() + ); let include_matcher = GlobDirFilter::from_globs(&include_globs).map_err(|err| Error::GlobSetTooLarge { field: "tool.uv.build-backend.source-include".to_string(), @@ -191,15 +197,7 @@ fn write_source_dist( let metadata = pyproject_toml.to_metadata(source_tree)?; let metadata_email = metadata.core_metadata_format(); - debug!("Adding content files to wheel"); - // Check that the source tree contains a module. - find_roots( - source_tree, - &pyproject_toml, - &settings.module_root, - settings.module_name.as_ref(), - )?; - + debug!("Adding content files to source distribution"); writer.write_bytes( &Path::new(&top_level) .join("PKG-INFO") @@ -208,7 +206,8 @@ fn write_source_dist( metadata_email.as_bytes(), )?; - let (include_matcher, exclude_matcher) = source_dist_matcher(&pyproject_toml, settings)?; + let (include_matcher, exclude_matcher) = + source_dist_matcher(source_tree, &pyproject_toml, settings)?; let mut files_visited = 0; for entry in WalkDir::new(source_tree) diff --git a/crates/uv-build-backend/src/wheel.rs b/crates/uv-build-backend/src/wheel.rs index b8e14967f..cf007c9c5 100644 --- a/crates/uv-build-backend/src/wheel.rs +++ b/crates/uv-build-backend/src/wheel.rs @@ -4,7 +4,6 @@ use itertools::Itertools; use sha2::{Digest, Sha256}; use std::io::{BufReader, Read, Write}; use std::path::{Path, PathBuf}; -use std::str::FromStr; use std::{io, mem}; use tracing::{debug, trace}; use walkdir::WalkDir; @@ -14,12 +13,12 @@ use uv_distribution_filename::WheelFilename; use uv_fs::Simplified; use uv_globfilter::{GlobDirFilter, PortableGlobParser}; use uv_platform_tags::{AbiTag, LanguageTag, PlatformTag}; -use uv_pypi_types::Identifier; use uv_warnings::warn_user_once; use crate::metadata::DEFAULT_EXCLUDES; use crate::{ - find_roots, BuildBackendSettings, DirectoryWriter, Error, FileList, ListWriter, PyProjectToml, + find_module_root, find_roots, BuildBackendSettings, DirectoryWriter, Error, FileList, + ListWriter, PyProjectToml, }; /// Build a wheel from the source tree and place it in the output directory. @@ -273,17 +272,12 @@ pub fn build_editable( return Err(Error::InvalidModuleRoot(settings.module_root.clone())); } - let module_name = if let Some(module_name) = settings.module_name { - module_name - } else { - // Should never error, the rules for package names (in dist-info formatting) are stricter - // than those for identifiers - Identifier::from_str(pyproject_toml.name().as_dist_info_name().as_ref())? - }; - debug!("Module name: `{:?}`", module_name); - // Check that a module root exists in the directory we're linking from the `.pth` file - crate::find_module_root(&src_root, module_name)?; + find_module_root( + &src_root, + settings.module_name.as_ref(), + pyproject_toml.name(), + )?; wheel_writer.write_bytes( &format!("{}.pth", pyproject_toml.name().as_dist_info_name()), diff --git a/crates/uv-globfilter/src/portable_glob.rs b/crates/uv-globfilter/src/portable_glob.rs index 367b7db0f..5e22b3fa8 100644 --- a/crates/uv-globfilter/src/portable_glob.rs +++ b/crates/uv-globfilter/src/portable_glob.rs @@ -94,6 +94,7 @@ impl PortableGlobParser { self.check(glob)?; Ok(GlobBuilder::new(glob) .literal_separator(true) + // No need to support Windows-style paths, so the backslash can be used a escape. .backslash_escape(self.backslash_escape()) .build()?) } diff --git a/crates/uv/tests/it/build_backend.rs b/crates/uv/tests/it/build_backend.rs index 868c834d0..b9e934403 100644 --- a/crates/uv/tests/it/build_backend.rs +++ b/crates/uv/tests/it/build_backend.rs @@ -451,6 +451,9 @@ fn build_module_name_normalization() -> Result<()> { [build-system] requires = ["uv_build>=0.5,<0.8"] build-backend = "uv_build" + + [tool.uv.build-backend] + module-name = "Django_plugin" "#})?; fs_err::create_dir_all(context.temp_dir.join("src"))?; @@ -458,28 +461,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 directory at: `src/django_plugin` - "###); + error: Missing module directory for `Django_plugin` in `src`. Found: `` + "); 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###" + .arg(&wheel_dir), @r" success: false exit_code: 2 ----- stdout ----- ----- stderr ----- - error: Expected an `__init__.py` at: `src/Django_plugin/__init__.py` - "###); + error: Expected a Python module directory at: `src/Django_plugin/__init__.py` + "); // Use `Django_plugin` instead of `django_plugin` context @@ -521,7 +524,7 @@ fn build_module_name_normalization() -> Result<()> { ----- stderr ----- "); - // Error case 3: Multiple modules a matching name. + // Former error case 3, now accepted: Multiple modules a matching name. // Requires a case-sensitive filesystem. #[cfg(target_os = "linux")] { @@ -534,14 +537,12 @@ fn build_module_name_normalization() -> Result<()> { .build_backend() .arg("build-wheel") .arg(&wheel_dir), @r" - success: false - exit_code: 2 + success: true + exit_code: 0 ----- stdout ----- + django_plugin-1.0.0-py3-none-any.whl ----- stderr ----- - error: Expected an `__init__.py` at `django_plugin`, found multiple: - * `src/Django_plugin` - * `src/django_plugin` "); } @@ -635,7 +636,7 @@ fn sdist_error_without_module() -> Result<()> { ----- stdout ----- ----- stderr ----- - error: Expected a Python module directory at: `src/foo` + error: Missing module directory for `foo` in `src`. Found: `` "); Ok(()) diff --git a/docs/configuration/build-backend.md b/docs/configuration/build-backend.md index 40bc3a282..a3984cafa 100644 --- a/docs/configuration/build-backend.md +++ b/docs/configuration/build-backend.md @@ -40,6 +40,20 @@ command includes a copy of the build backend, so when running `uv build`, the sa used for the build backend as for the uv process. Other build frontends, such as `python -m build`, will choose the latest compatible `uv_build` version. +## Modules + +The default module name is the package name in lower case with dots and dashes replaced by +underscores, and the default module location is under the `src` directory, i.e., the build backend +expects to find `src//__init__.py`. These defaults can be changed with the +`module-name` and `module-root` setting. The example below expects a module in the project root with +`PIL/__init__.py` instead: + +```toml +[tool.uv.build-backend] +module-name = "PIL" +module-root = "" +``` + ## Include and exclude configuration To select which files to include in the source distribution, uv first adds the included files and