Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/uv-build-backend/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ flate2 = { workspace = true, default-features = false }
fs-err = { workspace = true }
globset = { workspace = true }
itertools = { workspace = true }
rustc-hash = { workspace = true }
schemars = { workspace = true, optional = true }
serde = { workspace = true }
sha2 = { workspace = true }
Expand Down
188 changes: 164 additions & 24 deletions crates/uv-build-backend/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ use uv_normalize::PackageName;
use uv_pypi_types::{Identifier, IdentifierParseError};

use crate::metadata::ValidationError;
use crate::settings::ModuleName;

#[derive(Debug, Error)]
pub enum Error {
Expand Down Expand Up @@ -184,7 +185,7 @@ fn check_metadata_directory(
Ok(())
}

/// Returns the source root and the module path with the `__init__.py[i]` below to it while
/// Returns the source root and the module path(s) with the `__init__.py[i]` below to it while
/// checking the project layout and names.
///
/// Some target platforms have case-sensitive filesystems, while others have case-insensitive
Expand All @@ -198,13 +199,15 @@ fn check_metadata_directory(
/// 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.
///
/// While we recommend one module per package, it is possible to declare a list of modules.
fn find_roots(
source_tree: &Path,
pyproject_toml: &PyProjectToml,
relative_module_root: &Path,
module_name: Option<&str>,
module_name: Option<&ModuleName>,
namespace: bool,
) -> Result<(PathBuf, PathBuf), Error> {
) -> Result<(PathBuf, Vec<PathBuf>), Error> {
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) {
Expand All @@ -215,22 +218,45 @@ fn find_roots(

if namespace {
// `namespace = true` disables module structure checks.
let module_relative = if let Some(module_name) = module_name {
module_name.split('.').collect::<PathBuf>()
let modules_relative = if let Some(module_name) = module_name {
match module_name {
ModuleName::Name(name) => {
vec![name.split('.').collect::<PathBuf>()]
}
ModuleName::Names(names) => names
.iter()
.map(|name| name.split('.').collect::<PathBuf>())
.collect(),
}
} else {
PathBuf::from(pyproject_toml.name().as_dist_info_name().to_string())
vec![PathBuf::from(
pyproject_toml.name().as_dist_info_name().to_string(),
)]
};
debug!("Namespace module path: {}", module_relative.user_display());
return Ok((src_root, module_relative));
for module_relative in &modules_relative {
debug!("Namespace module path: {}", module_relative.user_display());
}
return Ok((src_root, modules_relative));
}

let module_relative = if let Some(module_name) = module_name {
module_path_from_module_name(&src_root, module_name)?
let modules_relative = if let Some(module_name) = module_name {
match module_name {
ModuleName::Name(name) => vec![module_path_from_module_name(&src_root, name)?],
ModuleName::Names(names) => names
.iter()
.map(|name| module_path_from_module_name(&src_root, name))
.collect::<Result<_, _>>()?,
}
} else {
find_module_path_from_package_name(&src_root, pyproject_toml.name())?
vec![find_module_path_from_package_name(
&src_root,
pyproject_toml.name(),
)?]
};
debug!("Module path: {}", module_relative.user_display());
Ok((src_root, module_relative))
for module_relative in &modules_relative {
debug!("Module path: {}", module_relative.user_display());
}
Ok((src_root, modules_relative))
}

/// Infer stubs packages from package name alone.
Expand Down Expand Up @@ -410,6 +436,15 @@ mod tests {
})
}

fn build_err(source_root: &Path) -> String {
let dist = TempDir::new().unwrap();
let build_err = build(source_root, dist.path()).unwrap_err();
let err_message: String = format_err(&build_err)
.replace(&source_root.user_display().to_string(), "[TEMP_PATH]")
.replace('\\', "/");
err_message
}

fn sdist_contents(source_dist_path: &Path) -> Vec<String> {
let sdist_reader = BufReader::new(File::open(source_dist_path).unwrap());
let mut source_dist = tar::Archive::new(GzDecoder::new(sdist_reader));
Expand Down Expand Up @@ -998,13 +1033,8 @@ mod tests {
fs_err::create_dir_all(src.path().join("src").join("simple_namespace").join("part"))
.unwrap();

let dist = TempDir::new().unwrap();
let build_err = build(src.path(), dist.path()).unwrap_err();
let err_message = format_err(&build_err)
.replace(&src.path().user_display().to_string(), "[TEMP_PATH]")
.replace('\\', "/");
assert_snapshot!(
err_message,
build_err(src.path()),
@"Expected a Python module at: `[TEMP_PATH]/src/simple_namespace/part/__init__.py`"
);

Expand All @@ -1025,16 +1055,13 @@ mod tests {
.join("simple_namespace")
.join("__init__.py");
File::create(&bogus_init_py).unwrap();
let build_err = build(src.path(), dist.path()).unwrap_err();
let err_message = format_err(&build_err)
.replace(&src.path().user_display().to_string(), "[TEMP_PATH]")
.replace('\\', "/");
assert_snapshot!(
err_message,
build_err(src.path()),
@"For namespace packages, `__init__.py[i]` is not allowed in parent directory: `[TEMP_PATH]/src/simple_namespace`"
);
fs_err::remove_file(bogus_init_py).unwrap();

let dist = TempDir::new().unwrap();
let build1 = build(src.path(), dist.path()).unwrap();
assert_snapshot!(build1.source_dist_contents.join("\n"), @r"
simple_namespace_part-1.0.0/
Expand Down Expand Up @@ -1209,4 +1236,117 @@ mod tests {
cloud_db_schema_stubs-1.0.0.dist-info/WHEEL
");
}

/// A package with multiple modules, one a regular module and two namespace modules.
#[test]
fn multiple_module_names() {
let src = TempDir::new().unwrap();
let pyproject_toml = indoc! {r#"
[project]
name = "simple-namespace-part"
version = "1.0.0"

[tool.uv.build-backend]
module-name = ["foo", "simple_namespace.part_a", "simple_namespace.part_b"]

[build-system]
requires = ["uv_build>=0.5.15,<0.6"]
build-backend = "uv_build"
"#
};
fs_err::write(src.path().join("pyproject.toml"), pyproject_toml).unwrap();
fs_err::create_dir_all(src.path().join("src").join("foo")).unwrap();
fs_err::create_dir_all(
src.path()
.join("src")
.join("simple_namespace")
.join("part_a"),
)
.unwrap();
fs_err::create_dir_all(
src.path()
.join("src")
.join("simple_namespace")
.join("part_b"),
)
.unwrap();

// Most of these checks exist in other tests too, but we want to ensure that they apply
// with multiple modules too.

// The first module is missing an `__init__.py`.
assert_snapshot!(
build_err(src.path()),
@"Expected a Python module at: `[TEMP_PATH]/src/foo/__init__.py`"
);

// Create the first correct `__init__.py` file
File::create(src.path().join("src").join("foo").join("__init__.py")).unwrap();

// The second module, a namespace, is missing an `__init__.py`.
assert_snapshot!(
build_err(src.path()),
@"Expected a Python module at: `[TEMP_PATH]/src/simple_namespace/part_a/__init__.py`"
);

// Create the other two correct `__init__.py` files
File::create(
src.path()
.join("src")
.join("simple_namespace")
.join("part_a")
.join("__init__.py"),
)
.unwrap();
File::create(
src.path()
.join("src")
.join("simple_namespace")
.join("part_b")
.join("__init__.py"),
)
.unwrap();

// For the second module, a namespace, there must not be an `__init__.py` here.
let bogus_init_py = src
.path()
.join("src")
.join("simple_namespace")
.join("__init__.py");
File::create(&bogus_init_py).unwrap();
assert_snapshot!(
build_err(src.path()),
@"For namespace packages, `__init__.py[i]` is not allowed in parent directory: `[TEMP_PATH]/src/simple_namespace`"
);
fs_err::remove_file(bogus_init_py).unwrap();

let dist = TempDir::new().unwrap();
let build = build(src.path(), dist.path()).unwrap();
assert_snapshot!(build.source_dist_contents.join("\n"), @r"
simple_namespace_part-1.0.0/
simple_namespace_part-1.0.0/PKG-INFO
simple_namespace_part-1.0.0/pyproject.toml
simple_namespace_part-1.0.0/src
simple_namespace_part-1.0.0/src/foo
simple_namespace_part-1.0.0/src/foo/__init__.py
simple_namespace_part-1.0.0/src/simple_namespace
simple_namespace_part-1.0.0/src/simple_namespace/part_a
simple_namespace_part-1.0.0/src/simple_namespace/part_a/__init__.py
simple_namespace_part-1.0.0/src/simple_namespace/part_b
simple_namespace_part-1.0.0/src/simple_namespace/part_b/__init__.py
");
assert_snapshot!(build.wheel_contents.join("\n"), @r"
foo/
foo/__init__.py
simple_namespace/
simple_namespace/part_a/
simple_namespace/part_a/__init__.py
simple_namespace/part_b/
simple_namespace/part_b/__init__.py
simple_namespace_part-1.0.0.dist-info/
simple_namespace_part-1.0.0.dist-info/METADATA
simple_namespace_part-1.0.0.dist-info/RECORD
simple_namespace_part-1.0.0.dist-info/WHEEL
");
}
}
19 changes: 17 additions & 2 deletions crates/uv-build-backend/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,19 @@ pub struct BuildBackendSettings {
/// For namespace packages with a single module, the path can be dotted, e.g., `foo.bar` or
/// `foo-stubs.bar`.
///
/// For namespace packages with multiple modules, the path can be a list, e.g.,
/// `["foo", "bar"]`. We recommend using a single module per package, splitting multiple
/// packages into a workspace.
///
/// Note that using this option runs the risk of creating two packages with different names but
/// the same module names. Installing such packages together leads to unspecified behavior,
/// often with corrupted files or directory trees.
#[option(
default = r#"None"#,
value_type = "str",
value_type = "str | list[str]",
example = r#"module-name = "sklearn""#
)]
pub module_name: Option<String>,
pub module_name: Option<ModuleName>,

/// Glob expressions which files and directories to additionally include in the source
/// distribution.
Expand Down Expand Up @@ -181,6 +185,17 @@ impl Default for BuildBackendSettings {
}
}

/// Whether to include a single module or multiple modules.
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(untagged)]
pub enum ModuleName {
/// A single module name.
Name(String),
/// Multiple module names, which are all included.
Names(Vec<String>),
}

/// Data includes for wheels.
///
/// See `BuildBackendSettings::data`.
Expand Down
24 changes: 13 additions & 11 deletions crates/uv-build-backend/src/source_dist.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,22 +68,24 @@ fn source_dist_matcher(
includes.push(globset::escape("pyproject.toml"));

// Check that the source tree contains a module.
let (src_root, module_relative) = find_roots(
let (src_root, modules_relative) = find_roots(
source_tree,
pyproject_toml,
&settings.module_root,
settings.module_name.as_deref(),
settings.module_name.as_ref(),
settings.namespace,
)?;
// 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(
&uv_fs::relative_to(src_root.join(module_relative), source_tree)
.expect("module root is inside source tree"),
)
.portable_display()
.to_string();
includes.push(format!("{}/**", globset::escape(&import_path)));
for module_relative in modules_relative {
// 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(
&uv_fs::relative_to(src_root.join(module_relative), 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
.parse(&include)
Expand Down
Loading
Loading