From f5dd0f9c034276aeb57ff6bc381250081457db0a Mon Sep 17 00:00:00 2001 From: Christian Sachs Date: Sat, 1 Mar 2025 14:50:23 +0100 Subject: [PATCH 01/10] Allow overriding module name for uv build backend --- crates/uv-build-backend/src/metadata.rs | 4 ++++ crates/uv-build-backend/src/source_dist.rs | 9 ++++++++- crates/uv-build-backend/src/wheel.rs | 9 ++++++++- 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/crates/uv-build-backend/src/metadata.rs b/crates/uv-build-backend/src/metadata.rs index 19676d6551ea7..5fc2e499b5d9a 100644 --- a/crates/uv-build-backend/src/metadata.rs +++ b/crates/uv-build-backend/src/metadata.rs @@ -846,6 +846,9 @@ pub(crate) struct BuildBackendSettings { /// using the flat layout over the src layout. pub(crate) module_root: PathBuf, + /// Explicitly specified module name if differing from the project name. + pub(crate) module_name: Option, + /// Glob expressions which files and directories to additionally include in the source /// distribution. /// @@ -877,6 +880,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(), diff --git a/crates/uv-build-backend/src/source_dist.rs b/crates/uv-build-backend/src/source_dist.rs index fa40840d2ed2b..375307527f67d 100644 --- a/crates/uv-build-backend/src/source_dist.rs +++ b/crates/uv-build-backend/src/source_dist.rs @@ -5,6 +5,7 @@ use flate2::write::GzEncoder; use flate2::Compression; use fs_err::File; use globset::{Glob, GlobSet}; +use std::borrow::Cow; use std::io; use std::io::{BufReader, Cursor}; use std::path::{Path, PathBuf}; @@ -65,11 +66,17 @@ fn source_dist_matcher( let mut includes: Vec = settings.source_include; // pyproject.toml is always included. includes.push(globset::escape("pyproject.toml")); + + let module_name = settings + .module_name + .map_or(pyproject_toml.name().as_dist_info_name(), Cow::from); + debug!("Module name is: {:?}", 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))); diff --git a/crates/uv-build-backend/src/wheel.rs b/crates/uv-build-backend/src/wheel.rs index 806700665264a..64b7f7998b9ed 100644 --- a/crates/uv-build-backend/src/wheel.rs +++ b/crates/uv-build-backend/src/wheel.rs @@ -1,3 +1,4 @@ +use std::borrow::Cow; use std::io::{BufReader, Read, Write}; use std::path::{Path, PathBuf}; use std::{io, mem}; @@ -128,7 +129,13 @@ 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 = settings + .module_name + .map_or(pyproject_toml.name().as_dist_info_name(), Cow::from); + debug!("Module name is: {:?}", 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)); } From 593edbb14f735725b8ea52a52831d032b0451154 Mon Sep 17 00:00:00 2001 From: konstin Date: Mon, 3 Mar 2025 14:03:30 +0100 Subject: [PATCH 02/10] Add validation, docs and test. --- Cargo.lock | 1 + crates/uv-build-backend/src/lib.rs | 3 + crates/uv-build-backend/src/metadata.rs | 16 ++- crates/uv-build-backend/src/source_dist.rs | 15 ++- crates/uv-build-backend/src/wheel.rs | 22 ++-- crates/uv-pypi-types/Cargo.toml | 1 + crates/uv-pypi-types/src/identifier.rs | 137 +++++++++++++++++++++ crates/uv-pypi-types/src/lib.rs | 2 + crates/uv/tests/it/build_backend.rs | 84 +++++++++++++ 9 files changed, 262 insertions(+), 19 deletions(-) create mode 100644 crates/uv-pypi-types/src/identifier.rs diff --git a/Cargo.lock b/Cargo.lock index ddd116f1445f5..0f738bb09173b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5387,6 +5387,7 @@ dependencies = [ "anyhow", "hashbrown 0.15.2", "indexmap", + "insta", "itertools 0.14.0", "jiff", "mailparse", diff --git a/crates/uv-build-backend/src/lib.rs b/crates/uv-build-backend/src/lib.rs index 6aa0bb86088e6..4f038256d0173 100644 --- a/crates/uv-build-backend/src/lib.rs +++ b/crates/uv-build-backend/src/lib.rs @@ -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, diff --git a/crates/uv-build-backend/src/metadata.rs b/crates/uv-build-backend/src/metadata.rs index 5fc2e499b5d9a..2d699faae1014 100644 --- a/crates/uv-build-backend/src/metadata.rs +++ b/crates/uv-build-backend/src/metadata.rs @@ -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//**`. +/// `src//**`. /// * `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//**`. +/// `src//**`. /// * `project.license-files` and `project.readme`, as part of the project metadata. /// * Each directory under `tool.uv.build-backend.data`, as data directories. /// @@ -846,8 +846,14 @@ pub(crate) struct BuildBackendSettings { /// using the flat layout over the src layout. pub(crate) module_root: PathBuf, - /// Explicitly specified module name if differing from the project name. - pub(crate) module_name: Option, + /// 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 undefined behavior, often + /// with corrupted files or directory trees. + pub(crate) module_name: Option, /// Glob expressions which files and directories to additionally include in the source /// distribution. diff --git a/crates/uv-build-backend/src/source_dist.rs b/crates/uv-build-backend/src/source_dist.rs index 375307527f67d..991d8ec67d794 100644 --- a/crates/uv-build-backend/src/source_dist.rs +++ b/crates/uv-build-backend/src/source_dist.rs @@ -5,15 +5,16 @@ use flate2::write::GzEncoder; use flate2::Compression; use fs_err::File; use globset::{Glob, GlobSet}; -use std::borrow::Cow; 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; @@ -67,10 +68,14 @@ fn source_dist_matcher( // pyproject.toml is always included. includes.push(globset::escape("pyproject.toml")); - let module_name = settings - .module_name - .map_or(pyproject_toml.name().as_dist_info_name(), Cow::from); - debug!("Module name is: {:?}", module_name); + let module_name = if let Some(module_name) = settings.module_name { + module_name + } else { + // Should never happen, 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). diff --git a/crates/uv-build-backend/src/wheel.rs b/crates/uv-build-backend/src/wheel.rs index 64b7f7998b9ed..dc3ab0491ccf7 100644 --- a/crates/uv-build-backend/src/wheel.rs +++ b/crates/uv-build-backend/src/wheel.rs @@ -1,12 +1,11 @@ -use std::borrow::Cow; -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}; @@ -15,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}; @@ -130,10 +130,14 @@ fn write_wheel( } let strip_root = source_tree.join(settings.module_root); - let module_name = settings - .module_name - .map_or(pyproject_toml.name().as_dist_info_name(), Cow::from); - debug!("Module name is: {:?}", module_name); + let module_name = if let Some(module_name) = settings.module_name { + module_name + } else { + // Should never happen, 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() { diff --git a/crates/uv-pypi-types/Cargo.toml b/crates/uv-pypi-types/Cargo.toml index fe97710d767bc..190f5c433d267 100644 --- a/crates/uv-pypi-types/Cargo.toml +++ b/crates/uv-pypi-types/Cargo.toml @@ -42,6 +42,7 @@ url = { workspace = true } [dev-dependencies] anyhow = { workspace = true } +insta = { version = "1.40.0"} [features] schemars = ["dep:schemars", "uv-normalize/schemars"] diff --git a/crates/uv-pypi-types/src/identifier.rs b/crates/uv-pypi-types/src/identifier.rs new file mode 100644 index 0000000000000..3652e52a7bd1b --- /dev/null +++ b/crates/uv-pypi-types/src/identifier.rs @@ -0,0 +1,137 @@ +use std::fmt::Display; +use std::str::FromStr; +use thiserror::Error; + +/// Simplified Python identifier. +/// +/// We don't match Python's identifier rules +/// () 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(String); + +#[derive(Debug, Clone, Error)] +pub enum IdentifierParseError { + #[error("An identifier must not be empty")] + Empty, + #[error( + "Invalid first character `{first}` at for `{identifier}`, expected an underscore or an alphabetic character" + )] + InvalidFirstChar { first: char, identifier: String }, + #[error( + "Invalid character `{invalid_char}` at position {pos} for `{identifier}`, expected an underscore or an alphanumeric character" + )] + InvalidChar { + pos: usize, + invalid_char: char, + identifier: String, + }, +} + +impl Identifier { + pub fn new(identifier: String) -> Result { + 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::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 for Identifier { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl<'de> serde::de::Deserialize<'de> for Identifier { + fn deserialize(deserializer: D) -> Result + 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` at for `1foo`, expected an underscore or an alphabetic character"); + assert_snapshot!(Identifier::from_str("$foo").unwrap_err(), @"Invalid first character `$` at for `$foo`, expected an underscore or an alphabetic character"); + assert_snapshot!(Identifier::from_str(".foo").unwrap_err(), @"Invalid first character `.` at for `.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 `foo.bar`, expected an underscore or an alphanumeric character"); + assert_snapshot!(Identifier::from_str("foo-bar").unwrap_err(), @"Invalid character `-` at position 4 for `foo-bar`, expected an underscore or an alphanumeric character"); + assert_snapshot!(Identifier::from_str("foo_bar$").unwrap_err(), @"Invalid character `$` at position 8 for `foo_bar$`, expected an underscore or an alphanumeric character"); + assert_snapshot!(Identifier::from_str("foo🦀bar").unwrap_err(), @"Invalid character `🦀` at position 4 for `foo🦀bar`, expected an underscore or an alphanumeric character"); + } +} diff --git a/crates/uv-pypi-types/src/lib.rs b/crates/uv-pypi-types/src/lib.rs index a3b2b6e05c26d..a5c9ec8a4b564 100644 --- a/crates/uv-pypi-types/src/lib.rs +++ b/crates/uv-pypi-types/src/lib.rs @@ -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; diff --git a/crates/uv/tests/it/build_backend.rs b/crates/uv/tests/it/build_backend.rs index 7c9320532d08c..29fa944f516ca 100644 --- a/crates/uv/tests/it/build_backend.rs +++ b/crates/uv/tests/it/build_backend.rs @@ -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,86 @@ fn preserve_executable_bit() -> Result<()> { Ok(()) } + +/// Test `tool.uv.build-backend.module-name`. +#[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>=0.5,<0.7"] + build-backend = "uv" + "#})?; + + context + .temp_dir + .child("src/foo/__init__.py") + .write_str(r#"print("Hi from foo")"#)?; + 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 renamed module 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 + 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 "", line 1, in + ModuleNotFoundError: No module named 'foo' + "###); + + Ok(()) +} From 24b8c8a16381fe150e293c522525d8f87c3ab9fc Mon Sep 17 00:00:00 2001 From: konstin Date: Thu, 6 Mar 2025 09:39:08 +0100 Subject: [PATCH 03/10] Review --- Cargo.lock | 20 ++++----- crates/uv-build-backend/src/metadata.rs | 4 +- crates/uv-build-backend/src/source_dist.rs | 2 +- crates/uv-build-backend/src/wheel.rs | 2 +- crates/uv-pypi-types/src/identifier.rs | 49 ++++++++++++++++------ crates/uv/tests/it/build_backend.rs | 10 ++++- 6 files changed, 58 insertions(+), 29 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0f738bb09173b..2293886f3826e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -688,7 +688,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" dependencies = [ "lazy_static", - "windows-sys 0.59.0", + "windows-sys 0.48.0", ] [[package]] @@ -1042,7 +1042,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -1841,9 +1841,9 @@ checksum = "b248f5224d1d606005e02c97f5aa4e88eeb230488bcc03bc9ca4d7991399f2b5" [[package]] name = "insta" -version = "1.42.2" +version = "1.42.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50259abbaa67d11d2bcafc7ba1d094ed7a0c70e3ce893f0d0997f73558cb3084" +checksum = "71c1b125e30d93896b365e156c33dadfffab45ee8400afcbba4752f59de08a86" dependencies = [ "console", "linked-hash-map", @@ -1882,7 +1882,7 @@ checksum = "e19b23d53f35ce9f56aebc7d1bb4e6ac1e9c0db7ac85c8d1760c04379edced37" dependencies = [ "hermit-abi 0.4.0", "libc", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -1941,7 +1941,7 @@ dependencies = [ "portable-atomic", "portable-atomic-util", "serde", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -2806,7 +2806,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -3236,7 +3236,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -3817,7 +3817,7 @@ dependencies = [ "getrandom 0.3.1", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -6046,7 +6046,7 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.48.0", ] [[package]] diff --git a/crates/uv-build-backend/src/metadata.rs b/crates/uv-build-backend/src/metadata.rs index 2d699faae1014..50de430af6e17 100644 --- a/crates/uv-build-backend/src/metadata.rs +++ b/crates/uv-build-backend/src/metadata.rs @@ -851,8 +851,8 @@ pub(crate) struct BuildBackendSettings { /// 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 undefined behavior, often - /// with corrupted files or directory trees. + /// the same module names. Installing such packages together leads to unspecified behavior, + /// often with corrupted files or directory trees. pub(crate) module_name: Option, /// Glob expressions which files and directories to additionally include in the source diff --git a/crates/uv-build-backend/src/source_dist.rs b/crates/uv-build-backend/src/source_dist.rs index 991d8ec67d794..3449ee4e02b98 100644 --- a/crates/uv-build-backend/src/source_dist.rs +++ b/crates/uv-build-backend/src/source_dist.rs @@ -71,7 +71,7 @@ fn source_dist_matcher( let module_name = if let Some(module_name) = settings.module_name { module_name } else { - // Should never happen, the rules for package names (in dist-info formatting) are stricter + // 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())? }; diff --git a/crates/uv-build-backend/src/wheel.rs b/crates/uv-build-backend/src/wheel.rs index dc3ab0491ccf7..f14cdc5267f75 100644 --- a/crates/uv-build-backend/src/wheel.rs +++ b/crates/uv-build-backend/src/wheel.rs @@ -133,7 +133,7 @@ fn write_wheel( let module_name = if let Some(module_name) = settings.module_name { module_name } else { - // Should never happen, the rules for package names (in dist-info formatting) are stricter + // 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())? }; diff --git a/crates/uv-pypi-types/src/identifier.rs b/crates/uv-pypi-types/src/identifier.rs index 3652e52a7bd1b..9b9bad8c5a4b6 100644 --- a/crates/uv-pypi-types/src/identifier.rs +++ b/crates/uv-pypi-types/src/identifier.rs @@ -9,28 +9,30 @@ use thiserror::Error; /// (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(String); +pub struct Identifier(Box); #[derive(Debug, Clone, Error)] pub enum IdentifierParseError { #[error("An identifier must not be empty")] Empty, #[error( - "Invalid first character `{first}` at for `{identifier}`, expected an underscore or an alphabetic character" + "Invalid first character `{first}` for identifier `{identifier}`, expected an underscore or an alphabetic character" )] - InvalidFirstChar { first: char, identifier: String }, + InvalidFirstChar { first: char, identifier: Box }, #[error( - "Invalid character `{invalid_char}` at position {pos} for `{identifier}`, expected an underscore or an alphanumeric character" + "Invalid character `{invalid_char}` at position {pos} for identifier `{identifier}`, \ + expected an underscore or an alphanumeric character" )] InvalidChar { pos: usize, invalid_char: char, - identifier: String, + identifier: Box, }, } impl Identifier { - pub fn new(identifier: String) -> Result { + pub fn new(identifier: impl Into>) -> Result { + 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() { @@ -121,17 +123,38 @@ mod tests { #[test] fn invalid_first_char() { - assert_snapshot!(Identifier::from_str("1foo").unwrap_err(), @"Invalid first character `1` at for `1foo`, expected an underscore or an alphabetic character"); - assert_snapshot!(Identifier::from_str("$foo").unwrap_err(), @"Invalid first character `$` at for `$foo`, expected an underscore or an alphabetic character"); - assert_snapshot!(Identifier::from_str(".foo").unwrap_err(), @"Invalid first character `.` at for `.foo`, expected an underscore or an alphabetic character"); + assert_snapshot!( + Identifier::from_str("1foo").unwrap_err(), + @"Invalid first character `1` at for `1foo`, expected an underscore or an alphabetic character" + ); + assert_snapshot!( + Identifier::from_str("$foo").unwrap_err(), + @"Invalid first character `$` at for `$foo`, expected an underscore or an alphabetic character" + ); + assert_snapshot!( + Identifier::from_str(".foo").unwrap_err(), + @"Invalid first character `.` at for `.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 `foo.bar`, expected an underscore or an alphanumeric character"); - assert_snapshot!(Identifier::from_str("foo-bar").unwrap_err(), @"Invalid character `-` at position 4 for `foo-bar`, expected an underscore or an alphanumeric character"); - assert_snapshot!(Identifier::from_str("foo_bar$").unwrap_err(), @"Invalid character `$` at position 8 for `foo_bar$`, expected an underscore or an alphanumeric character"); - assert_snapshot!(Identifier::from_str("foo🦀bar").unwrap_err(), @"Invalid character `🦀` at position 4 for `foo🦀bar`, expected an underscore or an alphanumeric character"); + assert_snapshot!( + Identifier::from_str("foo.bar").unwrap_err(), + @"Invalid character `.` at position 4 for `foo.bar`, expected an underscore or an alphanumeric character" + ); + assert_snapshot!( + Identifier::from_str("foo-bar").unwrap_err(), + @"Invalid character `-` at position 4 for `foo-bar`, expected an underscore or an alphanumeric character" + ); + assert_snapshot!( + Identifier::from_str("foo_bar$").unwrap_err(), + @"Invalid character `$` at position 8 for `foo_bar$`, expected an underscore or an alphanumeric character" + ); + assert_snapshot!( + Identifier::from_str("foo🦀bar").unwrap_err(), + @"Invalid character `🦀` at position 4 for `foo🦀bar`, expected an underscore or an alphanumeric character" + ); } } diff --git a/crates/uv/tests/it/build_backend.rs b/crates/uv/tests/it/build_backend.rs index 29fa944f516ca..05272db78231f 100644 --- a/crates/uv/tests/it/build_backend.rs +++ b/crates/uv/tests/it/build_backend.rs @@ -281,6 +281,9 @@ fn preserve_executable_bit() -> Result<()> { } /// 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"); @@ -302,10 +305,13 @@ fn rename_module() -> Result<()> { build-backend = "uv" "#})?; + // 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") @@ -330,7 +336,7 @@ fn rename_module() -> Result<()> { .assert() .success(); - // Importing the renamed module succeeds + // Importing the module with the `module-name` name succeeds. uv_snapshot!(Command::new(context.interpreter()) .arg("-c") .arg("import bar") @@ -344,7 +350,7 @@ fn rename_module() -> Result<()> { ----- stderr ----- "###); - // Importing the package name fails + // Importing the package name fails, it was overridden by `module-name`. uv_snapshot!(Command::new(context.interpreter()) .arg("-c") .arg("import foo") From f533804991de68ef592e001882d04bfc0aa49efe Mon Sep 17 00:00:00 2001 From: konstin Date: Thu, 6 Mar 2025 09:42:20 +0100 Subject: [PATCH 04/10] Reset Cargo.lock --- Cargo.lock | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2293886f3826e..ddd116f1445f5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -688,7 +688,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" dependencies = [ "lazy_static", - "windows-sys 0.48.0", + "windows-sys 0.59.0", ] [[package]] @@ -1042,7 +1042,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -1841,9 +1841,9 @@ checksum = "b248f5224d1d606005e02c97f5aa4e88eeb230488bcc03bc9ca4d7991399f2b5" [[package]] name = "insta" -version = "1.42.1" +version = "1.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71c1b125e30d93896b365e156c33dadfffab45ee8400afcbba4752f59de08a86" +checksum = "50259abbaa67d11d2bcafc7ba1d094ed7a0c70e3ce893f0d0997f73558cb3084" dependencies = [ "console", "linked-hash-map", @@ -1882,7 +1882,7 @@ checksum = "e19b23d53f35ce9f56aebc7d1bb4e6ac1e9c0db7ac85c8d1760c04379edced37" dependencies = [ "hermit-abi 0.4.0", "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -1941,7 +1941,7 @@ dependencies = [ "portable-atomic", "portable-atomic-util", "serde", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -2806,7 +2806,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -3236,7 +3236,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -3817,7 +3817,7 @@ dependencies = [ "getrandom 0.3.1", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -5387,7 +5387,6 @@ dependencies = [ "anyhow", "hashbrown 0.15.2", "indexmap", - "insta", "itertools 0.14.0", "jiff", "mailparse", @@ -6046,7 +6045,7 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.59.0", ] [[package]] From 4cebea89adb3b083fb6fc51f4bd670d785f732ac Mon Sep 17 00:00:00 2001 From: konstin Date: Thu, 6 Mar 2025 17:41:37 +0100 Subject: [PATCH 05/10] Update lockfile --- Cargo.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/Cargo.lock b/Cargo.lock index ddd116f1445f5..0f738bb09173b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5387,6 +5387,7 @@ dependencies = [ "anyhow", "hashbrown 0.15.2", "indexmap", + "insta", "itertools 0.14.0", "jiff", "mailparse", From 98edd36bbc473dae3a4967df46c44b6d50cdb778 Mon Sep 17 00:00:00 2001 From: konstin Date: Thu, 6 Mar 2025 17:46:14 +0100 Subject: [PATCH 06/10] Update snapshots --- crates/uv-pypi-types/src/identifier.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/uv-pypi-types/src/identifier.rs b/crates/uv-pypi-types/src/identifier.rs index 9b9bad8c5a4b6..0ff550f792e93 100644 --- a/crates/uv-pypi-types/src/identifier.rs +++ b/crates/uv-pypi-types/src/identifier.rs @@ -125,7 +125,7 @@ mod tests { fn invalid_first_char() { assert_snapshot!( Identifier::from_str("1foo").unwrap_err(), - @"Invalid first character `1` at for `1foo`, expected an underscore or an alphabetic character" + @"Invalid first character `1` for identifier `1foo`, expected an underscore or an alphabetic character" ); assert_snapshot!( Identifier::from_str("$foo").unwrap_err(), @@ -142,7 +142,7 @@ mod tests { // 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 `foo.bar`, expected an underscore or an alphanumeric character" + @"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(), From 4efa388e7aa860ee1f4f1e40e5479627c462f07c Mon Sep 17 00:00:00 2001 From: konstin Date: Fri, 7 Mar 2025 14:26:04 +0100 Subject: [PATCH 07/10] Update snapshots --- crates/uv-pypi-types/src/identifier.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/uv-pypi-types/src/identifier.rs b/crates/uv-pypi-types/src/identifier.rs index 0ff550f792e93..42bda8175a7da 100644 --- a/crates/uv-pypi-types/src/identifier.rs +++ b/crates/uv-pypi-types/src/identifier.rs @@ -129,11 +129,11 @@ mod tests { ); assert_snapshot!( Identifier::from_str("$foo").unwrap_err(), - @"Invalid first character `$` at for `$foo`, expected an underscore or an alphabetic character" + @"Invalid first character `$` for identifier `$foo`, expected an underscore or an alphabetic character" ); assert_snapshot!( Identifier::from_str(".foo").unwrap_err(), - @"Invalid first character `.` at for `.foo`, expected an underscore or an alphabetic character" + @"Invalid first character `.` for identifier `.foo`, expected an underscore or an alphabetic character" ); } @@ -146,15 +146,15 @@ mod tests { ); assert_snapshot!( Identifier::from_str("foo-bar").unwrap_err(), - @"Invalid character `-` at position 4 for `foo-bar`, expected an underscore or an alphanumeric character" + @"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 `foo_bar$`, expected an underscore or an alphanumeric character" + @"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 `foo🦀bar`, expected an underscore or an alphanumeric character" + @"Invalid character `🦀` at position 4 for identifier `foo🦀bar`, expected an underscore or an alphanumeric character" ); } } From ee96f0b9c8355b50c8c4b831a03201943216575f Mon Sep 17 00:00:00 2001 From: konstin Date: Fri, 7 Mar 2025 14:37:46 +0100 Subject: [PATCH 08/10] Rebase onto main --- crates/uv/tests/it/build_backend.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/uv/tests/it/build_backend.rs b/crates/uv/tests/it/build_backend.rs index 05272db78231f..731bcdfaf8f93 100644 --- a/crates/uv/tests/it/build_backend.rs +++ b/crates/uv/tests/it/build_backend.rs @@ -301,8 +301,8 @@ fn rename_module() -> Result<()> { module-name = "bar" [build-system] - requires = ["uv>=0.5,<0.7"] - build-backend = "uv" + 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 From 7bdf701fac7044243fa9c7e2a0826da5f41e613c Mon Sep 17 00:00:00 2001 From: konstin Date: Fri, 7 Mar 2025 14:52:28 +0100 Subject: [PATCH 09/10] serde is not optional in uv-pypi-types --- crates/uv-pypi-types/Cargo.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/uv-pypi-types/Cargo.toml b/crates/uv-pypi-types/Cargo.toml index 190f5c433d267..db515b86a1e52 100644 --- a/crates/uv-pypi-types/Cargo.toml +++ b/crates/uv-pypi-types/Cargo.toml @@ -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,7 +42,7 @@ url = { workspace = true } [dev-dependencies] anyhow = { workspace = true } -insta = { version = "1.40.0"} +insta = { version = "1.40.0" } [features] schemars = ["dep:schemars", "uv-normalize/schemars"] From 2ca4cbf2ed2cce496be7ea51ff34acd919151941 Mon Sep 17 00:00:00 2001 From: konstin Date: Fri, 7 Mar 2025 14:56:52 +0100 Subject: [PATCH 10/10] serde is not optional in uv-pypi-types --- crates/uv-workspace/Cargo.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/uv-workspace/Cargo.toml b/crates/uv-workspace/Cargo.toml index 43f4a53a941a1..0ba386accff81 100644 --- a/crates/uv-workspace/Cargo.toml +++ b/crates/uv-workspace/Cargo.toml @@ -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 }