mirror of
https://github.com/astral-sh/uv.git
synced 2025-08-04 19:08:04 +00:00
Build backend: Check module dir exists for sdist build (#12779)
Check that the source and module directory exist when build a source distribution, instead of delaying the check to building the wheel. This prevents building source distributions that can never be built into wheels.
This commit is contained in:
parent
980599f4fa
commit
a45ca9a36d
4 changed files with 150 additions and 61 deletions
|
@ -10,6 +10,7 @@ pub use wheel::{build_editable, build_wheel, list_wheel, metadata};
|
|||
use std::fs::FileType;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::str::FromStr;
|
||||
|
||||
use itertools::Itertools;
|
||||
use thiserror::Error;
|
||||
|
@ -58,6 +59,11 @@ pub enum Error {
|
|||
Zip(#[from] zip::result::ZipError),
|
||||
#[error("Failed to write RECORD file")]
|
||||
Csv(#[from] csv::Error),
|
||||
#[error(
|
||||
"Missing source directory at: `{}`",
|
||||
_0.user_display()
|
||||
)]
|
||||
MissingSrc(PathBuf),
|
||||
#[error(
|
||||
"Expected a Python module directory at: `{}`",
|
||||
_0.user_display()
|
||||
|
@ -188,6 +194,80 @@ fn check_metadata_directory(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve the source root and module root paths.
|
||||
fn find_roots(
|
||||
source_tree: &Path,
|
||||
pyproject_toml: &PyProjectToml,
|
||||
relative_module_root: &Path,
|
||||
module_name: Option<&Identifier>,
|
||||
) -> Result<(PathBuf, PathBuf), Error> {
|
||||
if relative_module_root.is_absolute() {
|
||||
return Err(Error::AbsoluteModuleRoot(
|
||||
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 {
|
||||
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))
|
||||
}
|
||||
|
||||
/// 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 dir_iterator = match fs_err::read_dir(src_root) {
|
||||
Ok(dir_iterator) => dir_iterator,
|
||||
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::<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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
use crate::metadata::{BuildBackendSettings, DEFAULT_EXCLUDES};
|
||||
use crate::wheel::build_exclude_matcher;
|
||||
use crate::{DirectoryWriter, Error, FileList, ListWriter, PyProjectToml};
|
||||
use crate::{find_roots, DirectoryWriter, Error, FileList, ListWriter, PyProjectToml};
|
||||
use flate2::write::GzEncoder;
|
||||
use flate2::Compression;
|
||||
use fs_err::File;
|
||||
|
@ -184,6 +184,15 @@ 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(),
|
||||
)?;
|
||||
|
||||
writer.write_bytes(
|
||||
&Path::new(&top_level)
|
||||
.join("PKG-INFO")
|
||||
|
|
|
@ -18,7 +18,7 @@ use uv_pypi_types::Identifier;
|
|||
use uv_warnings::warn_user_once;
|
||||
|
||||
use crate::metadata::{BuildBackendSettings, DEFAULT_EXCLUDES};
|
||||
use crate::{DirectoryWriter, Error, FileList, ListWriter, PyProjectToml};
|
||||
use crate::{find_roots, DirectoryWriter, Error, FileList, ListWriter, PyProjectToml};
|
||||
|
||||
/// Build a wheel from the source tree and place it in the output directory.
|
||||
pub fn build_wheel(
|
||||
|
@ -115,31 +115,22 @@ fn write_wheel(
|
|||
}
|
||||
// The wheel must not include any files excluded by the source distribution (at least until we
|
||||
// have files generated in the source dist -> wheel build step).
|
||||
for exclude in settings.source_exclude {
|
||||
for exclude in &settings.source_exclude {
|
||||
// Avoid duplicate entries.
|
||||
if !excludes.contains(&exclude) {
|
||||
excludes.push(exclude);
|
||||
if !excludes.contains(exclude) {
|
||||
excludes.push(exclude.clone());
|
||||
}
|
||||
}
|
||||
debug!("Wheel excludes: {:?}", excludes);
|
||||
let exclude_matcher = build_exclude_matcher(excludes)?;
|
||||
|
||||
debug!("Adding content files to wheel");
|
||||
if settings.module_root.is_absolute() {
|
||||
return Err(Error::AbsoluteModuleRoot(settings.module_root.clone()));
|
||||
}
|
||||
let src_root = source_tree.join(settings.module_root);
|
||||
|
||||
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);
|
||||
|
||||
let module_root = find_module_root(&src_root, module_name)?;
|
||||
let (src_root, module_root) = find_roots(
|
||||
source_tree,
|
||||
pyproject_toml,
|
||||
&settings.module_root,
|
||||
settings.module_name.as_ref(),
|
||||
)?;
|
||||
|
||||
let mut files_visited = 0;
|
||||
for entry in WalkDir::new(module_root)
|
||||
|
@ -241,46 +232,6 @@ 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_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.
|
||||
pub fn build_editable(
|
||||
source_tree: &Path,
|
||||
|
@ -331,7 +282,7 @@ pub fn build_editable(
|
|||
debug!("Module name: `{:?}`", module_name);
|
||||
|
||||
// Check that a module root exists in the directory we're linking from the `.pth` file
|
||||
find_module_root(&src_root, module_name)?;
|
||||
crate::find_module_root(&src_root, module_name)?;
|
||||
|
||||
wheel_writer.write_bytes(
|
||||
&format!("{}.pth", pyproject_toml.name().as_dist_info_name()),
|
||||
|
|
|
@ -591,3 +591,52 @@ fn build_sdist_with_long_path() -> Result<()> {
|
|||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sdist_error_without_module() -> Result<()> {
|
||||
let context = TestContext::new("3.12");
|
||||
let temp_dir = TempDir::new()?;
|
||||
|
||||
context
|
||||
.temp_dir
|
||||
.child("pyproject.toml")
|
||||
.write_str(indoc! {r#"
|
||||
[project]
|
||||
name = "foo"
|
||||
version = "1.0.0"
|
||||
|
||||
[build-system]
|
||||
requires = ["uv_build>=0.6,<0.7"]
|
||||
build-backend = "uv_build"
|
||||
"#})?;
|
||||
|
||||
uv_snapshot!(context
|
||||
.build_backend()
|
||||
.arg("build-sdist")
|
||||
.arg(temp_dir.path())
|
||||
.env("UV_PREVIEW", "1"), @r"
|
||||
success: false
|
||||
exit_code: 2
|
||||
----- stdout -----
|
||||
|
||||
----- stderr -----
|
||||
error: Missing source directory at: `src`
|
||||
");
|
||||
|
||||
fs_err::create_dir(context.temp_dir.join("src"))?;
|
||||
|
||||
uv_snapshot!(context
|
||||
.build_backend()
|
||||
.arg("build-sdist")
|
||||
.arg(temp_dir.path())
|
||||
.env("UV_PREVIEW", "1"), @r"
|
||||
success: false
|
||||
exit_code: 2
|
||||
----- stdout -----
|
||||
|
||||
----- stderr -----
|
||||
error: Expected a Python module directory at: `src/foo`
|
||||
");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue