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 <charlie.r.marsh@gmail.com>
This commit is contained in:
konsti
2026-01-14 19:52:09 +01:00
committed by GitHub
parent 9ea3466aba
commit 0ab603e035
21 changed files with 701 additions and 165 deletions

6
Cargo.lock generated
View File

@@ -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",
]

View File

@@ -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 }

View File

@@ -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<BuildResults, Error> {
fn build(source_root: &Path, dist: &Path, preview: Preview) -> Result<BuildResults, Error> {
// 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(&regular_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
");
}
}

View File

@@ -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: <https://github.com/pypi/warehouse/blob/main/warehouse/templates/packaging/detail.html>
/// archived: <https://github.com/pypi/warehouse/blob/e3bd3c3805ff47fff32b67a899c1ce11c16f3c31/warehouse/templates/packaging/detail.html>
urls: Option<BTreeMap<String, String>>,
urls: Option<IndexMap<String, String>>,
/// 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.

View File

@@ -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<WheelFilename, Error> {
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<WheelFilename, Error> {
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<String, Error> {
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<String, Error> {
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<String>,
}
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()

View File

@@ -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 }

View File

@@ -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")?;

View File

@@ -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)
}

View File

@@ -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]

View File

@@ -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);

View File

@@ -1,16 +1,18 @@
//! Vendored from <https://github.com/PyO3/python-pkginfo-rs>
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
/// <https://packaging.python.org/specifications/core-metadata/>.
#[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<String>,
/// A list of additional keywords, separated by commas, to be used to
/// assist searching for the distribution in a larger catalog.
pub keywords: Option<String>,
pub keywords: Option<Keywords>,
/// 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<String>,
/// A string containing a browsable URL for the project and a label for it, separated by a
/// comma.
pub project_urls: Vec<String>,
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<String>);
impl Keywords {
pub fn new(keywords: Vec<String>) -> 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<String, String>);
impl ProjectUrls {
pub fn new(project_urls: IndexMap<String, String>) -> Self {
Self(project_urls)
}
/// Read the `METADATA` format.
pub fn from_iter_str(project_urls: impl IntoIterator<Item = String>) -> 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<String> {
self.0
.iter()
.map(|(label, url)| format!("{label}, {url}"))
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -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;

View File

@@ -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<Option<bool>, 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<str>) -> Option<bool> {
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<Option<String>, Error> {
match std::env::var(name) {
@@ -790,11 +734,13 @@ fn parse_string_environment_variable(name: &'static str) -> Result<Option<String
}
Err(e) => 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::<T>() {
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(),
},
)),
}
}

View File

@@ -17,3 +17,5 @@ workspace = true
[dependencies]
uv-macros = { workspace = true }
thiserror = { workspace = true }

View File

@@ -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<Option<bool>, 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<str>) -> Option<bool> {
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))
}

View File

@@ -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",
]

View File

@@ -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<ExitStatus> {
@@ -21,6 +22,7 @@ pub(crate) fn build_sdist(sdist_directory: &Path) -> Result<ExitStatus> {
pub(crate) fn build_wheel(
wheel_directory: &Path,
metadata_directory: Option<&Path>,
preview: Preview,
) -> Result<ExitStatus> {
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<ExitStatus> {
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<ExitStatus> {
}
/// PEP 517 hook to just emit metadata through `.dist-info`.
pub(crate) fn prepare_metadata_for_build_wheel(metadata_directory: &Path) -> Result<ExitStatus> {
pub(crate) fn prepare_metadata_for_build_wheel(
metadata_directory: &Path,
preview: Preview,
) -> Result<ExitStatus> {
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<ExitStatus> {
}
/// PEP 660 hook to just emit metadata through `.dist-info`.
pub(crate) fn prepare_metadata_for_build_editable(metadata_directory: &Path) -> Result<ExitStatus> {
pub(crate) fn prepare_metadata_for_build_editable(
metadata_directory: &Path,
preview: Preview,
) -> Result<ExitStatus> {
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")?;

View File

@@ -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<BuildMessage, Error> {
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??;

View File

@@ -1877,6 +1877,7 @@ async fn run(mut cli: Cli) -> Result<ExitStatus> {
} => 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<ExitStatus> {
} => 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<ExitStatus> {
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

View File

@@ -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 <jane@example.com>, 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 <ferris@example.com>
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 <jane@example.com>, 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 <ferris@example.com>",
"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(())
}

View File

@@ -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,