mirror of https://github.com/astral-sh/uv
Build backend: Case sensitive module discovery (#13468)
We may run on case-sensitive file systems (Linux, generally) or on case-insensitive file systems (Windows, generally), while modules in Python may be lower or upper case. For robustness over filesystem casing, we require an explicit module name for modules with upper cases. Fixes #13419
This commit is contained in:
parent
23261b7e2e
commit
77268ee152
|
|
@ -14,12 +14,12 @@ use std::io;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
|
||||||
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_normalize::PackageName;
|
||||||
use uv_pypi_types::{Identifier, IdentifierParseError};
|
use uv_pypi_types::{Identifier, IdentifierParseError};
|
||||||
|
|
||||||
use crate::metadata::ValidationError;
|
use crate::metadata::ValidationError;
|
||||||
|
|
@ -70,20 +70,17 @@ pub enum Error {
|
||||||
"Expected a Python module directory at: `{}`",
|
"Expected a Python module directory at: `{}`",
|
||||||
_0.user_display()
|
_0.user_display()
|
||||||
)]
|
)]
|
||||||
MissingModule(PathBuf),
|
|
||||||
#[error(
|
|
||||||
"Expected an `__init__.py` at: `{}`",
|
|
||||||
_0.user_display()
|
|
||||||
)]
|
|
||||||
MissingInitPy(PathBuf),
|
MissingInitPy(PathBuf),
|
||||||
#[error(
|
#[error(
|
||||||
"Expected an `__init__.py` at `{}`, found multiple:\n* `{}`",
|
"Missing module directory for `{}` in `{}`. Found: `{}`",
|
||||||
module_name,
|
module_name,
|
||||||
paths.iter().map(Simplified::user_display).join("`\n* `")
|
src_root.user_display(),
|
||||||
|
dir_listing.join("`, `")
|
||||||
)]
|
)]
|
||||||
MultipleModules {
|
MissingModuleDir {
|
||||||
module_name: Identifier,
|
module_name: String,
|
||||||
paths: Vec<PathBuf>,
|
src_root: PathBuf,
|
||||||
|
dir_listing: Vec<String>,
|
||||||
},
|
},
|
||||||
/// Either an absolute path or a parent path through `..`.
|
/// Either an absolute path or a parent path through `..`.
|
||||||
#[error("Module root must be inside the project: `{}`", _0.user_display())]
|
#[error("Module root must be inside the project: `{}`", _0.user_display())]
|
||||||
|
|
@ -197,76 +194,93 @@ fn check_metadata_directory(
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve the source root and module root paths.
|
/// Resolve the source root, module root and the module name.
|
||||||
fn find_roots(
|
fn find_roots(
|
||||||
source_tree: &Path,
|
source_tree: &Path,
|
||||||
pyproject_toml: &PyProjectToml,
|
pyproject_toml: &PyProjectToml,
|
||||||
relative_module_root: &Path,
|
relative_module_root: &Path,
|
||||||
module_name: Option<&Identifier>,
|
module_name: Option<&Identifier>,
|
||||||
) -> Result<(PathBuf, PathBuf), Error> {
|
) -> 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) {
|
if !src_root.starts_with(source_tree) {
|
||||||
return Err(Error::InvalidModuleRoot(relative_module_root.to_path_buf()));
|
return Err(Error::InvalidModuleRoot(relative_module_root.to_path_buf()));
|
||||||
}
|
}
|
||||||
|
let src_root = source_tree.join(&relative_module_root);
|
||||||
let module_name = if let Some(module_name) = module_name {
|
let module_root = find_module_root(&src_root, module_name, pyproject_toml.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)?;
|
|
||||||
Ok((src_root, module_root))
|
Ok((src_root, module_root))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Match the module name to its module directory with potentially different casing.
|
/// 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
|
/// Some target platforms have case-sensitive filesystems, while others have case-insensitive
|
||||||
/// importable module is named `PIL_util`.
|
/// 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.
|
/// By default, the dist-info-normalized package name is the module name. For
|
||||||
/// For dist-info-normalizing a package name, the rules are lowercasing, replacing `.` with `_` and
|
/// dist-info-normalization, the rules are lowercasing, replacing `.` with `_` and
|
||||||
/// replace `-` with `_`. Since `.` and `-` are not allowed in module names, we can check whether a
|
/// replace `-` with `_`. Since `.` and `-` are not allowed in identifiers, we can use a string
|
||||||
/// directory name matches our expected module name by lowercasing it.
|
/// comparison with the module name.
|
||||||
fn find_module_root(src_root: &Path, module_name: Identifier) -> Result<PathBuf, Error> {
|
///
|
||||||
let normalized = module_name.to_string();
|
/// To make the behavior as consistent as possible across platforms as possible, we require that an
|
||||||
let dir_iterator = match fs_err::read_dir(src_root) {
|
/// upper case name is given explicitly through `tool.uv.module-name`.
|
||||||
Ok(dir_iterator) => dir_iterator,
|
///
|
||||||
|
/// 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<PathBuf, Error> {
|
||||||
|
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::<Result<Vec<_>, _>>()?,
|
||||||
Err(err) if err.kind() == io::ErrorKind::NotFound => {
|
Err(err) if err.kind() == io::ErrorKind::NotFound => {
|
||||||
return Err(Error::MissingSrc(src_root.to_path_buf()))
|
return Err(Error::MissingSrc(src_root.to_path_buf()))
|
||||||
}
|
}
|
||||||
Err(err) => return Err(Error::Io(err)),
|
Err(err) => return Err(Error::Io(err)),
|
||||||
};
|
};
|
||||||
let modules = dir_iterator
|
let module_root = dir.iter().find_map(|entry| {
|
||||||
.filter_ok(|entry| {
|
// TODO(konsti): Do we ever need to check if `dir/{module_name}/__init__.py` exists because
|
||||||
entry
|
// the wrong casing may be recorded on disk?
|
||||||
.file_name()
|
if entry
|
||||||
.to_str()
|
.file_name()
|
||||||
.is_some_and(|file_name| file_name.to_lowercase() == normalized)
|
.to_str()
|
||||||
})
|
.is_some_and(|file_name| file_name == module_name)
|
||||||
.map_ok(|entry| entry.path())
|
{
|
||||||
.collect::<Result<Vec<_>, _>>()?;
|
Some(entry.path())
|
||||||
match modules.as_slice() {
|
} else {
|
||||||
[] => {
|
None
|
||||||
// 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() {
|
let module_root = if let Some(module_root) = module_root {
|
||||||
Ok(module_root.clone())
|
if module_root.join("__init__.py").is_file() {
|
||||||
} else {
|
module_root.clone()
|
||||||
Err(Error::MissingInitPy(module_root.join("__init__.py")))
|
} else {
|
||||||
}
|
return Err(Error::MissingInitPy(module_root.join("__init__.py")));
|
||||||
}
|
}
|
||||||
multiple => {
|
} else {
|
||||||
let mut paths = multiple.to_vec();
|
return Err(Error::MissingModuleDir {
|
||||||
paths.sort();
|
module_name,
|
||||||
Err(Error::MultipleModules { module_name, paths })
|
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)]
|
#[cfg(test)]
|
||||||
|
|
@ -299,37 +313,36 @@ mod tests {
|
||||||
|
|
||||||
/// Run both a direct wheel build and an indirect wheel build through a source distribution,
|
/// 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.
|
/// 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<BuildResults, Error> {
|
||||||
// Build a direct wheel, capture all its properties to compare it with the indirect wheel
|
// 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.
|
// 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 (_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").unwrap();
|
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_path = dist.join(direct_wheel_filename.to_string());
|
||||||
let direct_wheel_contents = wheel_contents(&direct_wheel_path);
|
let direct_wheel_contents = wheel_contents(&direct_wheel_path);
|
||||||
let direct_wheel_hash = sha2::Sha256::digest(fs_err::read(&direct_wheel_path).unwrap());
|
let direct_wheel_hash = sha2::Sha256::digest(fs_err::read(&direct_wheel_path)?);
|
||||||
fs_err::remove_file(&direct_wheel_path).unwrap();
|
fs_err::remove_file(&direct_wheel_path)?;
|
||||||
|
|
||||||
// Build a source distribution.
|
// 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
|
// TODO(konsti): This should run in the unpacked source dist tempdir, but we need to
|
||||||
// normalize the path.
|
// normalize the path.
|
||||||
let (_name, wheel_list_files) = list_wheel(source_root, "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").unwrap();
|
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_path = dist.join(source_dist_filename.to_string());
|
||||||
let source_dist_contents = sdist_contents(&source_dist_path);
|
let source_dist_contents = sdist_contents(&source_dist_path);
|
||||||
|
|
||||||
// Unpack the source distribution and build a wheel from it.
|
// Unpack the source distribution and build a wheel from it.
|
||||||
let sdist_tree = TempDir::new().unwrap();
|
let sdist_tree = TempDir::new()?;
|
||||||
let sdist_reader = BufReader::new(File::open(&source_dist_path).unwrap());
|
let sdist_reader = BufReader::new(File::open(&source_dist_path)?);
|
||||||
let mut source_dist = tar::Archive::new(GzDecoder::new(sdist_reader));
|
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!(
|
let sdist_top_level_directory = sdist_tree.path().join(format!(
|
||||||
"{}-{}",
|
"{}-{}",
|
||||||
source_dist_filename.name.as_dist_info_name(),
|
source_dist_filename.name.as_dist_info_name(),
|
||||||
source_dist_filename.version
|
source_dist_filename.version
|
||||||
));
|
));
|
||||||
let wheel_filename =
|
let wheel_filename = build_wheel(&sdist_top_level_directory, dist, None, "1.0.0+test")?;
|
||||||
build_wheel(&sdist_top_level_directory, dist, None, "1.0.0+test").unwrap();
|
|
||||||
let wheel_contents = wheel_contents(&dist.join(wheel_filename.to_string()));
|
let wheel_contents = wheel_contents(&dist.join(wheel_filename.to_string()));
|
||||||
|
|
||||||
// Check that direct and indirect wheels are identical.
|
// 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_list_files, wheel_list_files);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
direct_wheel_hash,
|
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_list_files,
|
||||||
source_dist_filename,
|
source_dist_filename,
|
||||||
source_dist_contents,
|
source_dist_contents,
|
||||||
wheel_list_files,
|
wheel_list_files,
|
||||||
wheel_filename,
|
wheel_filename,
|
||||||
wheel_contents,
|
wheel_contents,
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sdist_contents(source_dist_path: &Path) -> Vec<String> {
|
fn sdist_contents(source_dist_path: &Path) -> Vec<String> {
|
||||||
|
|
@ -453,7 +466,7 @@ mod tests {
|
||||||
|
|
||||||
// Perform both the direct and the indirect build.
|
// Perform both the direct and the indirect build.
|
||||||
let dist = TempDir::new().unwrap();
|
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());
|
let source_dist_path = dist.path().join(build.source_dist_filename.to_string());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|
@ -721,7 +734,7 @@ mod tests {
|
||||||
File::create(src.path().join("two_step_build").join("__init__.py")).unwrap();
|
File::create(src.path().join("two_step_build").join("__init__.py")).unwrap();
|
||||||
|
|
||||||
let dist = TempDir::new().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"
|
assert_snapshot!(build1.source_dist_contents.join("\n"), @r"
|
||||||
two_step_build-1.0.0/
|
two_step_build-1.0.0/
|
||||||
|
|
@ -760,7 +773,58 @@ mod tests {
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let dist = TempDir::new().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);
|
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`"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,13 +10,11 @@ use globset::{Glob, GlobSet};
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::io::{BufReader, Cursor};
|
use std::io::{BufReader, Cursor};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::str::FromStr;
|
|
||||||
use tar::{EntryType, Header};
|
use tar::{EntryType, Header};
|
||||||
use tracing::{debug, trace};
|
use tracing::{debug, trace};
|
||||||
use uv_distribution_filename::{SourceDistExtension, SourceDistFilename};
|
use uv_distribution_filename::{SourceDistExtension, SourceDistFilename};
|
||||||
use uv_fs::Simplified;
|
use uv_fs::Simplified;
|
||||||
use uv_globfilter::{GlobDirFilter, PortableGlobParser};
|
use uv_globfilter::{GlobDirFilter, PortableGlobParser};
|
||||||
use uv_pypi_types::Identifier;
|
|
||||||
use uv_warnings::warn_user_once;
|
use uv_warnings::warn_user_once;
|
||||||
use walkdir::WalkDir;
|
use walkdir::WalkDir;
|
||||||
|
|
||||||
|
|
@ -59,6 +57,7 @@ pub fn list_source_dist(
|
||||||
|
|
||||||
/// Build includes and excludes for source tree walking for source dists.
|
/// Build includes and excludes for source tree walking for source dists.
|
||||||
fn source_dist_matcher(
|
fn source_dist_matcher(
|
||||||
|
source_tree: &Path,
|
||||||
pyproject_toml: &PyProjectToml,
|
pyproject_toml: &PyProjectToml,
|
||||||
settings: BuildBackendSettings,
|
settings: BuildBackendSettings,
|
||||||
) -> Result<(GlobDirFilter, GlobSet), Error> {
|
) -> Result<(GlobDirFilter, GlobSet), Error> {
|
||||||
|
|
@ -68,20 +67,20 @@ fn source_dist_matcher(
|
||||||
// pyproject.toml is always included.
|
// pyproject.toml is always included.
|
||||||
includes.push(globset::escape("pyproject.toml"));
|
includes.push(globset::escape("pyproject.toml"));
|
||||||
|
|
||||||
let module_name = if let Some(module_name) = settings.module_name {
|
// Check that the source tree contains a module.
|
||||||
module_name
|
let (_, module_root) = find_roots(
|
||||||
} else {
|
source_tree,
|
||||||
// Should never error, the rules for package names (in dist-info formatting) are stricter
|
pyproject_toml,
|
||||||
// than those for identifiers
|
&settings.module_root,
|
||||||
Identifier::from_str(pyproject_toml.name().as_dist_info_name().as_ref())?
|
settings.module_name.as_ref(),
|
||||||
};
|
)?;
|
||||||
debug!("Module name: `{:?}`", module_name);
|
|
||||||
|
|
||||||
// The wheel must not include any files included by the source distribution (at least until we
|
// 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).
|
// 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()))
|
let import_path = uv_fs::normalize_path(
|
||||||
.portable_display()
|
&uv_fs::relative_to(module_root, source_tree).expect("module root is inside source tree"),
|
||||||
.to_string();
|
)
|
||||||
|
.portable_display()
|
||||||
|
.to_string();
|
||||||
includes.push(format!("{}/**", globset::escape(&import_path)));
|
includes.push(format!("{}/**", globset::escape(&import_path)));
|
||||||
for include in includes {
|
for include in includes {
|
||||||
let glob = PortableGlobParser::Uv
|
let glob = PortableGlobParser::Uv
|
||||||
|
|
@ -136,6 +135,13 @@ fn source_dist_matcher(
|
||||||
include_globs.push(glob);
|
include_globs.push(glob);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
debug!(
|
||||||
|
"Source distribution includes: `{:?}`",
|
||||||
|
include_globs
|
||||||
|
.iter()
|
||||||
|
.map(ToString::to_string)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
);
|
||||||
let include_matcher =
|
let include_matcher =
|
||||||
GlobDirFilter::from_globs(&include_globs).map_err(|err| Error::GlobSetTooLarge {
|
GlobDirFilter::from_globs(&include_globs).map_err(|err| Error::GlobSetTooLarge {
|
||||||
field: "tool.uv.build-backend.source-include".to_string(),
|
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 = pyproject_toml.to_metadata(source_tree)?;
|
||||||
let metadata_email = metadata.core_metadata_format();
|
let metadata_email = metadata.core_metadata_format();
|
||||||
|
|
||||||
debug!("Adding content files to wheel");
|
debug!("Adding content files to source distribution");
|
||||||
// Check that the source tree contains a module.
|
|
||||||
find_roots(
|
|
||||||
source_tree,
|
|
||||||
&pyproject_toml,
|
|
||||||
&settings.module_root,
|
|
||||||
settings.module_name.as_ref(),
|
|
||||||
)?;
|
|
||||||
|
|
||||||
writer.write_bytes(
|
writer.write_bytes(
|
||||||
&Path::new(&top_level)
|
&Path::new(&top_level)
|
||||||
.join("PKG-INFO")
|
.join("PKG-INFO")
|
||||||
|
|
@ -208,7 +206,8 @@ fn write_source_dist(
|
||||||
metadata_email.as_bytes(),
|
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;
|
let mut files_visited = 0;
|
||||||
for entry in WalkDir::new(source_tree)
|
for entry in WalkDir::new(source_tree)
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ use itertools::Itertools;
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use std::io::{BufReader, Read, Write};
|
use std::io::{BufReader, Read, Write};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::str::FromStr;
|
|
||||||
use std::{io, mem};
|
use std::{io, mem};
|
||||||
use tracing::{debug, trace};
|
use tracing::{debug, trace};
|
||||||
use walkdir::WalkDir;
|
use walkdir::WalkDir;
|
||||||
|
|
@ -14,12 +13,12 @@ use uv_distribution_filename::WheelFilename;
|
||||||
use uv_fs::Simplified;
|
use uv_fs::Simplified;
|
||||||
use uv_globfilter::{GlobDirFilter, PortableGlobParser};
|
use uv_globfilter::{GlobDirFilter, PortableGlobParser};
|
||||||
use uv_platform_tags::{AbiTag, LanguageTag, PlatformTag};
|
use uv_platform_tags::{AbiTag, LanguageTag, PlatformTag};
|
||||||
use uv_pypi_types::Identifier;
|
|
||||||
use uv_warnings::warn_user_once;
|
use uv_warnings::warn_user_once;
|
||||||
|
|
||||||
use crate::metadata::DEFAULT_EXCLUDES;
|
use crate::metadata::DEFAULT_EXCLUDES;
|
||||||
use crate::{
|
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.
|
/// 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()));
|
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
|
// 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(
|
wheel_writer.write_bytes(
|
||||||
&format!("{}.pth", pyproject_toml.name().as_dist_info_name()),
|
&format!("{}.pth", pyproject_toml.name().as_dist_info_name()),
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,7 @@ impl PortableGlobParser {
|
||||||
self.check(glob)?;
|
self.check(glob)?;
|
||||||
Ok(GlobBuilder::new(glob)
|
Ok(GlobBuilder::new(glob)
|
||||||
.literal_separator(true)
|
.literal_separator(true)
|
||||||
|
// No need to support Windows-style paths, so the backslash can be used a escape.
|
||||||
.backslash_escape(self.backslash_escape())
|
.backslash_escape(self.backslash_escape())
|
||||||
.build()?)
|
.build()?)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -451,6 +451,9 @@ fn build_module_name_normalization() -> Result<()> {
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["uv_build>=0.5,<0.8"]
|
requires = ["uv_build>=0.5,<0.8"]
|
||||||
build-backend = "uv_build"
|
build-backend = "uv_build"
|
||||||
|
|
||||||
|
[tool.uv.build-backend]
|
||||||
|
module-name = "Django_plugin"
|
||||||
"#})?;
|
"#})?;
|
||||||
fs_err::create_dir_all(context.temp_dir.join("src"))?;
|
fs_err::create_dir_all(context.temp_dir.join("src"))?;
|
||||||
|
|
||||||
|
|
@ -458,28 +461,28 @@ fn build_module_name_normalization() -> Result<()> {
|
||||||
uv_snapshot!(context
|
uv_snapshot!(context
|
||||||
.build_backend()
|
.build_backend()
|
||||||
.arg("build-wheel")
|
.arg("build-wheel")
|
||||||
.arg(&wheel_dir), @r###"
|
.arg(&wheel_dir), @r"
|
||||||
success: false
|
success: false
|
||||||
exit_code: 2
|
exit_code: 2
|
||||||
----- stdout -----
|
----- stdout -----
|
||||||
|
|
||||||
----- stderr -----
|
----- 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"))?;
|
fs_err::create_dir_all(context.temp_dir.join("src/Django_plugin"))?;
|
||||||
// Error case 2: A matching module, but no `__init__.py`.
|
// Error case 2: A matching module, but no `__init__.py`.
|
||||||
uv_snapshot!(context
|
uv_snapshot!(context
|
||||||
.build_backend()
|
.build_backend()
|
||||||
.arg("build-wheel")
|
.arg("build-wheel")
|
||||||
.arg(&wheel_dir), @r###"
|
.arg(&wheel_dir), @r"
|
||||||
success: false
|
success: false
|
||||||
exit_code: 2
|
exit_code: 2
|
||||||
----- stdout -----
|
----- stdout -----
|
||||||
|
|
||||||
----- stderr -----
|
----- 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`
|
// Use `Django_plugin` instead of `django_plugin`
|
||||||
context
|
context
|
||||||
|
|
@ -521,7 +524,7 @@ fn build_module_name_normalization() -> Result<()> {
|
||||||
----- stderr -----
|
----- 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.
|
// Requires a case-sensitive filesystem.
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
{
|
{
|
||||||
|
|
@ -534,14 +537,12 @@ fn build_module_name_normalization() -> Result<()> {
|
||||||
.build_backend()
|
.build_backend()
|
||||||
.arg("build-wheel")
|
.arg("build-wheel")
|
||||||
.arg(&wheel_dir), @r"
|
.arg(&wheel_dir), @r"
|
||||||
success: false
|
success: true
|
||||||
exit_code: 2
|
exit_code: 0
|
||||||
----- stdout -----
|
----- stdout -----
|
||||||
|
django_plugin-1.0.0-py3-none-any.whl
|
||||||
|
|
||||||
----- stderr -----
|
----- 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 -----
|
----- stdout -----
|
||||||
|
|
||||||
----- stderr -----
|
----- stderr -----
|
||||||
error: Expected a Python module directory at: `src/foo`
|
error: Missing module directory for `foo` in `src`. Found: ``
|
||||||
");
|
");
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
|
||||||
|
|
@ -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`,
|
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.
|
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/<package_name>/__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
|
## Include and exclude configuration
|
||||||
|
|
||||||
To select which files to include in the source distribution, uv first adds the included files and
|
To select which files to include in the source distribution, uv first adds the included files and
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue