mirror of
https://github.com/astral-sh/uv.git
synced 2025-10-14 12:29:04 +00:00
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:
parent
c8479574f2
commit
46bc7d3477
5 changed files with 240 additions and 22 deletions
|
@ -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(®ular_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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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`.
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue