Allow overriding module name for uv build backend (#11884)

Thank you for uv, it has game-changer capabilities in the field of
Python package and environment maangement!

## Summary

This is a small PR adding the option `module-name`
(`tool.uv.build-backend.module-name`) to the uv build backend (
https://github.com/astral-sh/uv/issues/8779 ).

Currently, the uv build backend will assume that the module name matches
the (dash to underdash-transformed) package name. In some packaging
scenarios this is not the case, and currently there exists no
possibility to override it, which this PR addresses.

From the main issue ( https://github.com/astral-sh/uv/issues/8779 ) I
could not tell if there is any extensive roadmap or plans how to
implement more complex scenarios, hence this PR as a suggestion for a
small feature with a big impact for certain scenarios.

I am new to Rust, I hope the borrow/reference usage is correct.

## Test Plan

So far I tested this at an example, if desired I can look into extending
the tests.

Fixes #11428

---------

Co-authored-by: konstin <konstin@mailbox.org>
This commit is contained in:
Christian Sachs 2025-03-07 15:20:00 +01:00 committed by GitHub
parent 1ab1945dd9
commit c57dd1a4a8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 303 additions and 13 deletions

1
Cargo.lock generated
View file

@ -5387,6 +5387,7 @@ dependencies = [
"anyhow",
"hashbrown 0.15.2",
"indexmap",
"insta",
"itertools 0.14.0",
"jiff",
"mailparse",

View file

@ -15,6 +15,7 @@ use thiserror::Error;
use tracing::debug;
use uv_fs::Simplified;
use uv_globfilter::PortableGlobError;
use uv_pypi_types::IdentifierParseError;
#[derive(Debug, Error)]
pub enum Error {
@ -24,6 +25,8 @@ pub enum Error {
Toml(#[from] toml::de::Error),
#[error("Invalid pyproject.toml")]
Validation(#[from] ValidationError),
#[error(transparent)]
Identifier(#[from] IdentifierParseError),
#[error("Unsupported glob expression in: `{field}`")]
PortableGlob {
field: String,

View file

@ -17,7 +17,7 @@ use uv_pep440::{Version, VersionSpecifiers};
use uv_pep508::{
ExtraOperator, MarkerExpression, MarkerTree, MarkerValueExtra, Requirement, VersionOrUrl,
};
use uv_pypi_types::{Metadata23, VerbatimParsedUrl};
use uv_pypi_types::{Identifier, Metadata23, VerbatimParsedUrl};
use crate::serde_verbatim::SerdeVerbatim;
use crate::Error;
@ -803,7 +803,7 @@ pub(crate) struct ToolUv {
/// When building the source distribution, the following files and directories are included:
/// * `pyproject.toml`
/// * The module under `tool.uv.build-backend.module-root`, by default
/// `src/<project_name_with_underscores>/**`.
/// `src/<module-name or project_name_with_underscores>/**`.
/// * `project.license-files` and `project.readme`.
/// * All directories under `tool.uv.build-backend.data`.
/// * All patterns from `tool.uv.build-backend.source-include`.
@ -812,7 +812,7 @@ pub(crate) struct ToolUv {
///
/// When building the wheel, the following files and directories are included:
/// * The module under `tool.uv.build-backend.module-root`, by default
/// `src/<project_name_with_underscores>/**`.
/// `src/<module-name or project_name_with_underscores>/**`.
/// * `project.license-files` and `project.readme`, as part of the project metadata.
/// * Each directory under `tool.uv.build-backend.data`, as data directories.
///
@ -846,6 +846,15 @@ pub(crate) struct BuildBackendSettings {
/// using the flat layout over the src layout.
pub(crate) module_root: PathBuf,
/// The name of the module directory inside `module-root`.
///
/// The default module name is the package name with dots and dashes replaced by underscores.
///
/// 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.
pub(crate) module_name: Option<Identifier>,
/// Glob expressions which files and directories to additionally include in the source
/// distribution.
///
@ -877,6 +886,7 @@ impl Default for BuildBackendSettings {
fn default() -> Self {
Self {
module_root: PathBuf::from("src"),
module_name: None,
source_include: Vec::new(),
default_excludes: true,
source_exclude: Vec::new(),

View file

@ -8,11 +8,13 @@ use globset::{Glob, GlobSet};
use std::io;
use std::io::{BufReader, Cursor};
use std::path::{Path, PathBuf};
use std::str::FromStr;
use tar::{EntryType, Header};
use tracing::{debug, trace};
use uv_distribution_filename::{SourceDistExtension, SourceDistFilename};
use uv_fs::Simplified;
use uv_globfilter::{parse_portable_glob, GlobDirFilter};
use uv_pypi_types::Identifier;
use uv_warnings::warn_user_once;
use walkdir::WalkDir;
@ -65,11 +67,21 @@ fn source_dist_matcher(
let mut includes: Vec<String> = settings.source_include;
// pyproject.toml is always included.
includes.push(globset::escape("pyproject.toml"));
let module_name = if let Some(module_name) = settings.module_name {
module_name
} else {
// Should never error, the rules for package names (in dist-info formatting) are stricter
// than those for identifiers
Identifier::from_str(pyproject_toml.name().as_dist_info_name().as_ref())?
};
debug!("Module name: `{:?}`", module_name);
// 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 = &settings
.module_root
.join(pyproject_toml.name().as_dist_info_name().as_ref())
.join(module_name.as_ref())
.portable_display()
.to_string();
includes.push(format!("{}/**", globset::escape(import_path)));

View file

@ -1,11 +1,11 @@
use std::io::{BufReader, Read, Write};
use std::path::{Path, PathBuf};
use std::{io, mem};
use fs_err::File;
use globset::{GlobSet, GlobSetBuilder};
use itertools::Itertools;
use sha2::{Digest, Sha256};
use std::io::{BufReader, Read, Write};
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::{io, mem};
use tracing::{debug, trace};
use walkdir::WalkDir;
use zip::{CompressionMethod, ZipWriter};
@ -14,6 +14,7 @@ use uv_distribution_filename::WheelFilename;
use uv_fs::Simplified;
use uv_globfilter::{parse_portable_glob, GlobDirFilter};
use uv_platform_tags::{AbiTag, LanguageTag, PlatformTag};
use uv_pypi_types::Identifier;
use uv_warnings::warn_user_once;
use crate::metadata::{BuildBackendSettings, DEFAULT_EXCLUDES};
@ -128,7 +129,17 @@ fn write_wheel(
return Err(Error::AbsoluteModuleRoot(settings.module_root.clone()));
}
let strip_root = source_tree.join(settings.module_root);
let module_root = strip_root.join(pyproject_toml.name().as_dist_info_name().as_ref());
let module_name = if let Some(module_name) = settings.module_name {
module_name
} else {
// Should never error, the rules for package names (in dist-info formatting) are stricter
// than those for identifiers
Identifier::from_str(pyproject_toml.name().as_dist_info_name().as_ref())?
};
debug!("Module name: `{:?}`", module_name);
let module_root = strip_root.join(module_name.as_ref());
if !module_root.join("__init__.py").is_file() {
return Err(Error::MissingModule(module_root));
}

View file

@ -32,7 +32,7 @@ mailparse = { workspace = true }
regex = { workspace = true }
rkyv = { workspace = true }
schemars = { workspace = true, optional = true }
serde = { workspace = true, optional = true }
serde = { workspace = true }
serde-untagged = { workspace = true }
thiserror = { workspace = true }
toml = { workspace = true }
@ -42,6 +42,7 @@ url = { workspace = true }
[dev-dependencies]
anyhow = { workspace = true }
insta = { version = "1.40.0" }
[features]
schemars = ["dep:schemars", "uv-normalize/schemars"]

View file

@ -0,0 +1,160 @@
use std::fmt::Display;
use std::str::FromStr;
use thiserror::Error;
/// Simplified Python identifier.
///
/// We don't match Python's identifier rules
/// (<https://docs.python.org/3.13/reference/lexical_analysis.html#identifiers>) exactly
/// (we just use Rust's `is_alphabetic`) and we don't convert to NFKC, but it's good enough
/// for our validation purposes.
#[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)]
pub struct Identifier(Box<str>);
#[derive(Debug, Clone, Error)]
pub enum IdentifierParseError {
#[error("An identifier must not be empty")]
Empty,
#[error(
"Invalid first character `{first}` for identifier `{identifier}`, expected an underscore or an alphabetic character"
)]
InvalidFirstChar { first: char, identifier: Box<str> },
#[error(
"Invalid character `{invalid_char}` at position {pos} for identifier `{identifier}`, \
expected an underscore or an alphanumeric character"
)]
InvalidChar {
pos: usize,
invalid_char: char,
identifier: Box<str>,
},
}
impl Identifier {
pub fn new(identifier: impl Into<Box<str>>) -> Result<Self, IdentifierParseError> {
let identifier = identifier.into();
let mut chars = identifier.chars().enumerate();
let (_, first_char) = chars.next().ok_or(IdentifierParseError::Empty)?;
if first_char != '_' && !first_char.is_alphabetic() {
return Err(IdentifierParseError::InvalidFirstChar {
first: first_char,
identifier,
});
}
for (pos, current_char) in chars {
if current_char != '_' && !current_char.is_alphanumeric() {
return Err(IdentifierParseError::InvalidChar {
// Make the position 1-indexed
pos: pos + 1,
invalid_char: current_char,
identifier,
});
}
}
Ok(Self(identifier))
}
}
impl FromStr for Identifier {
type Err = IdentifierParseError;
fn from_str(identifier: &str) -> Result<Self, Self::Err> {
Self::new(identifier.to_string())
}
}
impl Display for Identifier {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl AsRef<str> for Identifier {
fn as_ref(&self) -> &str {
&self.0
}
}
impl<'de> serde::de::Deserialize<'de> for Identifier {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::de::Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
Identifier::from_str(&s).map_err(serde::de::Error::custom)
}
}
#[cfg(test)]
mod tests {
use super::*;
use insta::assert_snapshot;
#[test]
fn valid() {
let valid_ids = vec![
"abc",
"_abc",
"a_bc",
"a123",
"snake_case",
"camelCase",
"PascalCase",
// A single character is valid
"_",
"a",
// Unicode
"α",
"férrîs",
"안녕하세요",
];
for valid_id in valid_ids {
assert!(Identifier::from_str(valid_id).is_ok(), "{}", valid_id);
}
}
#[test]
fn empty() {
assert_snapshot!(Identifier::from_str("").unwrap_err(), @"An identifier must not be empty");
}
#[test]
fn invalid_first_char() {
assert_snapshot!(
Identifier::from_str("1foo").unwrap_err(),
@"Invalid first character `1` for identifier `1foo`, expected an underscore or an alphabetic character"
);
assert_snapshot!(
Identifier::from_str("$foo").unwrap_err(),
@"Invalid first character `$` for identifier `$foo`, expected an underscore or an alphabetic character"
);
assert_snapshot!(
Identifier::from_str(".foo").unwrap_err(),
@"Invalid first character `.` for identifier `.foo`, expected an underscore or an alphabetic character"
);
}
#[test]
fn invalid_char() {
// A dot in module names equals a path separator, which is a separate problem.
assert_snapshot!(
Identifier::from_str("foo.bar").unwrap_err(),
@"Invalid character `.` at position 4 for identifier `foo.bar`, expected an underscore or an alphanumeric character"
);
assert_snapshot!(
Identifier::from_str("foo-bar").unwrap_err(),
@"Invalid character `-` at position 4 for identifier `foo-bar`, expected an underscore or an alphanumeric character"
);
assert_snapshot!(
Identifier::from_str("foo_bar$").unwrap_err(),
@"Invalid character `$` at position 8 for identifier `foo_bar$`, expected an underscore or an alphanumeric character"
);
assert_snapshot!(
Identifier::from_str("foo🦀bar").unwrap_err(),
@"Invalid character `🦀` at position 4 for identifier `foo🦀bar`, expected an underscore or an alphanumeric character"
);
}
}

View file

@ -2,6 +2,7 @@ pub use base_url::*;
pub use conflicts::*;
pub use dependency_groups::*;
pub use direct_url::*;
pub use identifier::*;
pub use lenient_requirement::*;
pub use marker_environment::*;
pub use metadata::*;
@ -15,6 +16,7 @@ mod base_url;
mod conflicts;
mod dependency_groups;
mod direct_url;
mod identifier;
mod lenient_requirement;
mod marker_environment;
mod metadata;

View file

@ -25,7 +25,7 @@ uv-normalize = { workspace = true }
uv-options-metadata = { workspace = true }
uv-pep440 = { workspace = true }
uv-pep508 = { workspace = true }
uv-pypi-types = { workspace = true, features = ["serde"] }
uv-pypi-types = { workspace = true }
uv-static = { workspace = true }
uv-warnings = { workspace = true }
@ -38,8 +38,8 @@ schemars = { workspace = true, optional = true }
serde = { workspace = true, features = ["derive"] }
thiserror = { workspace = true }
tokio = { workspace = true }
toml = { workspace = true }
toml_edit = { workspace = true }
toml = { workspace = true }
toml_edit = { workspace = true }
tracing = { workspace = true }
url = { workspace = true }

View file

@ -1,6 +1,7 @@
use crate::common::{uv_snapshot, venv_bin_path, TestContext};
use anyhow::Result;
use assert_cmd::assert::OutputAssertExt;
use assert_fs::fixture::{FileWriteStr, PathChild};
use flate2::bufread::GzDecoder;
use fs_err::File;
use indoc::indoc;
@ -278,3 +279,92 @@ fn preserve_executable_bit() -> Result<()> {
Ok(())
}
/// Test `tool.uv.build-backend.module-name`.
///
/// We include only the module specified by `module-name`, ignoring the project name and all other
/// potential modules.
#[test]
fn rename_module() -> Result<()> {
let context = TestContext::new("3.12");
let temp_dir = TempDir::new()?;
context
.temp_dir
.child("pyproject.toml")
.write_str(indoc! {r#"
[project]
name = "foo"
version = "1.0.0"
[tool.uv.build-backend]
module-name = "bar"
[build-system]
requires = ["uv_build>=0.5,<0.7"]
build-backend = "uv_build"
"#})?;
// This is the module we would usually include, but due to the renaming by `module-name` must
// ignore.
context
.temp_dir
.child("src/foo/__init__.py")
.write_str(r#"print("Hi from foo")"#)?;
// This module would be ignored from just `project.name`, but is selected due to the renaming.
context
.temp_dir
.child("src/bar/__init__.py")
.write_str(r#"print("Hi from bar")"#)?;
uv_snapshot!(context
.build_backend()
.arg("build-wheel")
.arg(temp_dir.path())
.env("UV_PREVIEW", "1"), @r###"
success: true
exit_code: 0
----- stdout -----
foo-1.0.0-py3-none-any.whl
----- stderr -----
"###);
context
.pip_install()
.arg(temp_dir.path().join("foo-1.0.0-py3-none-any.whl"))
.assert()
.success();
// Importing the module with the `module-name` name succeeds.
uv_snapshot!(Command::new(context.interpreter())
.arg("-c")
.arg("import bar")
// Python on windows
.env(EnvVars::PYTHONUTF8, "1"), @r###"
success: true
exit_code: 0
----- stdout -----
Hi from bar
----- stderr -----
"###);
// Importing the package name fails, it was overridden by `module-name`.
uv_snapshot!(Command::new(context.interpreter())
.arg("-c")
.arg("import foo")
// Python on windows
.env(EnvVars::PYTHONUTF8, "1"), @r###"
success: false
exit_code: 1
----- stdout -----
----- stderr -----
Traceback (most recent call last):
File "<string>", line 1, in <module>
ModuleNotFoundError: No module named 'foo'
"###);
Ok(())
}