Build backend: Support namespace packages (#13833)

Unlike regular packages, specifying all `__init__.py` directories for a
namespace package would be very verbose There is e.g.
https://github.com/python-poetry/poetry/tree/main/src/poetry, which has
18 modules, or https://github.com/googleapis/api-common-protos which is
inconsistently nested. For both the Google Cloud SDK, there are both
packages with a single module and those with complex structures, with
many having multiple modules due to versioning through `<module>_v1`
versioning. The Azure SDK seems to use one module per package (it's not
explicitly documented but seems to follow from the process in
https://azure.github.io/azure-sdk/python_design.html#azure-sdk-distribution-packages
and
ccb0e03a3d/doc/dev/packaging.md).

For simplicity with complex projects, we add a `namespace = true` switch
which disabled checking for an `__init__.py`. We only check that there's
no `<module_root>/<module_name>/__init__.py` and otherwise add the whole
`<module_root>/<module_name>` folder. This comes at the cost of
`namespace = true` effectively creating an opt-out from our usual checks
that allows creating an almost entirely arbitrary package.

For simple projects with only a single module, the module name can be
dotted to point to the target module, so the build still gets checked:

```toml
[tool.uv.build-backend]
module-name = "poetry.core"
```

## Alternatives

### Declare all packages

We could make `module-name` a list and allow or require declaring all
packages:

```toml
[tool.uv.build-backend]
module-name = ["cloud_sdk.service.storage", "cloud_sdk.service.storage_v1", "cloud_sdk.billing.storage"]
```

Or for Poetry:

```toml
[tool.uv.build-backend]
module-name = [
    "poetry.config",
    "poetry.console",
    "poetry.inspection",
    "poetry.installation",
    "poetry.json",
    "poetry.layouts",
    "poetry.masonry",
    "poetry.mixology",
    "poetry.packages",
    "poetry.plugins",
    "poetry.publishing",
    "poetry.puzzle",
    "poetry.pyproject",
    "poetry.repositories",
    "poetry.toml",
    "poetry.utils",
    "poetry.vcs",
    "poetry.version"
]
```

### Support multiple namespaces

We could also allow namespace packages with multiple root level module:

```toml
[tool.uv.build-backend]
module-name = ["cloud_sdk.my_ext", "local_sdk.my_ext"]
```

For lack of use cases, we delegate this to creating a workspace with one
package per module.

## Implementation

Due to the more complex options for the module name, I'm moving
verification on deserialization later, dropping the source span we'd get
from serde. We also don't show similarly named directories anymore.

---------

Co-authored-by: Andrew Gallant <andrew@astral.sh>
This commit is contained in:
konsti 2025-06-12 19:23:58 +02:00 committed by GitHub
parent 95ad8e5e82
commit 7316bd01a3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 670 additions and 229 deletions

View file

@ -13,17 +13,15 @@ use std::fs::FileType;
use std::io;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use thiserror::Error;
use tracing::debug;
use uv_fs::Simplified;
use uv_globfilter::PortableGlobError;
use uv_normalize::PackageName;
use uv_pypi_types::IdentifierParseError;
use uv_pypi_types::{Identifier, IdentifierParseError};
use crate::metadata::ValidationError;
use crate::settings::ModuleName;
#[derive(Debug, Error)]
pub enum Error {
@ -33,8 +31,8 @@ pub enum Error {
Toml(#[from] toml::de::Error),
#[error("Invalid pyproject.toml")]
Validation(#[from] ValidationError),
#[error(transparent)]
Identifier(#[from] IdentifierParseError),
#[error("Invalid module name: {0}")]
InvalidModuleName(String, #[source] IdentifierParseError),
#[error("Unsupported glob expression in: `{field}`")]
PortableGlob {
field: String,
@ -62,27 +60,10 @@ 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()
)]
#[error("Expected a Python module at: `{}`", _0.user_display())]
MissingInitPy(PathBuf),
#[error(
"Missing module directory for `{}` in `{}`. Found: `{}`",
module_name,
src_root.user_display(),
dir_listing.join("`, `")
)]
MissingModuleDir {
module_name: String,
src_root: PathBuf,
dir_listing: Vec<String>,
},
#[error("For namespace packages, `__init__.py[i]` is not allowed in parent directory: `{}`", _0.user_display())]
NotANamespace(PathBuf),
/// Either an absolute path or a parent path through `..`.
#[error("Module root must be inside the project: `{}`", _0.user_display())]
InvalidModuleRoot(PathBuf),
@ -195,12 +176,26 @@ fn check_metadata_directory(
Ok(())
}
/// Resolve the source root, module root and the module name.
/// Returns the source root and the module path 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
/// filesystems. 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`. 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.build-backend.module-name`.
///
/// 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.
fn find_roots(
source_tree: &Path,
pyproject_toml: &PyProjectToml,
relative_module_root: &Path,
module_name: Option<&ModuleName>,
module_name: Option<&str>,
namespace: bool,
) -> Result<(PathBuf, PathBuf), Error> {
let relative_module_root = uv_fs::normalize_path(relative_module_root);
let src_root = source_tree.join(&relative_module_root);
@ -208,93 +203,114 @@ fn find_roots(
return Err(Error::InvalidModuleRoot(relative_module_root.to_path_buf()));
}
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))
debug!("Source root: {}", src_root.user_display());
if namespace {
// `namespace = true` disables module structure checks.
let module_relative = if let Some(module_name) = module_name {
module_name.split('.').collect::<PathBuf>()
} else {
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));
}
let module_relative = if let Some(module_name) = module_name {
module_path_from_module_name(&src_root, module_name)?
} else {
find_module_path_from_package_name(&src_root, pyproject_toml.name())?
};
debug!("Module path: {}", module_relative.user_display());
Ok((src_root, module_relative))
}
/// Match the module name to its module directory with potentially different casing.
/// Infer stubs packages from package name alone.
///
/// 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`.
///
/// 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(
/// There are potential false positives if someone had a regular package with `-stubs`.
/// The `Identifier` checks in `module_path_from_module_name` are here covered by the `PackageName`
/// validation.
fn find_module_path_from_package_name(
src_root: &Path,
module_name: Option<&ModuleName>,
package_name: &PackageName,
) -> Result<PathBuf, Error> {
let (module_name, stubs) = if let Some(module_name) = module_name {
// This name can be uppercase.
match module_name {
ModuleName::Identifier(module_name) => (module_name.to_string(), false),
ModuleName::Stubs(module_name) => (module_name.to_string(), true),
if let Some(stem) = package_name.to_string().strip_suffix("-stubs") {
debug!("Building stubs package instead of a regular package");
let module_name = PackageName::from_str(stem)
.expect("non-empty package name prefix must be valid package name")
.as_dist_info_name()
.to_string();
let module_relative = PathBuf::from(format!("{module_name}-stubs"));
let init_pyi = src_root.join(&module_relative).join("__init__.pyi");
if !init_pyi.is_file() {
return Err(Error::MissingInitPy(init_pyi));
}
Ok(module_relative)
} else {
// Infer stubs packages from package name alone. There are potential false positives if
// someone had a regular package with `-stubs`.
if let Some(stem) = package_name.to_string().strip_suffix("-stubs") {
debug!("Building stubs package instead of a regular package");
let module_name = PackageName::from_str(stem)
.expect("non-empty package name prefix must be valid package name")
.as_dist_info_name()
.to_string();
(format!("{module_name}-stubs"), true)
} else {
// This name is always lowercase.
(package_name.as_dist_info_name().to_string(), false)
// This name is always lowercase.
let module_relative = PathBuf::from(package_name.as_dist_info_name().to_string());
let init_py = src_root.join(&module_relative).join("__init__.py");
if !init_py.is_file() {
return Err(Error::MissingInitPy(init_py));
}
Ok(module_relative)
}
}
/// Determine the relative module path from an explicit module name.
fn module_path_from_module_name(src_root: &Path, module_name: &str) -> Result<PathBuf, Error> {
// This name can be uppercase.
let module_relative = module_name.split('.').collect::<PathBuf>();
// Check if we have a regular module or a namespace.
let (root_name, namespace_segments) =
if let Some((root_name, namespace_segments)) = module_name.split_once('.') {
(
root_name,
namespace_segments.split('.').collect::<Vec<&str>>(),
)
} else {
(module_name, Vec::new())
};
// Check if we have an implementation or a stubs package.
// For stubs for a namespace, the `-stubs` prefix must be on the root.
let stubs = if let Some(stem) = root_name.strip_suffix("-stubs") {
// Check that the stubs belong to a valid module.
Identifier::from_str(stem)
.map_err(|err| Error::InvalidModuleName(module_name.to_string(), err))?;
true
} else {
Identifier::from_str(root_name)
.map_err(|err| Error::InvalidModuleName(module_name.to_string(), err))?;
false
};
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 => {
return Err(Error::MissingSrc(src_root.to_path_buf()));
}
Err(err) => return Err(Error::Io(err)),
};
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)
// For a namespace, check that all names below the root is valid.
for segment in namespace_segments {
Identifier::from_str(segment)
.map_err(|err| Error::InvalidModuleName(module_name.to_string(), err))?;
}
// Check that an `__init__.py[i]` exists for the module.
let init_py =
src_root
.join(&module_relative)
.join(if stubs { "__init__.pyi" } else { "__init__.py" });
if !init_py.is_file() {
return Err(Error::MissingInitPy(init_py));
}
// For a namespace, check that the directories above the lowest are namespace directories.
for namespace_dir in module_relative.ancestors().skip(1) {
if src_root.join(namespace_dir).join("__init__.py").exists()
|| src_root.join(namespace_dir).join("__init__.pyi").exists()
{
Some(entry.path())
} else {
None
return Err(Error::NotANamespace(src_root.join(namespace_dir)));
}
});
let init_py = if stubs { "__init__.pyi" } else { "__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)));
}
} 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)
Ok(module_relative)
}
#[cfg(test)]
@ -845,7 +861,7 @@ mod tests {
.replace('\\', "/");
assert_snapshot!(
err_message,
@"Missing module directory for `camel_case` in `[TEMP_PATH]/src`. Found: `camelCase`"
@"Expected a Python module at: `[TEMP_PATH]/src/camel_case/__init__.py`"
);
}
@ -872,14 +888,10 @@ mod tests {
let err_message = format_err(&build_err);
assert_snapshot!(
err_message,
@r#"
Invalid pyproject.toml
Caused by: TOML parse error at line 10, column 15
|
10 | module-name = "django@home-stubs"
| ^^^^^^^^^^^^^^^^^^^
Invalid character `@` at position 7 for identifier `django@home`, expected an underscore or an alphanumeric character
"#
@r"
Invalid module name: django@home-stubs
Caused by: Invalid character `@` at position 7 for identifier `django@home`, expected an underscore or an alphanumeric character
"
);
}
@ -914,7 +926,7 @@ mod tests {
.replace('\\', "/");
assert_snapshot!(
err_message,
@"Expected a Python module directory at: `[TEMP_PATH]/src/stuffed_bird-stubs/__init__.pyi`"
@"Expected a Python module at: `[TEMP_PATH]/src/stuffed_bird-stubs/__init__.pyi`"
);
// Create the correct file
@ -956,4 +968,237 @@ mod tests {
let build2 = build(src.path(), dist.path()).unwrap();
assert_eq!(build1.wheel_contents, build2.wheel_contents);
}
/// A simple namespace package with a single root `__init__.py`.
#[test]
fn simple_namespace_package() {
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 = "simple_namespace.part"
[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("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,
@"Expected a Python module at: `[TEMP_PATH]/src/simple_namespace/part/__init__.py`"
);
// Create the correct file
File::create(
src.path()
.join("src")
.join("simple_namespace")
.join("part")
.join("__init__.py"),
)
.unwrap();
// For a namespace package, 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();
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,
@"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 build1 = build(src.path(), dist.path()).unwrap();
assert_snapshot!(build1.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/simple_namespace
simple_namespace_part-1.0.0/src/simple_namespace/part
simple_namespace_part-1.0.0/src/simple_namespace/part/__init__.py
");
assert_snapshot!(build1.wheel_contents.join("\n"), @r"
simple_namespace/
simple_namespace/part/
simple_namespace/part/__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
");
// Check that `namespace = true` works too.
let pyproject_toml = indoc! {r#"
[project]
name = "simple-namespace-part"
version = "1.0.0"
[tool.uv.build-backend]
module-name = "simple_namespace.part"
namespace = true
[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();
let build2 = build(src.path(), dist.path()).unwrap();
assert_eq!(build1, build2);
}
/// A complex namespace package with a multiple root `__init__.py`.
#[test]
fn complex_namespace_package() {
let src = TempDir::new().unwrap();
let pyproject_toml = indoc! {r#"
[project]
name = "complex-namespace"
version = "1.0.0"
[tool.uv.build-backend]
namespace = true
[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("complex_namespace")
.join("part_a"),
)
.unwrap();
File::create(
src.path()
.join("src")
.join("complex_namespace")
.join("part_a")
.join("__init__.py"),
)
.unwrap();
fs_err::create_dir_all(
src.path()
.join("src")
.join("complex_namespace")
.join("part_b"),
)
.unwrap();
File::create(
src.path()
.join("src")
.join("complex_namespace")
.join("part_b")
.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"
complex_namespace-1.0.0.dist-info/
complex_namespace-1.0.0.dist-info/METADATA
complex_namespace-1.0.0.dist-info/RECORD
complex_namespace-1.0.0.dist-info/WHEEL
complex_namespace/
complex_namespace/part_a/
complex_namespace/part_a/__init__.py
complex_namespace/part_b/
complex_namespace/part_b/__init__.py
");
// Check that setting the name manually works equally.
let pyproject_toml = indoc! {r#"
[project]
name = "complex-namespace"
version = "1.0.0"
[tool.uv.build-backend]
module-name = "complex_namespace"
namespace = true
[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();
let build2 = build(src.path(), dist.path()).unwrap();
assert_eq!(build1, build2);
}
/// Stubs for a namespace package.
#[test]
fn stubs_namespace() {
let src = TempDir::new().unwrap();
let pyproject_toml = indoc! {r#"
[project]
name = "cloud.db.schema-stubs"
version = "1.0.0"
[tool.uv.build-backend]
module-name = "cloud-stubs.db.schema"
[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("cloud-stubs")
.join("db")
.join("schema"),
)
.unwrap();
File::create(
src.path()
.join("src")
.join("cloud-stubs")
.join("db")
.join("schema")
.join("__init__.pyi"),
)
.unwrap();
let dist = TempDir::new().unwrap();
let build = build(src.path(), dist.path()).unwrap();
assert_snapshot!(build.wheel_contents.join("\n"), @r"
cloud-stubs/
cloud-stubs/db/
cloud-stubs/db/schema/
cloud-stubs/db/schema/__init__.pyi
cloud_db_schema_stubs-1.0.0.dist-info/
cloud_db_schema_stubs-1.0.0.dist-info/METADATA
cloud_db_schema_stubs-1.0.0.dist-info/RECORD
cloud_db_schema_stubs-1.0.0.dist-info/WHEEL
");
}
}

View file

@ -1,9 +1,6 @@
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::fmt::Display;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::str::FromStr;
use uv_macros::OptionsMetadata;
use uv_pypi_types::Identifier;
/// Settings for the uv build backend (`uv_build`).
///
@ -38,6 +35,9 @@ pub struct BuildBackendSettings {
/// `__init__.py`. An exception are stubs packages, whose name ends with `-stubs`, with the stem
/// being the module name, and which contain a `__init__.pyi` file.
///
/// For namespace packages with a single module, the path can be dotted, e.g., `foo.bar` or
/// `foo-stubs.bar`.
///
/// 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.
@ -46,7 +46,7 @@ pub struct BuildBackendSettings {
value_type = "str",
example = r#"module-name = "sklearn""#
)]
pub module_name: Option<ModuleName>,
pub module_name: Option<String>,
/// Glob expressions which files and directories to additionally include in the source
/// distribution.
@ -85,6 +85,56 @@ pub struct BuildBackendSettings {
)]
pub wheel_exclude: Vec<String>,
/// Build a namespace package.
///
/// Build a PEP 420 implicit namespace package, allowing more than one root `__init__.py`.
///
/// Use this option when the namespace package contains multiple root `__init__.py`, for
/// namespace packages with a single root `__init__.py` use a dotted `module-name` instead.
///
/// To compare dotted `module-name` and `namespace = true`, the first example below can be
/// expressed with `module-name = "cloud.database"`: There is one root `__init__.py` `database`.
/// In the second example, we have three roots (`cloud.database`, `cloud.database_pro`,
/// `billing.modules.database_pro`), so `namespace = true` is required.
///
/// ```text
/// src
/// └── cloud
/// └── database
/// ├── __init__.py
/// ├── query_builder
/// │ └── __init__.py
/// └── sql
/// ├── parser.py
/// └── __init__.py
/// ```
///
/// ```text
/// src
/// ├── cloud
/// │ ├── database
/// │ │ ├── __init__.py
/// │ │ ├── query_builder
/// │ │ │ └── __init__.py
/// │ │ └── sql
/// │ │ ├── __init__.py
/// │ │ └── parser.py
/// │ └── database_pro
/// │ ├── __init__.py
/// │ └── query_builder.py
/// └── billing
/// └── modules
/// └── database_pro
/// ├── __init__.py
/// └── sql.py
/// ```
#[option(
default = r#"false"#,
value_type = "bool",
example = r#"namespace = true"#
)]
pub namespace: bool,
/// Data includes for wheels.
///
/// Each entry is a directory, whose contents are copied to the matching directory in the wheel
@ -129,88 +179,12 @@ impl Default for BuildBackendSettings {
default_excludes: true,
source_exclude: Vec::new(),
wheel_exclude: Vec::new(),
namespace: false,
data: WheelDataIncludes::default(),
}
}
}
/// Packages come in two kinds: Regular packages, where the name must be a valid Python identifier,
/// and stubs packages.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ModuleName {
/// A Python module name, which needs to be a valid Python identifier to be used with `import`.
Identifier(Identifier),
/// A type stubs package, whose name ends with `-stubs` with the stem being the module name.
Stubs(String),
}
impl Display for ModuleName {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ModuleName::Identifier(module_name) => Display::fmt(module_name, f),
ModuleName::Stubs(module_name) => Display::fmt(module_name, f),
}
}
}
impl<'de> Deserialize<'de> for ModuleName {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let module_name = String::deserialize(deserializer)?;
if let Some(stem) = module_name.strip_suffix("-stubs") {
// Check that the stubs belong to a valid module.
Identifier::from_str(stem)
.map(ModuleName::Identifier)
.map_err(serde::de::Error::custom)?;
Ok(ModuleName::Stubs(module_name))
} else {
Identifier::from_str(&module_name)
.map(ModuleName::Identifier)
.map_err(serde::de::Error::custom)
}
}
}
impl Serialize for ModuleName {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match self {
ModuleName::Identifier(module_name) => module_name.serialize(serializer),
ModuleName::Stubs(module_name) => module_name.serialize(serializer),
}
}
}
#[cfg(feature = "schemars")]
impl schemars::JsonSchema for ModuleName {
fn schema_name() -> String {
"ModuleName".to_string()
}
fn json_schema(_gen: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema {
schemars::schema::SchemaObject {
instance_type: Some(schemars::schema::InstanceType::String.into()),
string: Some(Box::new(schemars::schema::StringValidation {
// Best-effort Unicode support (https://stackoverflow.com/a/68844380/3549270)
pattern: Some(r"^[_\p{Alphabetic}][_0-9\p{Alphabetic}]*(-stubs)?$".to_string()),
..schemars::schema::StringValidation::default()
})),
metadata: Some(Box::new(schemars::schema::Metadata {
description: Some(
"The name of the module, or the name of a stubs package".to_string(),
),
..schemars::schema::Metadata::default()
})),
..schemars::schema::SchemaObject::default()
}
.into()
}
}
/// Data includes for wheels.
///
/// See `BuildBackendSettings::data`.

View file

@ -68,16 +68,18 @@ fn source_dist_matcher(
includes.push(globset::escape("pyproject.toml"));
// Check that the source tree contains a module.
let (_, module_root) = find_roots(
let (src_root, module_relative) = find_roots(
source_tree,
pyproject_toml,
&settings.module_root,
settings.module_name.as_ref(),
settings.module_name.as_deref(),
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(module_root, source_tree).expect("module root is inside source tree"),
&uv_fs::relative_to(src_root.join(module_relative), source_tree)
.expect("module root is inside source tree"),
)
.portable_display()
.to_string();

View file

@ -17,8 +17,7 @@ use uv_warnings::warn_user_once;
use crate::metadata::DEFAULT_EXCLUDES;
use crate::{
BuildBackendSettings, DirectoryWriter, Error, FileList, ListWriter, PyProjectToml,
find_module_root, find_roots,
BuildBackendSettings, DirectoryWriter, Error, FileList, ListWriter, PyProjectToml, find_roots,
};
/// Build a wheel from the source tree and place it in the output directory.
@ -124,15 +123,24 @@ fn write_wheel(
let exclude_matcher = build_exclude_matcher(excludes)?;
debug!("Adding content files to wheel");
let (src_root, module_root) = find_roots(
let (src_root, module_relative) = find_roots(
source_tree,
pyproject_toml,
&settings.module_root,
settings.module_name.as_ref(),
settings.module_name.as_deref(),
settings.namespace,
)?;
// For convenience, have directories for the whole tree in the wheel
for ancestor in module_relative.ancestors().skip(1) {
if ancestor == Path::new("") {
continue;
}
wheel_writer.write_directory(&ancestor.portable_display().to_string())?;
}
let mut files_visited = 0;
for entry in WalkDir::new(module_root)
for entry in WalkDir::new(src_root.join(module_relative))
.sort_by_file_name()
.into_iter()
.filter_entry(|entry| !exclude_matcher.is_match(entry.path()))
@ -267,16 +275,13 @@ pub fn build_editable(
let mut wheel_writer = ZipDirectoryWriter::new_wheel(File::create(&wheel_path)?);
debug!("Adding pth file to {}", wheel_path.user_display());
let src_root = source_tree.join(&settings.module_root);
if !src_root.starts_with(source_tree) {
return Err(Error::InvalidModuleRoot(settings.module_root.clone()));
}
// Check that a module root exists in the directory we're linking from the `.pth` file
find_module_root(
&src_root,
settings.module_name.as_ref(),
pyproject_toml.name(),
let (src_root, _module_relative) = find_roots(
source_tree,
&pyproject_toml,
&settings.module_root,
settings.module_name.as_deref(),
settings.namespace,
)?;
wheel_writer.write_bytes(

View file

@ -1,10 +1,10 @@
use crate::common::{TestContext, uv_snapshot, venv_bin_path};
use anyhow::Result;
use assert_cmd::assert::OutputAssertExt;
use assert_fs::fixture::{FileWriteStr, PathChild};
use assert_fs::fixture::{FileWriteStr, PathChild, PathCreateDir};
use flate2::bufread::GzDecoder;
use fs_err::File;
use indoc::indoc;
use indoc::{formatdoc, indoc};
use std::env;
use std::io::BufReader;
use std::path::Path;
@ -470,7 +470,7 @@ fn build_module_name_normalization() -> Result<()> {
----- stdout -----
----- stderr -----
error: Missing module directory for `Django_plugin` in `src`. Found: ``
error: Expected a Python module at: `src/Django_plugin/__init__.py`
");
fs_err::create_dir_all(context.temp_dir.join("src/Django_plugin"))?;
@ -484,7 +484,7 @@ fn build_module_name_normalization() -> Result<()> {
----- stdout -----
----- stderr -----
error: Expected a Python module directory at: `src/Django_plugin/__init__.py`
error: Expected a Python module at: `src/Django_plugin/__init__.py`
");
// Use `Django_plugin` instead of `django_plugin`
@ -624,7 +624,7 @@ fn sdist_error_without_module() -> Result<()> {
----- stdout -----
----- stderr -----
error: Missing source directory at: `src`
error: Expected a Python module at: `src/foo/__init__.py`
");
fs_err::create_dir(context.temp_dir.join("src"))?;
@ -639,8 +639,147 @@ fn sdist_error_without_module() -> Result<()> {
----- stdout -----
----- stderr -----
error: Missing module directory for `foo` in `src`. Found: ``
error: Expected a Python module at: `src/foo/__init__.py`
");
Ok(())
}
#[test]
fn complex_namespace_packages() -> Result<()> {
let context = TestContext::new("3.12");
let dist = context.temp_dir.child("dist");
dist.create_dir_all()?;
let init_py_a = indoc! {"
def one():
return 1
"};
let init_py_b = indoc! {"
from complex_project.part_a import one
def two():
return one() + one()
"};
let projects = [
("complex-project", "part_a", init_py_a),
("complex-project", "part_b", init_py_b),
];
for (project_name, part_name, init_py) in projects {
let project = context
.temp_dir
.child(format!("{project_name}-{part_name}"));
let project_name_dist_info = project_name.replace('-', "_");
let pyproject_toml = formatdoc! {r#"
[project]
name = "{project_name}-{part_name}"
version = "1.0.0"
[tool.uv.build-backend]
module-name = "{project_name_dist_info}.{part_name}"
[build-system]
requires = ["uv_build>=0.5.15,<10000"]
build-backend = "uv_build"
"#
};
project.child("pyproject.toml").write_str(&pyproject_toml)?;
project
.child("src")
.child(project_name_dist_info)
.child(part_name)
.child("__init__.py")
.write_str(init_py)?;
context
.build()
.arg("--preview")
.arg(project.path())
.arg("--out-dir")
.arg(dist.path())
.assert()
.success();
}
uv_snapshot!(
context.filters(),
context
.pip_install()
.arg("complex-project-part-a")
.arg("complex-project-part-b")
.arg("--offline")
.arg("--find-links")
.arg(dist.path()),
@r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 2 packages in [TIME]
Prepared 2 packages in [TIME]
Installed 2 packages in [TIME]
+ complex-project-part-a==1.0.0
+ complex-project-part-b==1.0.0
"
);
uv_snapshot!(Command::new(context.interpreter())
.arg("-c")
.arg("from complex_project.part_b import two; print(two())"),
@r"
success: true
exit_code: 0
----- stdout -----
2
----- stderr -----
"
);
// Test editable installs
uv_snapshot!(
context.filters(),
context
.pip_install()
.arg("--preview")
.arg("-e")
.arg("complex-project-part_a")
.arg("-e")
.arg("complex-project-part_b")
.arg("--offline"),
@r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 2 packages in [TIME]
Prepared 2 packages in [TIME]
Uninstalled 2 packages in [TIME]
Installed 2 packages in [TIME]
- complex-project-part-a==1.0.0
+ complex-project-part-a==1.0.0 (from file://[TEMP_DIR]/complex-project-part_a)
- complex-project-part-b==1.0.0
+ complex-project-part-b==1.0.0 (from file://[TEMP_DIR]/complex-project-part_b)
"
);
uv_snapshot!(Command::new(context.interpreter())
.arg("-c")
.arg("from complex_project.part_b import two; print(two())"),
@r"
success: true
exit_code: 0
----- stdout -----
2
----- stderr -----
"
);
Ok(())
}

View file

@ -54,7 +54,24 @@ module-name = "PIL"
module-root = ""
```
The build backend supports building stubs packages with a `-stubs` package or module name.
For a namespace packages, the path can be dotted. The example below expects to find a
`src/cloud/db/schema/__init__.py`:
```toml
[tool.uv.build-backend]
module-name = "cloud.db.schema"
```
Complex namespaces with more than one root module can be built by setting the `namespace` option,
which allows more than one root `__init__.py`:
```toml
[tool.uv.build-backend]
namespace = true
```
The build backend supports building stubs packages with a `-stubs` suffix on the package or module
name, including for namespace packages.
## Include and exclude configuration

View file

@ -450,6 +450,9 @@ Package names need to be valid Python identifiers, and the directory needs to co
`__init__.py`. An exception are stubs packages, whose name ends with `-stubs`, with the stem
being the module name, and which contain a `__init__.pyi` file.
For namespace packages with a single module, the path can be dotted, e.g., `foo.bar` or
`foo-stubs.bar`.
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.
@ -487,6 +490,66 @@ module-root = ""
---
#### [`namespace`](#build-backend_namespace) {: #build-backend_namespace }
<span id="namespace"></span>
Build a namespace package.
Build a PEP 420 implicit namespace package, allowing more than one root `__init__.py`.
Use this option when the namespace package contains multiple root `__init__.py`, for
namespace packages with a single root `__init__.py` use a dotted `module-name` instead.
To compare dotted `module-name` and `namespace = true`, the first example below can be
expressed with `module-name = "cloud.database"`: There is one root `__init__.py` `database`.
In the second example, we have three roots (`cloud.database`, `cloud.database_pro`,
`billing.modules.database_pro`), so `namespace = true` is required.
```text
src
└── cloud
└── database
├── __init__.py
├── query_builder
│ └── __init__.py
└── sql
├── parser.py
└── __init__.py
```
```text
src
├── cloud
│ ├── database
│ │ ├── __init__.py
│ │ ├── query_builder
│ │ │ └── __init__.py
│ │ └── sql
│ │ ├── __init__.py
│ │ └── parser.py
│ └── database_pro
│ ├── __init__.py
│ └── query_builder.py
└── billing
└── modules
└── database_pro
├── __init__.py
└── sql.py
```
**Default value**: `false`
**Type**: `bool`
**Example usage**:
```toml title="pyproject.toml"
[tool.uv.build-backend]
namespace = true
```
---
#### [`source-exclude`](#build-backend_source-exclude) {: #build-backend_source-exclude }
<span id="source-exclude"></span>

22
uv.schema.json generated
View file

@ -671,15 +671,11 @@
"type": "boolean"
},
"module-name": {
"description": "The name of the module directory inside `module-root`.\n\nThe default module name is the package name with dots and dashes replaced by underscores.\n\nPackage names need to be valid Python identifiers, and the directory needs to contain a `__init__.py`. An exception are stubs packages, whose name ends with `-stubs`, with the stem being the module name, and which contain a `__init__.pyi` file.\n\nNote 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.",
"description": "The name of the module directory inside `module-root`.\n\nThe default module name is the package name with dots and dashes replaced by underscores.\n\nPackage names need to be valid Python identifiers, and the directory needs to contain a `__init__.py`. An exception are stubs packages, whose name ends with `-stubs`, with the stem being the module name, and which contain a `__init__.pyi` file.\n\nFor namespace packages with a single module, the path can be dotted, e.g., `foo.bar` or `foo-stubs.bar`.\n\nNote 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.",
"default": null,
"anyOf": [
{
"$ref": "#/definitions/ModuleName"
},
{
"type": "null"
}
"type": [
"string",
"null"
]
},
"module-root": {
@ -687,6 +683,11 @@
"default": "src",
"type": "string"
},
"namespace": {
"description": "Build a namespace package.\n\nBuild a PEP 420 implicit namespace package, allowing more than one root `__init__.py`.\n\nUse this option when the namespace package contains multiple root `__init__.py`, for namespace packages with a single root `__init__.py` use a dotted `module-name` instead.\n\nTo compare dotted `module-name` and `namespace = true`, the first example below can be expressed with `module-name = \"cloud.database\"`: There is one root `__init__.py` `database`. In the second example, we have three roots (`cloud.database`, `cloud.database_pro`, `billing.modules.database_pro`), so `namespace = true` is required.\n\n```text src └── cloud └── database ├── __init__.py ├── query_builder │ └── __init__.py └── sql ├── parser.py └── __init__.py ```\n\n```text src ├── cloud │ ├── database │ │ ├── __init__.py │ │ ├── query_builder │ │ │ └── __init__.py │ │ └── sql │ │ ├── __init__.py │ │ └── parser.py │ └── database_pro │ ├── __init__.py │ └── query_builder.py └── billing └── modules └── database_pro ├── __init__.py └── sql.py ```",
"default": false,
"type": "boolean"
},
"source-exclude": {
"description": "Glob expressions which files and directories to exclude from the source distribution.",
"default": [],
@ -1062,11 +1063,6 @@
"description": "A PEP 508-compliant marker expression, e.g., `sys_platform == 'Darwin'`",
"type": "string"
},
"ModuleName": {
"description": "The name of the module, or the name of a stubs package",
"type": "string",
"pattern": "^[_\\p{Alphabetic}][_0-9\\p{Alphabetic}]*(-stubs)?$"
},
"PackageName": {
"description": "The normalized name of a package.\n\nConverts the name to lowercase and collapses runs of `-`, `_`, and `.` down to a single `-`. For example, `---`, `.`, and `__` are all converted to a single `-`.\n\nSee: <https://packaging.python.org/en/latest/specifications/name-normalization/>",
"type": "string"