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`.