From 4c2fc5490d67e8e56ac3f263ca1bb4726539e6f3 Mon Sep 17 00:00:00 2001 From: konstin Date: Mon, 25 Aug 2025 10:15:10 +0200 Subject: [PATCH] Add `METADATA.json` and `WHEEL.json` to build backend Add an experimental JSON format for METADATA and WHEEL files in the uv build backend to have a reference what it would look like and to demonstrate that the actual change is just a few lines, how much easier it is than the existing email-ish format. --- Cargo.lock | 5 + crates/uv-build-backend/Cargo.toml | 2 + crates/uv-build-backend/src/lib.rs | 116 +++++++++++++---- crates/uv-build-backend/src/wheel.rs | 121 +++++++++++++----- crates/uv-build/Cargo.toml | 2 + crates/uv-build/src/main.rs | 26 ++++ crates/uv-dispatch/src/lib.rs | 3 + crates/uv-preview/src/lib.rs | 7 + .../uv-pypi-types/src/metadata/metadata23.rs | 3 +- crates/uv-settings/src/lib.rs | 62 +-------- crates/uv-static/Cargo.toml | 2 + crates/uv-static/src/lib.rs | 64 +++++++++ crates/uv/src/commands/build_backend.rs | 17 ++- crates/uv/src/commands/build_frontend.rs | 8 +- crates/uv/src/lib.rs | 12 +- 15 files changed, 330 insertions(+), 120 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bbf136a19..90b211c78 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5186,6 +5186,8 @@ version = "0.8.13" dependencies = [ "anyhow", "uv-build-backend", + "uv-preview", + "uv-static", "uv-version", ] @@ -5205,6 +5207,7 @@ dependencies = [ "rustc-hash", "schemars", "serde", + "serde_json", "sha2", "spdx", "tar", @@ -5221,6 +5224,7 @@ dependencies = [ "uv-pep440", "uv-pep508", "uv-platform-tags", + "uv-preview", "uv-pypi-types", "uv-version", "uv-warnings", @@ -6346,6 +6350,7 @@ dependencies = [ name = "uv-static" version = "0.0.1" dependencies = [ + "thiserror 2.0.12", "uv-macros", ] diff --git a/crates/uv-build-backend/Cargo.toml b/crates/uv-build-backend/Cargo.toml index 269491582..183a689c7 100644 --- a/crates/uv-build-backend/Cargo.toml +++ b/crates/uv-build-backend/Cargo.toml @@ -22,6 +22,7 @@ uv-options-metadata = { workspace = true } uv-pep440 = { workspace = true } uv-pep508 = { workspace = true } uv-platform-tags = { workspace = true } +uv-preview = { workspace = true } uv-pypi-types = { workspace = true } uv-version = { workspace = true } uv-warnings = { workspace = true } @@ -35,6 +36,7 @@ itertools = { workspace = true } rustc-hash = { workspace = true } schemars = { workspace = true, optional = true } serde = { workspace = true } +serde_json = { workspace = true } sha2 = { workspace = true } spdx = { workspace = true } tar = { workspace = true } diff --git a/crates/uv-build-backend/src/lib.rs b/crates/uv-build-backend/src/lib.rs index aa90a88c5..a3bdcbd82 100644 --- a/crates/uv-build-backend/src/lib.rs +++ b/crates/uv-build-backend/src/lib.rs @@ -59,6 +59,8 @@ pub enum Error { Zip(#[from] zip::result::ZipError), #[error("Failed to write RECORD file")] Csv(#[from] csv::Error), + #[error("Failed to write JSON metadata file")] + Json(#[source] serde_json::Error), #[error("Expected a Python module at: `{}`", _0.user_display())] MissingInitPy(PathBuf), #[error("For namespace packages, `__init__.py[i]` is not allowed in parent directory: `{}`", _0.user_display())] @@ -362,6 +364,7 @@ mod tests { use tempfile::TempDir; use uv_distribution_filename::{SourceDistFilename, WheelFilename}; use uv_fs::{copy_dir_all, relative_to}; + use uv_preview::{Preview, PreviewFeatures}; const MOCK_UV_VERSION: &str = "1.0.0+test"; @@ -388,11 +391,11 @@ mod tests { /// Run both a direct wheel build and an indirect wheel build through a source distribution, /// while checking that directly built wheel and indirectly built wheel are the same. - fn build(source_root: &Path, dist: &Path) -> Result { + fn build(source_root: &Path, dist: &Path, preview: Preview) -> Result { // Build a direct wheel, capture all its properties to compare it with the indirect wheel // latest and remove it since it has the same filename as the indirect wheel. - let (_name, direct_wheel_list_files) = list_wheel(source_root, MOCK_UV_VERSION)?; - let direct_wheel_filename = build_wheel(source_root, dist, None, MOCK_UV_VERSION)?; + let (_name, direct_wheel_list_files) = list_wheel(source_root, MOCK_UV_VERSION, preview)?; + let direct_wheel_filename = build_wheel(source_root, dist, None, MOCK_UV_VERSION, preview)?; let direct_wheel_path = dist.join(direct_wheel_filename.to_string()); let direct_wheel_contents = wheel_contents(&direct_wheel_path); let direct_wheel_hash = sha2::Sha256::digest(fs_err::read(&direct_wheel_path)?); @@ -402,7 +405,7 @@ mod tests { let (_name, source_dist_list_files) = list_source_dist(source_root, MOCK_UV_VERSION)?; // TODO(konsti): This should run in the unpacked source dist tempdir, but we need to // normalize the path. - let (_name, wheel_list_files) = list_wheel(source_root, MOCK_UV_VERSION)?; + let (_name, wheel_list_files) = list_wheel(source_root, MOCK_UV_VERSION, preview)?; let source_dist_filename = build_source_dist(source_root, dist, MOCK_UV_VERSION)?; let source_dist_path = dist.join(source_dist_filename.to_string()); let source_dist_contents = sdist_contents(&source_dist_path); @@ -417,7 +420,13 @@ mod tests { source_dist_filename.name.as_dist_info_name(), source_dist_filename.version )); - let wheel_filename = build_wheel(&sdist_top_level_directory, dist, None, MOCK_UV_VERSION)?; + let wheel_filename = build_wheel( + &sdist_top_level_directory, + dist, + None, + MOCK_UV_VERSION, + preview, + )?; let wheel_contents = wheel_contents(&dist.join(wheel_filename.to_string())); // Check that direct and indirect wheels are identical. @@ -441,7 +450,7 @@ mod tests { fn build_err(source_root: &Path) -> String { let dist = TempDir::new().unwrap(); - let build_err = build(source_root, dist.path()).unwrap_err(); + let build_err = build(source_root, dist.path(), Preview::default()).unwrap_err(); let err_message: String = format_err(&build_err) .replace(&source_root.user_display().to_string(), "[TEMP_PATH]") .replace('\\', "/"); @@ -558,7 +567,7 @@ mod tests { // Perform both the direct and the indirect build. let dist = TempDir::new().unwrap(); - let build = build(src.path(), dist.path()).unwrap(); + let build = build(src.path(), dist.path(), Preview::default()).unwrap(); let source_dist_path = dist.path().join(build.source_dist_filename.to_string()); assert_eq!( @@ -622,7 +631,7 @@ mod tests { // Check that the wheel is reproducible across platforms. assert_snapshot!( format!("{:x}", sha2::Sha256::digest(fs_err::read(&wheel_path).unwrap())), - @"319afb04e87caf894b1362b508ec745253c6d241423ea59021694d2015e821da" + @"dbe56fd8bd52184095b2e0ea3e83c95d1bc8b4aa53cf469cec5af62251b24abb" ); assert_snapshot!(build.wheel_contents.join("\n"), @r" built_by_uv-0.1.0.data/data/ @@ -673,7 +682,7 @@ mod tests { .unwrap() .read_to_string(&mut record) .unwrap(); - assert_snapshot!(record, @r###" + assert_snapshot!(record, @r" built_by_uv/__init__.py,sha256=AJ7XpTNWxYktP97ydb81UpnNqoebH7K4sHRakAMQKG4,44 built_by_uv/arithmetic/__init__.py,sha256=x2agwFbJAafc9Z6TdJ0K6b6bLMApQdvRSQjP4iy7IEI,67 built_by_uv/arithmetic/circle.py,sha256=FYZkv6KwrF9nJcwGOKigjke1dm1Fkie7qW1lWJoh3AE,287 @@ -685,11 +694,11 @@ mod tests { built_by_uv-0.1.0.data/headers/built_by_uv.h,sha256=p5-HBunJ1dY-xd4dMn03PnRClmGyRosScIp8rT46kg4,144 built_by_uv-0.1.0.data/scripts/whoami.sh,sha256=T2cmhuDFuX-dTkiSkuAmNyIzvv8AKopjnuTCcr9o-eE,20 built_by_uv-0.1.0.data/data/data.csv,sha256=7z7u-wXu7Qr2eBZFVpBILlNUiGSngv_1vYqZHVWOU94,265 - built_by_uv-0.1.0.dist-info/WHEEL,sha256=PaG_oOj9G2zCRqoLK0SjWBVZbGAMtIXDmm-MEGw9Wo0,83 + built_by_uv-0.1.0.dist-info/WHEEL,sha256=JBpLtoa_WBz5WPGpRsAUTD4Dz6H0KkkdiKWCkfMSS1U,84 built_by_uv-0.1.0.dist-info/entry_points.txt,sha256=-IO6yaq6x6HSl-zWH96rZmgYvfyHlH00L5WQoCpz-YI,50 built_by_uv-0.1.0.dist-info/METADATA,sha256=m6EkVvKrGmqx43b_VR45LHD37IZxPYC0NI6Qx9_UXLE,474 built_by_uv-0.1.0.dist-info/RECORD,, - "###); + "); } /// Test that `license = { file = "LICENSE" }` is supported. @@ -738,6 +747,7 @@ mod tests { output_dir.path(), None, "0.5.15", + Preview::default(), ) .unwrap(); let wheel = output_dir @@ -790,7 +800,13 @@ mod tests { // Prepare the metadata. let metadata_dir = TempDir::new().unwrap(); - let dist_info_dir = metadata(src.path(), metadata_dir.path(), "0.5.15").unwrap(); + let dist_info_dir = metadata( + src.path(), + metadata_dir.path(), + "0.5.15", + Preview::default(), + ) + .unwrap(); let metadata_prepared = fs_err::read_to_string(metadata_dir.path().join(&dist_info_dir).join("METADATA")) .unwrap(); @@ -802,6 +818,7 @@ mod tests { output_dir.path(), Some(&metadata_dir.path().join(&dist_info_dir)), "0.5.15", + Preview::default(), ) .unwrap(); let wheel = output_dir @@ -851,7 +868,7 @@ mod tests { File::create(src.path().join("two_step_build").join("__init__.py")).unwrap(); let dist = TempDir::new().unwrap(); - let build1 = build(src.path(), dist.path()).unwrap(); + let build1 = build(src.path(), dist.path(), Preview::default()).unwrap(); assert_snapshot!(build1.source_dist_contents.join("\n"), @r" two_step_build-1.0.0/ @@ -890,7 +907,7 @@ mod tests { .unwrap(); let dist = TempDir::new().unwrap(); - let build2 = build(src.path(), dist.path()).unwrap(); + let build2 = build(src.path(), dist.path(), Preview::default()).unwrap(); assert_eq!(build1, build2); } @@ -917,7 +934,7 @@ mod tests { File::create(src.path().join("src").join("camelCase").join("__init__.py")).unwrap(); let dist = TempDir::new().unwrap(); - let build1 = build(src.path(), dist.path()).unwrap(); + let build1 = build(src.path(), dist.path(), Preview::default()).unwrap(); assert_snapshot!(build1.wheel_contents.join("\n"), @r" camelCase/ @@ -934,7 +951,7 @@ mod tests { pyproject_toml.replace("camelCase", "camel_case"), ) .unwrap(); - let build_err = build(src.path(), dist.path()).unwrap_err(); + let build_err = build(src.path(), dist.path(), Preview::default()).unwrap_err(); let err_message = format_err(&build_err) .replace(&src.path().user_display().to_string(), "[TEMP_PATH]") .replace('\\', "/"); @@ -963,7 +980,7 @@ mod tests { 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 build_err = build(src.path(), dist.path(), Preview::default()).unwrap_err(); let err_message = format_err(&build_err); assert_snapshot!( err_message, @@ -999,7 +1016,7 @@ mod tests { File::create(®ular_init_py).unwrap(); let dist = TempDir::new().unwrap(); - let build_err = build(src.path(), dist.path()).unwrap_err(); + let build_err = build(src.path(), dist.path(), Preview::default()).unwrap_err(); let err_message = format_err(&build_err) .replace(&src.path().user_display().to_string(), "[TEMP_PATH]") .replace('\\', "/"); @@ -1018,7 +1035,7 @@ mod tests { ) .unwrap(); - let build1 = build(src.path(), dist.path()).unwrap(); + let build1 = build(src.path(), dist.path(), Preview::default()).unwrap(); assert_snapshot!(build1.wheel_contents.join("\n"), @r" stuffed_bird-stubs/ stuffed_bird-stubs/__init__.pyi @@ -1044,7 +1061,7 @@ mod tests { }; fs_err::write(src.path().join("pyproject.toml"), pyproject_toml).unwrap(); - let build2 = build(src.path(), dist.path()).unwrap(); + let build2 = build(src.path(), dist.path(), Preview::default()).unwrap(); assert_eq!(build1.wheel_contents, build2.wheel_contents); } @@ -1098,7 +1115,7 @@ mod tests { fs_err::remove_file(bogus_init_py).unwrap(); let dist = TempDir::new().unwrap(); - let build1 = build(src.path(), dist.path()).unwrap(); + let build1 = build(src.path(), dist.path(), Preview::default()).unwrap(); assert_snapshot!(build1.source_dist_contents.join("\n"), @r" simple_namespace_part-1.0.0/ simple_namespace_part-1.0.0/PKG-INFO @@ -1135,7 +1152,7 @@ mod tests { }; fs_err::write(src.path().join("pyproject.toml"), pyproject_toml).unwrap(); - let build2 = build(src.path(), dist.path()).unwrap(); + let build2 = build(src.path(), dist.path(), Preview::default()).unwrap(); assert_eq!(build1, build2); } @@ -1189,7 +1206,7 @@ mod tests { .unwrap(); let dist = TempDir::new().unwrap(); - let build1 = build(src.path(), dist.path()).unwrap(); + let build1 = build(src.path(), dist.path(), Preview::default()).unwrap(); assert_snapshot!(build1.wheel_contents.join("\n"), @r" complex_namespace-1.0.0.dist-info/ complex_namespace-1.0.0.dist-info/METADATA @@ -1219,7 +1236,7 @@ mod tests { }; fs_err::write(src.path().join("pyproject.toml"), pyproject_toml).unwrap(); - let build2 = build(src.path(), dist.path()).unwrap(); + let build2 = build(src.path(), dist.path(), Preview::default()).unwrap(); assert_eq!(build1, build2); } @@ -1260,7 +1277,7 @@ mod tests { .unwrap(); let dist = TempDir::new().unwrap(); - let build = build(src.path(), dist.path()).unwrap(); + let build = build(src.path(), dist.path(), Preview::default()).unwrap(); assert_snapshot!(build.wheel_contents.join("\n"), @r" cloud-stubs/ cloud-stubs/db/ @@ -1357,7 +1374,7 @@ mod tests { fs_err::remove_file(bogus_init_py).unwrap(); let dist = TempDir::new().unwrap(); - let build = build(src.path(), dist.path()).unwrap(); + let build = build(src.path(), dist.path(), Preview::default()).unwrap(); assert_snapshot!(build.source_dist_contents.join("\n"), @r" simple_namespace_part-1.0.0/ simple_namespace_part-1.0.0/PKG-INFO @@ -1385,4 +1402,51 @@ mod tests { simple_namespace_part-1.0.0.dist-info/WHEEL "); } + + /// Check that JSON metadata files are present. + #[test] + fn metadata_json_preview() { + let src = TempDir::new().unwrap(); + fs_err::write( + src.path().join("pyproject.toml"), + indoc! {r#" + [project] + name = "metadata-json-preview" + version = "1.0.0" + + [build-system] + requires = ["uv_build>=0.5.15,<0.6.0"] + build-backend = "uv_build" + "# + }, + ) + .unwrap(); + fs_err::create_dir_all(src.path().join("src").join("metadata_json_preview")).unwrap(); + File::create( + src.path() + .join("src") + .join("metadata_json_preview") + .join("__init__.py"), + ) + .unwrap(); + + let dist = TempDir::new().unwrap(); + let build = build( + src.path(), + dist.path(), + Preview::new(PreviewFeatures::METADATA_JSON), + ) + .unwrap(); + + assert_snapshot!(build.wheel_contents.join("\n"), @r" + metadata_json_preview-1.0.0.dist-info/ + metadata_json_preview-1.0.0.dist-info/METADATA + metadata_json_preview-1.0.0.dist-info/METADATA.json + metadata_json_preview-1.0.0.dist-info/RECORD + metadata_json_preview-1.0.0.dist-info/WHEEL + metadata_json_preview-1.0.0.dist-info/WHEEL.json + metadata_json_preview/ + metadata_json_preview/__init__.py + "); + } } diff --git a/crates/uv-build-backend/src/wheel.rs b/crates/uv-build-backend/src/wheel.rs index 7009a6468..c85dadf27 100644 --- a/crates/uv-build-backend/src/wheel.rs +++ b/crates/uv-build-backend/src/wheel.rs @@ -1,9 +1,9 @@ use base64::{Engine, prelude::BASE64_URL_SAFE_NO_PAD as base64}; use fs_err::File; use globset::{GlobSet, GlobSetBuilder}; -use itertools::Itertools; use rustc_hash::FxHashSet; use sha2::{Digest, Sha256}; +use std::fmt::{Display, Formatter}; use std::io::{BufReader, Read, Write}; use std::path::{Path, PathBuf}; use std::{io, mem}; @@ -15,6 +15,7 @@ use uv_distribution_filename::WheelFilename; use uv_fs::Simplified; use uv_globfilter::{GlobDirFilter, PortableGlobParser}; use uv_platform_tags::{AbiTag, LanguageTag, PlatformTag}; +use uv_preview::{Preview, PreviewFeatures}; use uv_warnings::warn_user_once; use crate::metadata::DEFAULT_EXCLUDES; @@ -28,6 +29,7 @@ pub fn build_wheel( wheel_dir: &Path, metadata_directory: Option<&Path>, uv_version: &str, + preview: Preview, ) -> Result { let contents = fs_err::read_to_string(source_tree.join("pyproject.toml"))?; let pyproject_toml = PyProjectToml::parse(&contents)?; @@ -57,6 +59,7 @@ pub fn build_wheel( &filename, uv_version, wheel_writer, + preview, )?; Ok(filename) @@ -66,6 +69,7 @@ pub fn build_wheel( pub fn list_wheel( source_tree: &Path, uv_version: &str, + preview: Preview, ) -> Result<(WheelFilename, FileList), Error> { let contents = fs_err::read_to_string(source_tree.join("pyproject.toml"))?; let pyproject_toml = PyProjectToml::parse(&contents)?; @@ -86,7 +90,14 @@ pub fn list_wheel( let mut files = FileList::new(); let writer = ListWriter::new(&mut files); - write_wheel(source_tree, &pyproject_toml, &filename, uv_version, writer)?; + write_wheel( + source_tree, + &pyproject_toml, + &filename, + uv_version, + writer, + preview, + )?; Ok((filename, files)) } @@ -96,6 +107,7 @@ fn write_wheel( filename: &WheelFilename, uv_version: &str, mut wheel_writer: impl DirectoryWriter, + preview: Preview, ) -> Result<(), Error> { let settings = pyproject_toml .settings() @@ -231,6 +243,7 @@ fn write_wheel( filename, source_tree, uv_version, + preview, )?; wheel_writer.close(&dist_info_dir)?; @@ -243,6 +256,7 @@ pub fn build_editable( wheel_dir: &Path, metadata_directory: Option<&Path>, uv_version: &str, + preview: Preview, ) -> Result { let contents = fs_err::read_to_string(source_tree.join("pyproject.toml"))?; let pyproject_toml = PyProjectToml::parse(&contents)?; @@ -293,6 +307,7 @@ pub fn build_editable( &filename, source_tree, uv_version, + preview, )?; wheel_writer.close(&dist_info_dir)?; @@ -304,6 +319,7 @@ pub fn metadata( source_tree: &Path, metadata_directory: &Path, uv_version: &str, + preview: Preview, ) -> Result { let contents = fs_err::read_to_string(source_tree.join("pyproject.toml"))?; let pyproject_toml = PyProjectToml::parse(&contents)?; @@ -333,6 +349,7 @@ pub fn metadata( &filename, source_tree, uv_version, + preview, )?; wheel_writer.close(&dist_info_dir)?; @@ -535,6 +552,7 @@ fn write_dist_info( filename: &WheelFilename, root: &Path, uv_version: &str, + preview: Preview, ) -> Result { let dist_info_dir = format!( "{}-{}.dist-info", @@ -544,9 +562,18 @@ fn write_dist_info( writer.write_directory(&dist_info_dir)?; - // Add `WHEEL`. - let wheel_info = wheel_info(filename, uv_version); - writer.write_bytes(&format!("{dist_info_dir}/WHEEL"), wheel_info.as_bytes())?; + // Add `WHEEL` and `WHEEL.json`. + let wheel_info = WheelInfo::new(filename, uv_version); + writer.write_bytes( + &format!("{dist_info_dir}/WHEEL"), + wheel_info.to_string().as_bytes(), + )?; + if preview.is_enabled(PreviewFeatures::METADATA_JSON) { + writer.write_bytes( + &format!("{dist_info_dir}/WHEEL.json"), + &serde_json::to_vec(&wheel_info).map_err(Error::Json)?, + )?; + } // Add `entry_points.txt`. if let Some(entrypoint) = pyproject_toml.to_entry_points()? { @@ -556,34 +583,64 @@ fn write_dist_info( )?; } - // Add `METADATA`. - let metadata = pyproject_toml.to_metadata(root)?.core_metadata_format(); - writer.write_bytes(&format!("{dist_info_dir}/METADATA"), metadata.as_bytes())?; + // Add `METADATA` and `METADATA.json`. + let metadata = pyproject_toml.to_metadata(root)?; + writer.write_bytes( + &format!("{dist_info_dir}/METADATA"), + metadata.core_metadata_format().as_bytes(), + )?; + if preview.is_enabled(PreviewFeatures::METADATA_JSON) { + writer.write_bytes( + &format!("{dist_info_dir}/METADATA.json"), + &serde_json::to_vec(&metadata).map_err(Error::Json)?, + )?; + } // `RECORD` is added on closing. Ok(dist_info_dir) } -/// Returns the `WHEEL` file contents. -fn wheel_info(filename: &WheelFilename, uv_version: &str) -> String { - // https://packaging.python.org/en/latest/specifications/binary-distribution-format/#file-contents - let mut wheel_info = vec![ - ("Wheel-Version", "1.0".to_string()), - ("Generator", format!("uv {uv_version}")), - ("Root-Is-Purelib", "true".to_string()), - ]; - for python_tag in filename.python_tags() { - for abi_tag in filename.abi_tags() { - for platform_tag in filename.platform_tags() { - wheel_info.push(("Tag", format!("{python_tag}-{abi_tag}-{platform_tag}"))); +/// The contents of the `WHEEL` and `WHEEL.json` files. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "kebab-case")] +struct WheelInfo { + wheel_version: String, + generator: String, + root_is_purelib: bool, + tags: Vec, +} + +impl WheelInfo { + fn new(filename: &WheelFilename, uv_version: &str) -> Self { + let mut tags = Vec::new(); + for python_tag in filename.python_tags() { + for abi_tag in filename.abi_tags() { + for platform_tag in filename.platform_tags() { + tags.push(format!("{python_tag}-{abi_tag}-{platform_tag}")); + } } } + Self { + wheel_version: "1.0".to_string(), + generator: format!("uv {uv_version}"), + root_is_purelib: true, + tags, + } + } +} + +impl Display for WheelInfo { + /// Returns the `WHEEL` file contents in its key-value format. + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + writeln!(f, "Wheel-Version: {}", self.wheel_version)?; + writeln!(f, "Generator: {}", self.generator)?; + writeln!(f, "Root-Is-Purelib: {}", self.root_is_purelib)?; + for tag in &self.tags { + writeln!(f, "Tag: {tag}")?; + } + Ok(()) } - wheel_info - .into_iter() - .map(|(key, value)| format!("{key}: {value}")) - .join("\n") } /// Zip archive (wheel) writer. @@ -784,7 +841,7 @@ mod test { PlatformTag::Any, ); - assert_snapshot!(wheel_info(&filename, "1.0.0+test"), @r" + assert_snapshot!(WheelInfo::new(&filename, "1.0.0+test").to_string(), @r" Wheel-Version: 1.0 Generator: uv 1.0.0+test Root-Is-Purelib: true @@ -813,7 +870,13 @@ mod test { fn test_prepare_metadata() { let metadata_dir = TempDir::new().unwrap(); let built_by_uv = Path::new("../../scripts/packages/built-by-uv"); - metadata(built_by_uv, metadata_dir.path(), "1.0.0+test").unwrap(); + metadata( + built_by_uv, + metadata_dir.path(), + "1.0.0+test", + Preview::default(), + ) + .unwrap(); let mut files: Vec<_> = WalkDir::new(metadata_dir.path()) .sort_by_file_name() @@ -861,12 +924,12 @@ mod test { let record_file = metadata_dir .path() .join("built_by_uv-0.1.0.dist-info/RECORD"); - assert_snapshot!(fs_err::read_to_string(record_file).unwrap(), @r###" - built_by_uv-0.1.0.dist-info/WHEEL,sha256=PaG_oOj9G2zCRqoLK0SjWBVZbGAMtIXDmm-MEGw9Wo0,83 + assert_snapshot!(fs_err::read_to_string(record_file).unwrap(), @r" + built_by_uv-0.1.0.dist-info/WHEEL,sha256=JBpLtoa_WBz5WPGpRsAUTD4Dz6H0KkkdiKWCkfMSS1U,84 built_by_uv-0.1.0.dist-info/entry_points.txt,sha256=-IO6yaq6x6HSl-zWH96rZmgYvfyHlH00L5WQoCpz-YI,50 built_by_uv-0.1.0.dist-info/METADATA,sha256=m6EkVvKrGmqx43b_VR45LHD37IZxPYC0NI6Qx9_UXLE,474 built_by_uv-0.1.0.dist-info/RECORD,, - "###); + "); let wheel_file = metadata_dir .path() diff --git a/crates/uv-build/Cargo.toml b/crates/uv-build/Cargo.toml index 413a8603e..dc42baf29 100644 --- a/crates/uv-build/Cargo.toml +++ b/crates/uv-build/Cargo.toml @@ -11,7 +11,9 @@ license = { workspace = true } [dependencies] uv-build-backend = { workspace = true } +uv-preview = { workspace = true } uv-version = { workspace = true } +uv-static = { workspace = true } anyhow = { workspace = true } diff --git a/crates/uv-build/src/main.rs b/crates/uv-build/src/main.rs index 4b9b5b3ee..efe810fdb 100644 --- a/crates/uv-build/src/main.rs +++ b/crates/uv-build/src/main.rs @@ -2,6 +2,9 @@ use anyhow::{Context, Result, bail}; use std::env; use std::io::Write; use std::path::PathBuf; +use std::str::FromStr; +use uv_preview::{Preview, PreviewFeatures}; +use uv_static::{EnvVars, parse_boolish_environment_variable}; /// Entrypoint for the `uv-build` Python package. fn main() -> Result<()> { @@ -15,6 +18,25 @@ fn main() -> Result<()> { .to_str() .context("Invalid non-UTF8 command")? .to_string(); + + // Ad-hoc preview features parsing due to a lack of clap CLI in uv-build. + let preview_features = + if parse_boolish_environment_variable(EnvVars::UV_PREVIEW)?.unwrap_or(false) { + PreviewFeatures::all() + } else if let Some(preview_features) = env::var_os(EnvVars::UV_PREVIEW_FEATURES) { + let preview_features = preview_features.to_str().with_context(|| { + format!("`{}` is not valid UTF-8", EnvVars::UV_PREVIEW_FEATURES) + })?; + PreviewFeatures::from_str(preview_features).with_context(|| { + format!( + "Invalid preview features list in `{}`", + EnvVars::UV_PREVIEW_FEATURES + ) + })? + } else { + PreviewFeatures::default() + }; + let preview = Preview::new(preview_features); match command.as_str() { "build-sdist" => { let sdist_directory = PathBuf::from(args.next().context("Missing sdist directory")?); @@ -34,6 +56,7 @@ fn main() -> Result<()> { &wheel_directory, metadata_directory.as_deref(), uv_version::version(), + preview, )?; // Tell the build frontend about the name of the artifact we built writeln!(&mut std::io::stdout(), "{filename}").context("stdout is closed")?; @@ -46,6 +69,7 @@ fn main() -> Result<()> { &wheel_directory, metadata_directory.as_deref(), uv_version::version(), + preview, )?; // Tell the build frontend about the name of the artifact we built writeln!(&mut std::io::stdout(), "{filename}").context("stdout is closed")?; @@ -56,6 +80,7 @@ fn main() -> Result<()> { &env::current_dir()?, &wheel_directory, uv_version::version(), + preview, )?; // Tell the build frontend about the name of the artifact we built writeln!(&mut std::io::stdout(), "{filename}").context("stdout is closed")?; @@ -66,6 +91,7 @@ fn main() -> Result<()> { &env::current_dir()?, &wheel_directory, uv_version::version(), + preview, )?; // Tell the build frontend about the name of the artifact we built writeln!(&mut std::io::stdout(), "{filename}").context("stdout is closed")?; diff --git a/crates/uv-dispatch/src/lib.rs b/crates/uv-dispatch/src/lib.rs index a20c26ffb..0508e061d 100644 --- a/crates/uv-dispatch/src/lib.rs +++ b/crates/uv-dispatch/src/lib.rs @@ -523,6 +523,7 @@ impl BuildContext for BuildDispatch<'_> { debug!("Performing direct build for {identifier}"); let output_dir = output_dir.to_path_buf(); + let preview = self.preview; let filename = tokio::task::spawn_blocking(move || -> Result<_> { let filename = match build_kind { BuildKind::Wheel => { @@ -531,6 +532,7 @@ impl BuildContext for BuildDispatch<'_> { &output_dir, None, uv_version::version(), + preview, )?; DistFilename::WheelFilename(wheel) } @@ -548,6 +550,7 @@ impl BuildContext for BuildDispatch<'_> { &output_dir, None, uv_version::version(), + preview, )?; DistFilename::WheelFilename(wheel) } diff --git a/crates/uv-preview/src/lib.rs b/crates/uv-preview/src/lib.rs index 59c680264..f2f1af6bc 100644 --- a/crates/uv-preview/src/lib.rs +++ b/crates/uv-preview/src/lib.rs @@ -18,6 +18,7 @@ bitflags::bitflags! { const EXTRA_BUILD_DEPENDENCIES = 1 << 6; const DETECT_MODULE_CONFLICTS = 1 << 7; const FORMAT = 1 << 8; + const METADATA_JSON = 1 << 9; } } @@ -36,6 +37,7 @@ impl PreviewFeatures { Self::EXTRA_BUILD_DEPENDENCIES => "extra-build-dependencies", Self::DETECT_MODULE_CONFLICTS => "detect-module-conflicts", Self::FORMAT => "format", + Self::METADATA_JSON => "metadata-json", _ => panic!("`flag_as_str` can only be used for exactly one feature flag"), } } @@ -82,6 +84,7 @@ impl FromStr for PreviewFeatures { "extra-build-dependencies" => Self::EXTRA_BUILD_DEPENDENCIES, "detect-module-conflicts" => Self::DETECT_MODULE_CONFLICTS, "format" => Self::FORMAT, + "metadata-json" => Self::METADATA_JSON, _ => { warn_user_once!("Unknown preview feature: `{part}`"); continue; @@ -257,6 +260,10 @@ mod tests { "detect-module-conflicts" ); assert_eq!(PreviewFeatures::FORMAT.flag_as_str(), "format"); + assert_eq!( + PreviewFeatures::METADATA_JSON.flag_as_str(), + "metadata-json" + ); } #[test] diff --git a/crates/uv-pypi-types/src/metadata/metadata23.rs b/crates/uv-pypi-types/src/metadata/metadata23.rs index 811f5773d..d914be401 100644 --- a/crates/uv-pypi-types/src/metadata/metadata23.rs +++ b/crates/uv-pypi-types/src/metadata/metadata23.rs @@ -10,7 +10,8 @@ use crate::metadata::Headers; /// Code Metadata 2.3 as specified in /// . -#[derive(Debug, Clone, Default, PartialEq, Eq)] +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "kebab-case")] pub struct Metadata23 { /// Version of the file format; legal values are `1.0`, `1.1`, `1.2`, `2.1`, `2.2`, `2.3` and /// `2.4`. diff --git a/crates/uv-settings/src/lib.rs b/crates/uv-settings/src/lib.rs index df15d60bf..d0ebdb5f4 100644 --- a/crates/uv-settings/src/lib.rs +++ b/crates/uv-settings/src/lib.rs @@ -3,7 +3,7 @@ use std::path::{Path, PathBuf}; use uv_dirs::{system_config_file, user_config_dir}; use uv_fs::Simplified; -use uv_static::EnvVars; +use uv_static::{EnvVars, InvalidEnvironmentVariable, parse_boolish_environment_variable}; use uv_warnings::warn_user; pub use crate::combine::*; @@ -554,12 +554,8 @@ pub enum Error { #[error("Failed to parse: `{}`. The `{}` field is not allowed in a `uv.toml` file. `{}` is only applicable in the context of a project, and should be placed in a `pyproject.toml` file instead.", _0.user_display(), _1, _1)] PyprojectOnlyField(PathBuf, &'static str), - #[error("Failed to parse environment variable `{name}` with invalid value `{value}`: {err}")] - InvalidEnvironmentVariable { - name: String, - value: String, - err: String, - }, + #[error(transparent)] + InvalidEnvironmentVariable(#[from] InvalidEnvironmentVariable), } /// Options loaded from environment variables. @@ -583,55 +579,3 @@ impl EnvironmentOptions { }) } } - -/// Parse a boolean environment variable. -/// -/// Adapted from Clap's `BoolishValueParser` which is dual licensed under the MIT and Apache-2.0. -fn parse_boolish_environment_variable(name: &'static str) -> Result, Error> { - // See `clap_builder/src/util/str_to_bool.rs` - // We want to match Clap's accepted values - - // True values are `y`, `yes`, `t`, `true`, `on`, and `1`. - const TRUE_LITERALS: [&str; 6] = ["y", "yes", "t", "true", "on", "1"]; - - // False values are `n`, `no`, `f`, `false`, `off`, and `0`. - const FALSE_LITERALS: [&str; 6] = ["n", "no", "f", "false", "off", "0"]; - - // Converts a string literal representation of truth to true or false. - // - // `false` values are `n`, `no`, `f`, `false`, `off`, and `0` (case insensitive). - // - // Any other value will be considered as `true`. - fn str_to_bool(val: impl AsRef) -> Option { - let pat: &str = &val.as_ref().to_lowercase(); - if TRUE_LITERALS.contains(&pat) { - Some(true) - } else if FALSE_LITERALS.contains(&pat) { - Some(false) - } else { - None - } - } - - let Some(value) = std::env::var_os(name) else { - return Ok(None); - }; - - let Some(value) = value.to_str() else { - return Err(Error::InvalidEnvironmentVariable { - name: name.to_string(), - value: value.to_string_lossy().to_string(), - err: "expected a valid UTF-8 string".to_string(), - }); - }; - - let Some(value) = str_to_bool(value) else { - return Err(Error::InvalidEnvironmentVariable { - name: name.to_string(), - value: value.to_string(), - err: "expected a boolish value".to_string(), - }); - }; - - Ok(Some(value)) -} diff --git a/crates/uv-static/Cargo.toml b/crates/uv-static/Cargo.toml index 77d1fe54b..924846e39 100644 --- a/crates/uv-static/Cargo.toml +++ b/crates/uv-static/Cargo.toml @@ -17,3 +17,5 @@ workspace = true [dependencies] uv-macros = { workspace = true } + +thiserror = { workspace = true } diff --git a/crates/uv-static/src/lib.rs b/crates/uv-static/src/lib.rs index 153591db7..85085cbb8 100644 --- a/crates/uv-static/src/lib.rs +++ b/crates/uv-static/src/lib.rs @@ -1,3 +1,67 @@ pub use env_vars::*; mod env_vars; + +use thiserror::Error; + +#[derive(Debug, Error)] +#[error("Failed to parse environment variable `{name}` with invalid value `{value}`: {err}")] +pub struct InvalidEnvironmentVariable { + name: String, + value: String, + err: String, +} + +/// Parse a boolean environment variable. +/// +/// Adapted from Clap's `BoolishValueParser` which is dual licensed under the MIT and Apache-2.0. +pub fn parse_boolish_environment_variable( + name: &'static str, +) -> Result, InvalidEnvironmentVariable> { + // See `clap_builder/src/util/str_to_bool.rs` + // We want to match Clap's accepted values + + // True values are `y`, `yes`, `t`, `true`, `on`, and `1`. + const TRUE_LITERALS: [&str; 6] = ["y", "yes", "t", "true", "on", "1"]; + + // False values are `n`, `no`, `f`, `false`, `off`, and `0`. + const FALSE_LITERALS: [&str; 6] = ["n", "no", "f", "false", "off", "0"]; + + // Converts a string literal representation of truth to true or false. + // + // `false` values are `n`, `no`, `f`, `false`, `off`, and `0` (case insensitive). + // + // Any other value will be considered as `true`. + fn str_to_bool(val: impl AsRef) -> Option { + let pat: &str = &val.as_ref().to_lowercase(); + if TRUE_LITERALS.contains(&pat) { + Some(true) + } else if FALSE_LITERALS.contains(&pat) { + Some(false) + } else { + None + } + } + + let Some(value) = std::env::var_os(name) else { + return Ok(None); + }; + + let Some(value) = value.to_str() else { + return Err(InvalidEnvironmentVariable { + name: name.to_string(), + value: value.to_string_lossy().to_string(), + err: "expected a valid UTF-8 string".to_string(), + }); + }; + + let Some(value) = str_to_bool(value) else { + return Err(InvalidEnvironmentVariable { + name: name.to_string(), + value: value.to_string(), + err: "expected a boolish value".to_string(), + }); + }; + + Ok(Some(value)) +} diff --git a/crates/uv/src/commands/build_backend.rs b/crates/uv/src/commands/build_backend.rs index 2a725e149..c1bcab7a5 100644 --- a/crates/uv/src/commands/build_backend.rs +++ b/crates/uv/src/commands/build_backend.rs @@ -3,6 +3,7 @@ use anyhow::{Context, Result}; use std::env; use std::io::Write; use std::path::Path; +use uv_preview::Preview; /// PEP 517 hook to build a source distribution. pub(crate) fn build_sdist(sdist_directory: &Path) -> Result { @@ -20,12 +21,14 @@ pub(crate) fn build_sdist(sdist_directory: &Path) -> Result { pub(crate) fn build_wheel( wheel_directory: &Path, metadata_directory: Option<&Path>, + preview: Preview, ) -> Result { let filename = uv_build_backend::build_wheel( &env::current_dir()?, wheel_directory, metadata_directory, uv_version::version(), + preview, )?; // Tell the build frontend about the name of the artifact we built writeln!(&mut std::io::stdout(), "{filename}").context("stdout is closed")?; @@ -36,12 +39,14 @@ pub(crate) fn build_wheel( pub(crate) fn build_editable( wheel_directory: &Path, metadata_directory: Option<&Path>, + preview: Preview, ) -> Result { let filename = uv_build_backend::build_editable( &env::current_dir()?, wheel_directory, metadata_directory, uv_version::version(), + preview, )?; // Tell the build frontend about the name of the artifact we built writeln!(&mut std::io::stdout(), "{filename}").context("stdout is closed")?; @@ -59,11 +64,15 @@ pub(crate) fn get_requires_for_build_wheel() -> Result { } /// PEP 517 hook to just emit metadata through `.dist-info`. -pub(crate) fn prepare_metadata_for_build_wheel(metadata_directory: &Path) -> Result { +pub(crate) fn prepare_metadata_for_build_wheel( + metadata_directory: &Path, + preview: Preview, +) -> Result { let filename = uv_build_backend::metadata( &env::current_dir()?, metadata_directory, uv_version::version(), + preview, )?; // Tell the build frontend about the name of the artifact we built writeln!(&mut std::io::stdout(), "{filename}").context("stdout is closed")?; @@ -76,11 +85,15 @@ pub(crate) fn get_requires_for_build_editable() -> Result { } /// PEP 660 hook to just emit metadata through `.dist-info`. -pub(crate) fn prepare_metadata_for_build_editable(metadata_directory: &Path) -> Result { +pub(crate) fn prepare_metadata_for_build_editable( + metadata_directory: &Path, + preview: Preview, +) -> Result { let filename = uv_build_backend::metadata( &env::current_dir()?, metadata_directory, uv_version::version(), + preview, )?; // Tell the build frontend about the name of the artifact we built writeln!(&mut std::io::stdout(), "{filename}").context("stdout is closed")?; diff --git a/crates/uv/src/commands/build_frontend.rs b/crates/uv/src/commands/build_frontend.rs index b90ae0283..3fe919489 100644 --- a/crates/uv/src/commands/build_frontend.rs +++ b/crates/uv/src/commands/build_frontend.rs @@ -729,6 +729,7 @@ async fn build_package( version_id, build_output, Some(sdist_build.normalized_filename().version()), + preview, ) .await?; build_results.push(wheel_build); @@ -766,6 +767,7 @@ async fn build_package( version_id, build_output, None, + preview, ) .await?; build_results.push(wheel_build); @@ -801,6 +803,7 @@ async fn build_package( version_id, build_output, Some(sdist_build.normalized_filename().version()), + preview, ) .await?; build_results.push(sdist_build); @@ -844,6 +847,7 @@ async fn build_package( version_id, build_output, version.as_ref(), + preview, ) .await?; build_results.push(wheel_build); @@ -996,12 +1000,13 @@ async fn build_wheel( build_output: BuildOutput, // Used for checking version consistency version: Option<&Version>, + preview: Preview, ) -> Result { let build_message = match action { BuildAction::List => { let source_tree_ = source_tree.to_path_buf(); let (filename, file_list) = tokio::task::spawn_blocking(move || { - uv_build_backend::list_wheel(&source_tree_, uv_version::version()) + uv_build_backend::list_wheel(&source_tree_, uv_version::version(), preview) }) .await??; let raw_filename = filename.to_string(); @@ -1031,6 +1036,7 @@ async fn build_wheel( &output_dir_, None, uv_version::version(), + preview, ) }) .await??; diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index 84c727fd0..00c5eef4d 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -1641,6 +1641,7 @@ async fn run(mut cli: Cli) -> Result { } => commands::build_backend::build_wheel( &wheel_directory, metadata_directory.as_deref(), + globals.preview, ), BuildBackendCommand::BuildEditable { wheel_directory, @@ -1648,6 +1649,7 @@ async fn run(mut cli: Cli) -> Result { } => commands::build_backend::build_editable( &wheel_directory, metadata_directory.as_deref(), + globals.preview, ), BuildBackendCommand::GetRequiresForBuildSdist => { commands::build_backend::get_requires_for_build_sdist() @@ -1656,13 +1658,19 @@ async fn run(mut cli: Cli) -> Result { commands::build_backend::get_requires_for_build_wheel() } BuildBackendCommand::PrepareMetadataForBuildWheel { wheel_directory } => { - commands::build_backend::prepare_metadata_for_build_wheel(&wheel_directory) + commands::build_backend::prepare_metadata_for_build_wheel( + &wheel_directory, + globals.preview, + ) } BuildBackendCommand::GetRequiresForBuildEditable => { commands::build_backend::get_requires_for_build_editable() } BuildBackendCommand::PrepareMetadataForBuildEditable { wheel_directory } => { - commands::build_backend::prepare_metadata_for_build_editable(&wheel_directory) + commands::build_backend::prepare_metadata_for_build_editable( + &wheel_directory, + globals.preview, + ) } }) .await