From 0ab603e0352bd7e6906305a189804f18ad035fbe Mon Sep 17 00:00:00 2001 From: konsti Date: Wed, 14 Jan 2026 19:52:09 +0100 Subject: [PATCH] Add `METADATA.json` and `WHEEL.json` in uv build backend (#15510) https://discuss.python.org/t/pep-819-json-package-metadata/105558 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 header format. The change is overwhelmingly adding the preview feature as it's the first preview item in the build backend, writing the actual JSON is only a couple of lines. --------- Co-authored-by: Charlie Marsh --- Cargo.lock | 6 + crates/uv-build-backend/Cargo.toml | 3 + crates/uv-build-backend/src/lib.rs | 141 ++++++++--- crates/uv-build-backend/src/metadata.rs | 24 +- crates/uv-build-backend/src/wheel.rs | 113 ++++++--- 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 + crates/uv-publish/src/lib.rs | 4 +- .../uv-pypi-types/src/metadata/metadata23.rs | 87 ++++++- crates/uv-pypi-types/src/metadata/mod.rs | 2 +- crates/uv-settings/src/lib.rs | 98 ++------ crates/uv-static/Cargo.toml | 2 + crates/uv-static/src/lib.rs | 64 +++++ crates/uv-trampoline/Cargo.lock | 21 ++ crates/uv/src/commands/build_backend.rs | 17 +- crates/uv/src/commands/build_frontend.rs | 7 + crates/uv/src/lib.rs | 12 +- crates/uv/tests/it/build_backend.rs | 223 ++++++++++++++++++ crates/uv/tests/it/show_settings.rs | 4 +- 21 files changed, 701 insertions(+), 165 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 99dee3d69..a9300c235 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5606,6 +5606,8 @@ dependencies = [ "tracing-subscriber", "uv-build-backend", "uv-logging", + "uv-preview", + "uv-static", "uv-version", ] @@ -5619,6 +5621,7 @@ dependencies = [ "flate2", "fs-err", "globset", + "indexmap", "indoc", "insta", "itertools 0.14.0", @@ -5626,6 +5629,7 @@ dependencies = [ "rustc-hash", "schemars", "serde", + "serde_json", "sha2", "spdx 0.13.3", "tar", @@ -5642,6 +5646,7 @@ dependencies = [ "uv-pep440", "uv-pep508", "uv-platform-tags", + "uv-preview", "uv-pypi-types", "uv-version", "uv-warnings", @@ -6822,6 +6827,7 @@ dependencies = [ name = "uv-static" version = "0.0.14" dependencies = [ + "thiserror 2.0.17", "uv-macros", ] diff --git a/crates/uv-build-backend/Cargo.toml b/crates/uv-build-backend/Cargo.toml index 2c2db3ec9..f39c84c75 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 } @@ -31,10 +32,12 @@ csv = { workspace = true } flate2 = { workspace = true, default-features = false } fs-err = { workspace = true } globset = { workspace = true } +indexmap = { workspace = true } 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 b65aaa365..a0d2dee22 100644 --- a/crates/uv-build-backend/src/lib.rs +++ b/crates/uv-build-backend/src/lib.rs @@ -65,6 +65,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())] @@ -451,6 +453,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"; @@ -477,11 +480,13 @@ 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, false)?; - let direct_wheel_filename = build_wheel(source_root, dist, None, MOCK_UV_VERSION, false)?; + let (_name, direct_wheel_list_files) = + list_wheel(source_root, MOCK_UV_VERSION, false, preview)?; + let direct_wheel_filename = + build_wheel(source_root, dist, None, MOCK_UV_VERSION, false, 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)?); @@ -492,7 +497,7 @@ mod tests { list_source_dist(source_root, MOCK_UV_VERSION, false)?; // 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, false)?; + let (_name, wheel_list_files) = list_wheel(source_root, MOCK_UV_VERSION, false, preview)?; let source_dist_filename = build_source_dist(source_root, dist, MOCK_UV_VERSION, false)?; let source_dist_path = dist.join(source_dist_filename.to_string()); let source_dist_contents = sdist_contents(&source_dist_path); @@ -513,6 +518,7 @@ mod tests { None, MOCK_UV_VERSION, false, + preview, )?; let wheel_contents = wheel_contents(&dist.join(wheel_filename.to_string())); @@ -537,7 +543,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('\\', "/"); @@ -654,7 +660,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!( @@ -718,7 +724,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/ @@ -769,7 +775,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 @@ -781,11 +787,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. @@ -835,6 +841,7 @@ mod tests { None, "0.5.15", false, + Preview::default(), ) .unwrap(); let wheel = output_dir @@ -887,7 +894,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(); @@ -900,6 +913,7 @@ mod tests { Some(&metadata_dir.path().join(&dist_info_dir)), "0.5.15", false, + Preview::default(), ) .unwrap(); let wheel = output_dir @@ -949,7 +963,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/ @@ -988,7 +1002,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); } @@ -1015,7 +1029,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/ @@ -1032,7 +1046,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('\\', "/"); @@ -1069,7 +1083,14 @@ mod tests { assert!(sdist_result.is_err()); // Wheel build should fail - let wheel_result = build_wheel(src.path(), dist.path(), None, MOCK_UV_VERSION, false); + let wheel_result = build_wheel( + src.path(), + dist.path(), + None, + MOCK_UV_VERSION, + false, + Preview::default(), + ); assert!(wheel_result.is_err()); // dist directory should be empty (no partial files) @@ -1113,7 +1134,14 @@ mod tests { let sdist_result = build_source_dist(src.path(), dist.path(), MOCK_UV_VERSION, false); assert!(sdist_result.is_err()); - let wheel_result = build_wheel(src.path(), dist.path(), None, MOCK_UV_VERSION, false); + let wheel_result = build_wheel( + src.path(), + dist.path(), + None, + MOCK_UV_VERSION, + false, + Preview::default(), + ); assert!(wheel_result.is_err()); // Verify pre-existing files were deleted @@ -1166,7 +1194,15 @@ mod tests { // Build should succeed and overwrite existing files build_source_dist(src.path(), dist.path(), MOCK_UV_VERSION, false).unwrap(); - build_wheel(src.path(), dist.path(), None, MOCK_UV_VERSION, false).unwrap(); + build_wheel( + src.path(), + dist.path(), + None, + MOCK_UV_VERSION, + false, + Preview::default(), + ) + .unwrap(); // Verify files were overwritten (content should be different) assert_ne!( @@ -1210,7 +1246,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, @@ -1246,7 +1282,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('\\', "/"); @@ -1265,7 +1301,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 @@ -1291,7 +1327,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); } @@ -1345,7 +1381,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 @@ -1382,7 +1418,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); } @@ -1436,7 +1472,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 @@ -1466,7 +1502,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); } @@ -1507,7 +1543,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/ @@ -1604,7 +1640,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 @@ -1718,7 +1754,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.source_dist_contents.join("\n"), @r" duplicate-1.0.0/ duplicate-1.0.0/PKG-INFO @@ -1742,4 +1778,51 @@ mod tests { foo/__init__.py "); } + + /// 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/metadata.rs b/crates/uv-build-backend/src/metadata.rs index 8752cc15b..1fdc476ad 100644 --- a/crates/uv-build-backend/src/metadata.rs +++ b/crates/uv-build-backend/src/metadata.rs @@ -1,3 +1,6 @@ +use indexmap::IndexMap; +use itertools::Itertools; +use serde::{Deserialize, Deserializer}; use std::borrow::Cow; use std::collections::{BTreeMap, Bound}; use std::ffi::OsStr; @@ -5,9 +8,6 @@ use std::fmt::Display; use std::fmt::Write; use std::path::{Path, PathBuf}; use std::str::{self, FromStr}; - -use itertools::Itertools; -use serde::{Deserialize, Deserializer}; use tracing::{debug, trace, warn}; use version_ranges::Ranges; use walkdir::WalkDir; @@ -19,7 +19,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::{Keywords, Metadata23, ProjectUrls, VerbatimParsedUrl}; use crate::serde_verbatim::SerdeVerbatim; use crate::{BuildBackendSettings, Error, error_on_venv}; @@ -353,13 +353,7 @@ impl PyProjectToml { let (license, license_expression, license_files) = self.license_metadata(root)?; // TODO(konsti): https://peps.python.org/pep-0753/#label-normalization (Draft) - let project_urls = self - .project - .urls - .iter() - .flatten() - .map(|(key, value)| format!("{key}, {value}")) - .collect(); + let project_urls = ProjectUrls::new(self.project.urls.clone().unwrap_or_default()); let extras = self .project @@ -404,11 +398,7 @@ impl PyProjectToml { summary, description, description_content_type, - keywords: self - .project - .keywords - .as_ref() - .map(|keywords| keywords.join(",")), + keywords: self.project.keywords.clone().map(Keywords::new), home_page: None, download_url: None, author, @@ -699,7 +689,7 @@ struct Project { /// PyPI shows all URLs with their name. For some known patterns, they add favicons. /// main: /// archived: - urls: Option>, + urls: Option>, /// The console entrypoints of the project. /// /// The key of the table is the name of the entry point and the value is the object reference. diff --git a/crates/uv-build-backend/src/wheel.rs b/crates/uv-build-backend/src/wheel.rs index 26d1bb191..7e2da3188 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, Seek, Write}; use std::path::{Component, Path, PathBuf}; use std::{io, mem}; @@ -16,6 +16,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; @@ -31,6 +32,7 @@ pub fn build_wheel( metadata_directory: Option<&Path>, uv_version: &str, show_warnings: bool, + preview: Preview, ) -> Result { let pyproject_toml = PyProjectToml::parse(&source_tree.join("pyproject.toml"))?; for warning in pyproject_toml.check_build_system(uv_version) { @@ -66,6 +68,7 @@ pub fn build_wheel( uv_version, wheel_writer, show_warnings, + preview, )?; temp_file @@ -80,6 +83,7 @@ pub fn list_wheel( source_tree: &Path, uv_version: &str, show_warnings: bool, + preview: Preview, ) -> Result<(WheelFilename, FileList), Error> { let pyproject_toml = PyProjectToml::parse(&source_tree.join("pyproject.toml"))?; for warning in pyproject_toml.check_build_system(uv_version) { @@ -106,6 +110,7 @@ pub fn list_wheel( uv_version, writer, show_warnings, + preview, )?; Ok((filename, files)) } @@ -117,6 +122,7 @@ fn write_wheel( uv_version: &str, mut wheel_writer: impl DirectoryWriter, show_warnings: bool, + preview: Preview, ) -> Result<(), Error> { let settings = pyproject_toml .settings() @@ -268,6 +274,7 @@ fn write_wheel( filename, source_tree, uv_version, + preview, )?; wheel_writer.close(&dist_info_dir)?; @@ -281,6 +288,7 @@ pub fn build_editable( metadata_directory: Option<&Path>, uv_version: &str, show_warnings: bool, + preview: Preview, ) -> Result { let pyproject_toml = PyProjectToml::parse(&source_tree.join("pyproject.toml"))?; for warning in pyproject_toml.check_build_system(uv_version) { @@ -337,6 +345,7 @@ pub fn build_editable( &filename, source_tree, uv_version, + preview, )?; wheel_writer.close(&dist_info_dir)?; @@ -352,6 +361,7 @@ pub fn metadata( source_tree: &Path, metadata_directory: &Path, uv_version: &str, + preview: Preview, ) -> Result { let pyproject_toml = PyProjectToml::parse(&source_tree.join("pyproject.toml"))?; for warning in pyproject_toml.check_build_system(uv_version) { @@ -380,6 +390,7 @@ pub fn metadata( &filename, source_tree, uv_version, + preview, )?; wheel_writer.close(&dist_info_dir)?; @@ -584,6 +595,7 @@ fn write_dist_info( filename: &WheelFilename, root: &Path, uv_version: &str, + preview: Preview, ) -> Result { let dist_info_dir = format!( "{}-{}.dist-info", @@ -593,9 +605,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()? { @@ -605,34 +626,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. @@ -833,7 +884,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 @@ -862,7 +913,13 @@ mod test { fn test_prepare_metadata() { let metadata_dir = TempDir::new().unwrap(); let built_by_uv = Path::new("../../test/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() @@ -910,12 +967,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 bf7bec4d5..ee40aea86 100644 --- a/crates/uv-build/Cargo.toml +++ b/crates/uv-build/Cargo.toml @@ -12,7 +12,9 @@ license = { workspace = true } [dependencies] uv-build-backend = { workspace = true } uv-logging = { workspace = true } +uv-preview = { workspace = true } uv-version = { workspace = true } +uv-static = { workspace = true } anstream = { workspace = true } anyhow = { workspace = true } diff --git a/crates/uv-build/src/main.rs b/crates/uv-build/src/main.rs index b3211cb4c..6f5d13874 100644 --- a/crates/uv-build/src/main.rs +++ b/crates/uv-build/src/main.rs @@ -1,6 +1,9 @@ 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}; use anyhow::{Context, Result, bail}; use tracing_subscriber::filter::LevelFilter; @@ -37,6 +40,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!("Invalid UTF-8 in `{}`", 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")?); @@ -58,6 +80,7 @@ fn main() -> Result<()> { metadata_directory.as_deref(), uv_version::version(), false, + preview, )?; // Tell the build frontend about the name of the artifact we built writeln!(&mut std::io::stdout(), "{filename}").context("stdout is closed")?; @@ -71,6 +94,7 @@ fn main() -> Result<()> { metadata_directory.as_deref(), uv_version::version(), false, + preview, )?; // Tell the build frontend about the name of the artifact we built writeln!(&mut std::io::stdout(), "{filename}").context("stdout is closed")?; @@ -81,6 +105,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")?; @@ -91,6 +116,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 afcf38bd1..d55a63031 100644 --- a/crates/uv-dispatch/src/lib.rs +++ b/crates/uv-dispatch/src/lib.rs @@ -526,6 +526,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 => { @@ -535,6 +536,7 @@ impl BuildContext for BuildDispatch<'_> { None, uv_version::version(), sources == SourceStrategy::Enabled, + preview, )?; DistFilename::WheelFilename(wheel) } @@ -554,6 +556,7 @@ impl BuildContext for BuildDispatch<'_> { None, uv_version::version(), sources == SourceStrategy::Enabled, + preview, )?; DistFilename::WheelFilename(wheel) } diff --git a/crates/uv-preview/src/lib.rs b/crates/uv-preview/src/lib.rs index 8e3fa788a..35232af85 100644 --- a/crates/uv-preview/src/lib.rs +++ b/crates/uv-preview/src/lib.rs @@ -29,6 +29,7 @@ bitflags::bitflags! { const AUTH_HELPER = 1 << 17; const DIRECT_PUBLISH = 1 << 18; const TARGET_WORKSPACE_DISCOVERY = 1 << 19; + const METADATA_JSON = 1 << 20; } } @@ -58,6 +59,7 @@ impl PreviewFeatures { Self::AUTH_HELPER => "auth-helper", Self::DIRECT_PUBLISH => "direct-publish", Self::TARGET_WORKSPACE_DISCOVERY => "target-workspace-discovery", + Self::METADATA_JSON => "metadata-json", _ => panic!("`flag_as_str` can only be used for exactly one feature flag"), } } @@ -115,6 +117,7 @@ impl FromStr for PreviewFeatures { "auth-helper" => Self::AUTH_HELPER, "direct-publish" => Self::DIRECT_PUBLISH, "target-workspace-discovery" => Self::TARGET_WORKSPACE_DISCOVERY, + "metadata-json" => Self::METADATA_JSON, _ => { warn_user_once!("Unknown preview feature: `{part}`"); continue; @@ -299,6 +302,10 @@ mod tests { PreviewFeatures::TARGET_WORKSPACE_DISCOVERY.flag_as_str(), "target-workspace-discovery" ); + assert_eq!( + PreviewFeatures::METADATA_JSON.flag_as_str(), + "metadata-json" + ); } #[test] diff --git a/crates/uv-publish/src/lib.rs b/crates/uv-publish/src/lib.rs index 6d21d3cca..b1a5a73df 100644 --- a/crates/uv-publish/src/lib.rs +++ b/crates/uv-publish/src/lib.rs @@ -1156,7 +1156,7 @@ impl FormMetadata { add_option("description_content_type", description_content_type); add_option("download_url", download_url); add_option("home_page", home_page); - add_option("keywords", keywords); + add_option("keywords", keywords.map(|keywords| keywords.as_metadata())); add_option("license", license); add_option("license_expression", license_expression); add_option("maintainer", maintainer); @@ -1178,7 +1178,7 @@ impl FormMetadata { add_vec("license_file", license_files); add_vec("obsoletes_dist", obsoletes_dist); add_vec("platform", platforms); - add_vec("project_urls", project_urls); + add_vec("project_urls", project_urls.to_vec_str()); add_vec("provides_dist", provides_dist); add_vec("provides_extra", provides_extra); add_vec("requires_dist", requires_dist); diff --git a/crates/uv-pypi-types/src/metadata/metadata23.rs b/crates/uv-pypi-types/src/metadata/metadata23.rs index 79c2043fc..72b3ee90a 100644 --- a/crates/uv-pypi-types/src/metadata/metadata23.rs +++ b/crates/uv-pypi-types/src/metadata/metadata23.rs @@ -1,16 +1,18 @@ //! Vendored from - use std::fmt::Display; use std::fmt::Write; use std::str; use std::str::FromStr; +use indexmap::IndexMap; + use crate::MetadataError; 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 = "snake_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`. @@ -36,7 +38,7 @@ pub struct Metadata23 { pub description_content_type: Option, /// A list of additional keywords, separated by commas, to be used to /// assist searching for the distribution in a larger catalog. - pub keywords: Option, + pub keywords: Option, /// A string containing the URL for the distribution's home page. /// /// Deprecated by PEP 753. @@ -95,7 +97,7 @@ pub struct Metadata23 { pub requires_external: Vec, /// A string containing a browsable URL for the project and a label for it, separated by a /// comma. - pub project_urls: Vec, + pub project_urls: ProjectUrls, /// A string containing the name of an optional feature. Must be a valid Python identifier. /// May be used to make a dependency conditional on whether the optional feature has been /// requested. @@ -128,7 +130,10 @@ impl Metadata23 { } else { Some(body.to_string()) }; - let keywords = headers.get_first_value("Keywords"); + let keywords = headers + .get_first_value("Keywords") + .as_deref() + .map(Keywords::from_metadata); let home_page = headers.get_first_value("Home-Page"); let download_url = headers.get_first_value("Download-URL"); let author = headers.get_first_value("Author"); @@ -144,7 +149,7 @@ impl Metadata23 { let maintainer_email = headers.get_first_value("Maintainer-email"); let requires_python = headers.get_first_value("Requires-Python"); let requires_external = headers.get_all_values("Requires-External").collect(); - let project_urls = headers.get_all_values("Project-URL").collect(); + let project_urls = ProjectUrls::from_iter_str(headers.get_all_values("Project-URL")); let provides_extra = headers.get_all_values("Provides-Extra").collect(); let description_content_type = headers.get_first_value("Description-Content-Type"); let dynamic = headers.get_all_values("Dynamic").collect(); @@ -235,7 +240,11 @@ impl Metadata23 { write_all(&mut writer, "Platform", &self.platforms); write_all(&mut writer, "Supported-Platform", &self.supported_platforms); write_all(&mut writer, "Summary", &self.summary); - write_opt_str(&mut writer, "Keywords", self.keywords.as_ref()); + write_opt_str( + &mut writer, + "Keywords", + self.keywords.as_ref().map(Keywords::as_metadata).as_ref(), + ); write_opt_str(&mut writer, "Home-Page", self.home_page.as_ref()); write_opt_str(&mut writer, "Download-URL", self.download_url.as_ref()); write_opt_str(&mut writer, "Author", self.author.as_ref()); @@ -263,7 +272,7 @@ impl Metadata23 { self.requires_python.as_ref(), ); write_all(&mut writer, "Requires-External", &self.requires_external); - write_all(&mut writer, "Project-URL", &self.project_urls); + write_all(&mut writer, "Project-URL", self.project_urls.to_vec_str()); write_all(&mut writer, "Provides-Extra", &self.provides_extra); write_opt_str( &mut writer, @@ -288,6 +297,68 @@ impl FromStr for Metadata23 { } } +/// Handle the different keywords representation between `METADATA` and `METADATA.json`. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct Keywords(Vec); + +impl Keywords { + pub fn new(keywords: Vec) -> Self { + Self(keywords) + } + + /// Read the `METADATA` format. + pub fn from_metadata(keywords: &str) -> Self { + Self(keywords.split(',').map(ToString::to_string).collect()) + } + + /// Write the `METADATA` format. + pub fn as_metadata(&self) -> String { + let mut keywords = self.0.iter(); + let mut rendered = String::new(); + if let Some(keyword) = keywords.next() { + rendered.push_str(keyword); + } + for keyword in keywords { + rendered.push(','); + rendered.push_str(keyword); + } + rendered + } +} + +/// Handle the different project URLs representation between `METADATA` and `METADATA.json`. +#[derive(Debug, Default, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct ProjectUrls(IndexMap); + +impl ProjectUrls { + pub fn new(project_urls: IndexMap) -> Self { + Self(project_urls) + } + + /// Read the `METADATA` format. + pub fn from_iter_str(project_urls: impl IntoIterator) -> Self { + Self( + project_urls + .into_iter() + .map(|project_url| { + let (label, url) = project_url.split_once(',').unwrap_or((&project_url, "")); + // TODO(konsti): The spec says separated by comma, but it's actually comma and a + // space. + (label.trim().to_string(), url.trim().to_string()) + }) + .collect(), + ) + } + + /// Write the `METADATA` format. + pub fn to_vec_str(&self) -> Vec { + self.0 + .iter() + .map(|(label, url)| format!("{label}, {url}")) + .collect() + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/uv-pypi-types/src/metadata/mod.rs b/crates/uv-pypi-types/src/metadata/mod.rs index 0ef7f2321..3bf738b82 100644 --- a/crates/uv-pypi-types/src/metadata/mod.rs +++ b/crates/uv-pypi-types/src/metadata/mod.rs @@ -20,7 +20,7 @@ use crate::VerbatimParsedUrl; pub use build_requires::BuildRequires; pub use metadata_resolver::ResolutionMetadata; pub use metadata10::Metadata10; -pub use metadata23::Metadata23; +pub use metadata23::{Keywords, Metadata23, ProjectUrls}; pub use pyproject_toml::PyProjectToml; pub use requires_dist::RequiresDist; pub use requires_txt::RequiresTxt; diff --git a/crates/uv-settings/src/lib.rs b/crates/uv-settings/src/lib.rs index b25000582..6ee598e7a 100644 --- a/crates/uv-settings/src/lib.rs +++ b/crates/uv-settings/src/lib.rs @@ -6,7 +6,7 @@ use std::time::Duration; use uv_dirs::{system_config_file, user_config_dir}; use uv_flags::EnvironmentFlags; 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::*; @@ -579,12 +579,8 @@ pub enum Error { )] 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), } #[derive(Copy, Clone, Debug)] @@ -726,58 +722,6 @@ impl EnvironmentOptions { } } -/// 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, 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)) -} - /// Parse a string environment variable. fn parse_string_environment_variable(name: &'static str) -> Result, Error> { match std::env::var(name) { @@ -790,11 +734,13 @@ fn parse_string_environment_variable(name: &'static str) -> Result match e { std::env::VarError::NotPresent => Ok(None), - std::env::VarError::NotUnicode(err) => Err(Error::InvalidEnvironmentVariable { - name: name.to_string(), - value: err.to_string_lossy().to_string(), - err: "expected a valid UTF-8 string".to_string(), - }), + std::env::VarError::NotUnicode(err) => Err(Error::InvalidEnvironmentVariable( + InvalidEnvironmentVariable { + name: name.to_string(), + value: err.to_string_lossy().to_string(), + err: "expected a valid UTF-8 string".to_string(), + }, + )), }, } } @@ -809,11 +755,13 @@ where Err(e) => { return match e { std::env::VarError::NotPresent => Ok(None), - std::env::VarError::NotUnicode(err) => Err(Error::InvalidEnvironmentVariable { - name: name.to_string(), - value: err.to_string_lossy().to_string(), - err: "expected a valid UTF-8 string".to_string(), - }), + std::env::VarError::NotUnicode(err) => Err(Error::InvalidEnvironmentVariable( + InvalidEnvironmentVariable { + name: name.to_string(), + value: err.to_string_lossy().to_string(), + err: "expected a valid UTF-8 string".to_string(), + }, + )), }; } }; @@ -823,11 +771,13 @@ where match value.parse::() { Ok(v) => Ok(Some(v)), - Err(err) => Err(Error::InvalidEnvironmentVariable { - name: name.to_string(), - value, - err: err.to_string(), - }), + Err(err) => Err(Error::InvalidEnvironmentVariable( + InvalidEnvironmentVariable { + name: name.to_string(), + value, + err: err.to_string(), + }, + )), } } diff --git a/crates/uv-static/Cargo.toml b/crates/uv-static/Cargo.toml index 6f7819cd8..823a7244d 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..2a347d4c7 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 { + pub name: String, + pub value: String, + pub 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-trampoline/Cargo.lock b/crates/uv-trampoline/Cargo.lock index 23bb337c1..62975b9b1 100644 --- a/crates/uv-trampoline/Cargo.lock +++ b/crates/uv-trampoline/Cargo.lock @@ -71,6 +71,26 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "thiserror" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.87", +] + [[package]] name = "ufmt" version = "0.2.0" @@ -130,6 +150,7 @@ dependencies = [ name = "uv-static" version = "0.0.14" dependencies = [ + "thiserror", "uv-macros", ] diff --git a/crates/uv/src/commands/build_backend.rs b/crates/uv/src/commands/build_backend.rs index c9f4e2ed5..cf4dc3ce7 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 { @@ -21,6 +22,7 @@ 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()?, @@ -28,6 +30,7 @@ pub(crate) fn build_wheel( metadata_directory, uv_version::version(), false, + preview, )?; // Tell the build frontend about the name of the artifact we built writeln!(&mut std::io::stdout(), "{filename}").context("stdout is closed")?; @@ -38,6 +41,7 @@ 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()?, @@ -45,6 +49,7 @@ pub(crate) fn build_editable( metadata_directory, uv_version::version(), false, + preview, )?; // Tell the build frontend about the name of the artifact we built writeln!(&mut std::io::stdout(), "{filename}").context("stdout is closed")?; @@ -62,11 +67,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")?; @@ -79,11 +88,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 099ece65e..5ca26744b 100644 --- a/crates/uv/src/commands/build_frontend.rs +++ b/crates/uv/src/commands/build_frontend.rs @@ -742,6 +742,7 @@ async fn build_package( version_id, build_output, Some(sdist_build.normalized_filename().version()), + preview, ) .await?; build_results.push(wheel_build); @@ -779,6 +780,7 @@ async fn build_package( version_id, build_output, None, + preview, ) .await?; build_results.push(wheel_build); @@ -814,6 +816,7 @@ async fn build_package( version_id, build_output, Some(sdist_build.normalized_filename().version()), + preview, ) .await?; build_results.push(sdist_build); @@ -857,6 +860,7 @@ async fn build_package( version_id, build_output, version.as_ref(), + preview, ) .await?; build_results.push(wheel_build); @@ -1014,6 +1018,7 @@ 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 => { @@ -1023,6 +1028,7 @@ async fn build_wheel( &source_tree_, uv_version::version(), sources == SourceStrategy::Enabled, + preview, ) }) .await??; @@ -1054,6 +1060,7 @@ async fn build_wheel( None, uv_version::version(), sources == SourceStrategy::Enabled, + preview, ) }) .await??; diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index 74eaf3863..145380799 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -1877,6 +1877,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, @@ -1884,6 +1885,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() @@ -1892,13 +1894,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 diff --git a/crates/uv/tests/it/build_backend.rs b/crates/uv/tests/it/build_backend.rs index 0afa76bf6..07979f153 100644 --- a/crates/uv/tests/it/build_backend.rs +++ b/crates/uv/tests/it/build_backend.rs @@ -5,6 +5,7 @@ use assert_fs::fixture::{FileTouch, FileWriteBin, FileWriteStr, PathChild, PathC use flate2::bufread::GzDecoder; use fs_err::File; use indoc::{formatdoc, indoc}; +use insta::{assert_json_snapshot, assert_snapshot}; use std::env; use std::io::BufReader; use std::path::Path; @@ -1222,3 +1223,225 @@ fn invalid_pyproject_toml() -> Result<()> { Ok(()) } + +#[test] +fn build_with_all_metadata() -> 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" + description = "A Python package with all metadata fields" + readme = "Readme.md" + requires-python = ">=3.12" + license = "MIT OR Apache-2.0" + license-files = ["License*"] + authors = [ + {name = "Jane Doe", email = "jane@example.com"}, + {name = "John Doe"}, + {email = "info@example.com"}, + ] + maintainers = [ + {name = "ferris", email = "ferris@example.com"}, + ] + keywords = ["example", "test", "metadata"] + classifiers = [ + "Development Status :: 4 - Beta", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.12", + ] + dependencies = [ + "anyio>=4,<5", + ] + + [project.optional-dependencies] + dev = ["pytest>=7.0"] + + [project.urls] + Homepage = "https://octocat.github.io/spoon-knife" + Repository = "https://github.com/octocat/Spoon-Knife" + Changelog = "https://github.com/octocat/Spoon-Knife/blob/main/CHANGELOG.md" + + [project.scripts] + foo-cli = "foo:main" + + [project.gui-scripts] + foo-gui = "foo:gui_main" + + [project.entry-points."foo.plugins"] + bar = "foo:bar_plugin" + + [build-system] + requires = ["uv_build>=0.7,<10000"] + build-backend = "uv_build" + "#})?; + context + .temp_dir + .child("src/foo/__init__.py") + .write_str(indoc! {r#" + def main(): + print("Hello from foo!") + + def gui_main(): + print("GUI main") + + def bar_plugin(): + pass + "#})?; + context + .temp_dir + .child("License.txt") + .write_str("MIT License")?; + context + .temp_dir + .child("Readme.md") + .write_str("Hello World!")?; + + uv_snapshot!(context + .build_backend() + .arg("build-wheel") + .arg("--preview-features") + .arg("metadata-json") + .arg(temp_dir.path()), @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(); + + let metadata = fs_err::read_to_string( + context + .site_packages() + .join("foo-1.0.0.dist-info") + .join("METADATA"), + )?; + assert_snapshot!(metadata, @r" + Metadata-Version: 2.4 + Name: foo + Version: 1.0.0 + Summary: A Python package with all metadata fields + Keywords: example,test,metadata + Author: Jane Doe, John Doe + Author-email: Jane Doe , info@example.com + License-Expression: MIT OR Apache-2.0 + License-File: License.txt + Classifier: Development Status :: 4 - Beta + Classifier: Programming Language :: Python :: 3 + Classifier: Programming Language :: Python :: 3.12 + Requires-Dist: anyio>=4,<5 + Requires-Dist: pytest>=7.0 ; extra == 'dev' + Maintainer: ferris + Maintainer-email: ferris + Requires-Python: >=3.12 + Project-URL: Homepage, https://octocat.github.io/spoon-knife + Project-URL: Repository, https://github.com/octocat/Spoon-Knife + Project-URL: Changelog, https://github.com/octocat/Spoon-Knife/blob/main/CHANGELOG.md + Provides-Extra: dev + Description-Content-Type: text/markdown + + Hello World! + "); + let metadata_json = fs_err::read_to_string( + context + .site_packages() + .join("foo-1.0.0.dist-info") + .join("METADATA.json"), + )?; + let metadata_json: serde_json::Value = serde_json::from_str(&metadata_json)?; + assert_json_snapshot!(metadata_json, @r#" + { + "author": "Jane Doe, John Doe", + "author_email": "Jane Doe , info@example.com", + "classifiers": [ + "Development Status :: 4 - Beta", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.12" + ], + "description": "Hello World!", + "description_content_type": "text/markdown", + "download_url": null, + "dynamic": [], + "home_page": null, + "keywords": [ + "example", + "test", + "metadata" + ], + "license": null, + "license_expression": "MIT OR Apache-2.0", + "license_files": [ + "License.txt" + ], + "maintainer": "ferris", + "maintainer_email": "ferris ", + "metadata_version": "2.4", + "name": "foo", + "obsoletes_dist": [], + "platforms": [], + "project_urls": { + "Changelog": "https://github.com/octocat/Spoon-Knife/blob/main/CHANGELOG.md", + "Homepage": "https://octocat.github.io/spoon-knife", + "Repository": "https://github.com/octocat/Spoon-Knife" + }, + "provides_dist": [], + "provides_extra": [ + "dev" + ], + "requires_dist": [ + "anyio>=4,<5", + "pytest>=7.0 ; extra == 'dev'" + ], + "requires_external": [], + "requires_python": ">=3.12", + "summary": "A Python package with all metadata fields", + "supported_platforms": [], + "version": "1.0.0" + } + "#); + let wheel = fs_err::read_to_string( + context + .site_packages() + .join("foo-1.0.0.dist-info") + .join("WHEEL"), + )?; + let wheel = wheel.replace(uv_version::version(), "[VERSION]"); + assert_snapshot!(wheel, @r" + Wheel-Version: 1.0 + Generator: uv [VERSION] + Root-Is-Purelib: true + Tag: py3-none-any + "); + let wheel_json = fs_err::read_to_string( + context + .site_packages() + .join("foo-1.0.0.dist-info") + .join("WHEEL.json"), + )?; + let wheel_json = wheel_json.replace(uv_version::version(), "[VERSION]"); + let wheel_json: serde_json::Value = serde_json::from_str(&wheel_json)?; + assert_json_snapshot!(wheel_json, @r#" + { + "generator": "uv [VERSION]", + "root-is-purelib": true, + "tags": [ + "py3-none-any" + ], + "wheel-version": "1.0" + } + "#); + + Ok(()) +} diff --git a/crates/uv/tests/it/show_settings.rs b/crates/uv/tests/it/show_settings.rs index d540aa86b..5186a20ed 100644 --- a/crates/uv/tests/it/show_settings.rs +++ b/crates/uv/tests/it/show_settings.rs @@ -7982,7 +7982,7 @@ fn preview_features() { show_settings: true, preview: Preview { flags: PreviewFeatures( - PYTHON_INSTALL_DEFAULT | PYTHON_UPGRADE | JSON_OUTPUT | PYLOCK | ADD_BOUNDS | PACKAGE_CONFLICTS | EXTRA_BUILD_DEPENDENCIES | DETECT_MODULE_CONFLICTS | FORMAT | NATIVE_AUTH | S3_ENDPOINT | CACHE_SIZE | INIT_PROJECT_FLAG | WORKSPACE_METADATA | WORKSPACE_DIR | WORKSPACE_LIST | SBOM_EXPORT | AUTH_HELPER | DIRECT_PUBLISH | TARGET_WORKSPACE_DISCOVERY, + PYTHON_INSTALL_DEFAULT | PYTHON_UPGRADE | JSON_OUTPUT | PYLOCK | ADD_BOUNDS | PACKAGE_CONFLICTS | EXTRA_BUILD_DEPENDENCIES | DETECT_MODULE_CONFLICTS | FORMAT | NATIVE_AUTH | S3_ENDPOINT | CACHE_SIZE | INIT_PROJECT_FLAG | WORKSPACE_METADATA | WORKSPACE_DIR | WORKSPACE_LIST | SBOM_EXPORT | AUTH_HELPER | DIRECT_PUBLISH | TARGET_WORKSPACE_DISCOVERY | METADATA_JSON, ), }, python_preference: Managed, @@ -8220,7 +8220,7 @@ fn preview_features() { show_settings: true, preview: Preview { flags: PreviewFeatures( - PYTHON_INSTALL_DEFAULT | PYTHON_UPGRADE | JSON_OUTPUT | PYLOCK | ADD_BOUNDS | PACKAGE_CONFLICTS | EXTRA_BUILD_DEPENDENCIES | DETECT_MODULE_CONFLICTS | FORMAT | NATIVE_AUTH | S3_ENDPOINT | CACHE_SIZE | INIT_PROJECT_FLAG | WORKSPACE_METADATA | WORKSPACE_DIR | WORKSPACE_LIST | SBOM_EXPORT | AUTH_HELPER | DIRECT_PUBLISH | TARGET_WORKSPACE_DISCOVERY, + PYTHON_INSTALL_DEFAULT | PYTHON_UPGRADE | JSON_OUTPUT | PYLOCK | ADD_BOUNDS | PACKAGE_CONFLICTS | EXTRA_BUILD_DEPENDENCIES | DETECT_MODULE_CONFLICTS | FORMAT | NATIVE_AUTH | S3_ENDPOINT | CACHE_SIZE | INIT_PROJECT_FLAG | WORKSPACE_METADATA | WORKSPACE_DIR | WORKSPACE_LIST | SBOM_EXPORT | AUTH_HELPER | DIRECT_PUBLISH | TARGET_WORKSPACE_DISCOVERY | METADATA_JSON, ), }, python_preference: Managed,