Build backend: Support stubs packages (#13563)

Stubs packages are different in that their name ends with `-stubs`,
their module is `<module name>-stubs` (with a dash, not the generally
legal underscore) and their modules contain a `__init__.pyi` instead of
an `__init__.py`
(https://typing.python.org/en/latest/spec/distributing.html#stub-only-packages).

We add support in the uv build backend by detecting the `-stubs` suffix.

Fixes #13546

---------

Co-authored-by: Alex Waygood <Alex.Waygood@Gmail.com>
This commit is contained in:
konsti 2025-05-22 19:02:17 +02:00 committed by GitHub
parent c8479574f2
commit 46bc7d3477
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 240 additions and 22 deletions

View file

@ -20,9 +20,10 @@ use tracing::debug;
use uv_fs::Simplified;
use uv_globfilter::PortableGlobError;
use uv_normalize::PackageName;
use uv_pypi_types::{Identifier, IdentifierParseError};
use uv_pypi_types::IdentifierParseError;
use crate::metadata::ValidationError;
use crate::settings::ModuleName;
#[derive(Debug, Error)]
pub enum Error {
@ -199,7 +200,7 @@ fn find_roots(
source_tree: &Path,
pyproject_toml: &PyProjectToml,
relative_module_root: &Path,
module_name: Option<&Identifier>,
module_name: Option<&ModuleName>,
) -> Result<(PathBuf, PathBuf), Error> {
let relative_module_root = uv_fs::normalize_path(relative_module_root);
let src_root = source_tree.join(&relative_module_root);
@ -229,17 +230,29 @@ fn find_roots(
/// Returns the module root path, the directory below which the `__init__.py` lives.
fn find_module_root(
src_root: &Path,
module_name: Option<&Identifier>,
module_name: Option<&ModuleName>,
package_name: &PackageName,
) -> Result<PathBuf, Error> {
let module_name = if let Some(module_name) = module_name {
let (module_name, stubs) = if let Some(module_name) = module_name {
// This name can be uppercase.
module_name.to_string()
match module_name {
ModuleName::Identifier(module_name) => (module_name.to_string(), false),
ModuleName::Stubs(module_name) => (module_name.to_string(), true),
}
} 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()
// 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)
}
};
let dir = match fs_err::read_dir(src_root) {
@ -262,11 +275,12 @@ fn find_module_root(
None
}
});
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() {
if module_root.join(init_py).is_file() {
module_root.clone()
} else {
return Err(Error::MissingInitPy(module_root.join("__init__.py")));
return Err(Error::MissingInitPy(module_root.join(init_py)));
}
} else {
return Err(Error::MissingModuleDir {
@ -293,10 +307,18 @@ mod tests {
use itertools::Itertools;
use sha2::Digest;
use std::io::{BufReader, Read};
use std::iter;
use tempfile::TempDir;
use uv_distribution_filename::{SourceDistFilename, WheelFilename};
use uv_fs::{copy_dir_all, relative_to};
fn format_err(err: &Error) -> String {
let context = iter::successors(std::error::Error::source(&err), |&err| err.source())
.map(|err| format!(" Caused by: {err}"))
.join("\n");
err.to_string() + "\n" + &context
}
/// File listings, generated archives and archive contents for both a build with
/// source tree -> wheel
/// and a build with
@ -818,8 +840,7 @@ mod tests {
)
.unwrap();
let build_err = build(src.path(), dist.path()).unwrap_err();
let err_message = build_err
.to_string()
let err_message = format_err(&build_err)
.replace(&src.path().user_display().to_string(), "[TEMP_PATH]")
.replace('\\', "/");
assert_snapshot!(
@ -827,4 +848,112 @@ mod tests {
@"Missing module directory for `camel_case` in `[TEMP_PATH]/src`. Found: `camelCase`"
);
}
#[test]
fn invalid_stubs_name() {
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 = "django@home-stubs"
"#
};
fs_err::write(src.path().join("pyproject.toml"), pyproject_toml).unwrap();
let dist = TempDir::new().unwrap();
let build_err = build(src.path(), dist.path()).unwrap_err();
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
"#
);
}
/// Stubs packages use a special name and `__init__.pyi`.
#[test]
fn stubs_package() {
let src = TempDir::new().unwrap();
let pyproject_toml = indoc! {r#"
[project]
name = "stuffed-bird-stubs"
version = "1.0.0"
[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("stuffed_bird-stubs")).unwrap();
// That's the wrong file, we're expecting a `__init__.pyi`.
let regular_init_py = src
.path()
.join("src")
.join("stuffed_bird-stubs")
.join("__init__.py");
File::create(&regular_init_py).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 directory at: `[TEMP_PATH]/src/stuffed_bird-stubs/__init__.pyi`"
);
// Create the correct file
fs_err::remove_file(regular_init_py).unwrap();
File::create(
src.path()
.join("src")
.join("stuffed_bird-stubs")
.join("__init__.pyi"),
)
.unwrap();
let build1 = build(src.path(), dist.path()).unwrap();
assert_snapshot!(build1.wheel_contents.join("\n"), @r"
stuffed_bird-stubs/
stuffed_bird-stubs/__init__.pyi
stuffed_bird_stubs-1.0.0.dist-info/
stuffed_bird_stubs-1.0.0.dist-info/METADATA
stuffed_bird_stubs-1.0.0.dist-info/RECORD
stuffed_bird_stubs-1.0.0.dist-info/WHEEL
");
// Check that setting the name manually works equally.
let pyproject_toml = indoc! {r#"
[project]
name = "stuffed-bird-stubs"
version = "1.0.0"
[build-system]
requires = ["uv_build>=0.5.15,<0.6"]
build-backend = "uv_build"
[tool.uv.build-backend]
module-name = "stuffed_bird-stubs"
"#
};
fs_err::write(src.path().join("pyproject.toml"), pyproject_toml).unwrap();
let build2 = build(src.path(), dist.path()).unwrap();
assert_eq!(build1.wheel_contents, build2.wheel_contents);
}
}

View file

@ -1,5 +1,7 @@
use serde::{Deserialize, Serialize};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::fmt::Display;
use std::path::PathBuf;
use std::str::FromStr;
use uv_macros::OptionsMetadata;
use uv_pypi_types::Identifier;
@ -32,6 +34,10 @@ pub struct BuildBackendSettings {
///
/// The default module name is the package name with dots and dashes replaced by underscores.
///
/// Package 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.
///
/// 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.
@ -40,7 +46,7 @@ pub struct BuildBackendSettings {
value_type = "str",
example = r#"module-name = "sklearn""#
)]
pub module_name: Option<Identifier>,
pub module_name: Option<ModuleName>,
/// Glob expressions which files and directories to additionally include in the source
/// distribution.
@ -128,6 +134,83 @@ impl Default for BuildBackendSettings {
}
}
/// 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

@ -54,6 +54,8 @@ module-name = "PIL"
module-root = ""
```
The build backend supports building stubs packages with a `-stubs` package or module name.
## Include and exclude configuration
To select which files to include in the source distribution, uv first adds the included files and

View file

@ -446,6 +446,10 @@ The name of the module directory inside `module-root`.
The default module name is the package name with dots and dashes replaced by underscores.
Package 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.
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.

14
uv.schema.json generated
View file

@ -627,11 +627,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\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\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/Identifier"
"$ref": "#/definitions/ModuleName"
},
{
"type": "null"
@ -838,11 +838,6 @@
"description": "The normalized name of a dependency group.\n\nSee: - <https://peps.python.org/pep-0735/> - <https://packaging.python.org/en/latest/specifications/name-normalization/>",
"type": "string"
},
"Identifier": {
"description": "An identifier in Python",
"type": "string",
"pattern": "^[_\\p{Alphabetic}][_0-9\\p{Alphabetic}]*$"
},
"Index": {
"type": "object",
"required": [
@ -1023,6 +1018,11 @@
"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"