Add support for Metadata 2.2 (#2293)

## Summary

PyPI now supports Metadata 2.2, which means distributions with Metadata
2.2-compliant metadata will start to appear. The upside is that if a
source distribution includes a `PKG-INFO` file with (1) a metadata
version of 2.2 or greater, and (2) no dynamic fields (at least, of the
fields we rely on), we can read the metadata from the `PKG-INFO` file
directly rather than running _any_ of the PEP 517 build hooks.

Closes https://github.com/astral-sh/uv/issues/2009.
This commit is contained in:
Charlie Marsh 2024-03-08 08:02:32 -08:00 committed by GitHub
parent 41c911fc41
commit 2e9678e5d3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 263 additions and 70 deletions

View file

@ -111,10 +111,10 @@ impl InstalledDist {
} }
/// Read the `METADATA` file from a `.dist-info` directory. /// Read the `METADATA` file from a `.dist-info` directory.
pub fn metadata(&self) -> Result<pypi_types::Metadata21> { pub fn metadata(&self) -> Result<pypi_types::Metadata23> {
let path = self.path().join("METADATA"); let path = self.path().join("METADATA");
let contents = fs::read(&path)?; let contents = fs::read(&path)?;
pypi_types::Metadata21::parse(&contents).with_context(|| { pypi_types::Metadata23::parse_metadata(&contents).with_context(|| {
format!( format!(
"Failed to parse METADATA file at: {}", "Failed to parse METADATA file at: {}",
path.simplified_display() path.simplified_display()

View file

@ -15,14 +15,16 @@ use uv_normalize::{ExtraName, InvalidNameError, PackageName};
use crate::lenient_requirement::LenientRequirement; use crate::lenient_requirement::LenientRequirement;
use crate::LenientVersionSpecifiers; use crate::LenientVersionSpecifiers;
/// Python Package Metadata 2.1 as specified in /// Python Package Metadata 2.3 as specified in
/// <https://packaging.python.org/specifications/core-metadata/>. /// <https://packaging.python.org/specifications/core-metadata/>.
/// ///
/// This is a subset of the full metadata specification, and only includes the /// This is a subset of the full metadata specification, and only includes the
/// fields that are relevant to dependency resolution. /// fields that are relevant to dependency resolution.
///
/// At present, we support up to version 2.3 of the metadata specification.
#[derive(Serialize, Deserialize, Debug, Clone, Eq, PartialEq)] #[derive(Serialize, Deserialize, Debug, Clone, Eq, PartialEq)]
#[serde(rename_all = "kebab-case")] #[serde(rename_all = "kebab-case")]
pub struct Metadata21 { pub struct Metadata23 {
// Mandatory fields // Mandatory fields
pub metadata_version: String, pub metadata_version: String,
pub name: PackageName, pub name: PackageName,
@ -70,12 +72,18 @@ pub enum Error {
Pep508Error(#[from] Pep508Error), Pep508Error(#[from] Pep508Error),
#[error(transparent)] #[error(transparent)]
InvalidName(#[from] InvalidNameError), InvalidName(#[from] InvalidNameError),
#[error("Invalid `Metadata-Version` field: {0}")]
InvalidMetadataVersion(String),
#[error("Reading metadata from `PKG-INFO` requires Metadata 2.2 or later (found: {0})")]
UnsupportedMetadataVersion(String),
#[error("The following field was marked as dynamic: {0}")]
DynamicField(&'static str),
} }
/// From <https://github.com/PyO3/python-pkginfo-rs/blob/d719988323a0cfea86d4737116d7917f30e819e2/src/metadata.rs#LL78C2-L91C26> /// From <https://github.com/PyO3/python-pkginfo-rs/blob/d719988323a0cfea86d4737116d7917f30e819e2/src/metadata.rs#LL78C2-L91C26>
impl Metadata21 { impl Metadata23 {
/// Parse distribution metadata from metadata bytes /// Parse the [`Metadata23`] from a `METADATA` file, as included in a built distribution (wheel).
pub fn parse(content: &[u8]) -> Result<Self, Error> { pub fn parse_metadata(content: &[u8]) -> Result<Self, Error> {
let headers = Headers::parse(content)?; let headers = Headers::parse(content)?;
let metadata_version = headers let metadata_version = headers
@ -124,13 +132,96 @@ impl Metadata21 {
provides_extras, provides_extras,
}) })
} }
/// Read the [`Metadata23`] from a source distribution's `PKG-INFO` file, if it uses Metadata 2.2
/// or later _and_ none of the required fields (`Requires-Python`, `Requires-Dist`, and
/// `Provides-Extra`) are marked as dynamic.
pub fn parse_pkg_info(content: &[u8]) -> Result<Self, Error> {
let headers = Headers::parse(content)?;
// To rely on a source distribution's `PKG-INFO` file, the `Metadata-Version` field must be
// present and set to a value of at least `2.2`.
let metadata_version = headers
.get_first_value("Metadata-Version")
.ok_or(Error::FieldNotFound("Metadata-Version"))?;
// Parse the version into (major, minor).
let (major, minor) = parse_version(&metadata_version)?;
if (major, minor) < (2, 2) || (major, minor) >= (3, 0) {
return Err(Error::UnsupportedMetadataVersion(metadata_version));
}
// If any of the fields we need are marked as dynamic, we can't use the `PKG-INFO` file.
let dynamic = headers.get_all_values("Dynamic").collect::<Vec<_>>();
for field in dynamic {
match field.as_str() {
"Requires-Python" => return Err(Error::DynamicField("Requires-Python")),
"Requires-Dist" => return Err(Error::DynamicField("Requires-Dist")),
"Provides-Extra" => return Err(Error::DynamicField("Provides-Extra")),
_ => (),
}
}
// The `Name` and `Version` fields are required, and can't be dynamic.
let name = PackageName::new(
headers
.get_first_value("Name")
.ok_or(Error::FieldNotFound("Name"))?,
)?;
let version = Version::from_str(
&headers
.get_first_value("Version")
.ok_or(Error::FieldNotFound("Version"))?,
)
.map_err(Error::Pep440VersionError)?;
// The remaining fields are required to be present.
let requires_dist = headers
.get_all_values("Requires-Dist")
.map(|requires_dist| {
LenientRequirement::from_str(&requires_dist).map(Requirement::from)
})
.collect::<Result<Vec<_>, _>>()?;
let requires_python = headers
.get_first_value("Requires-Python")
.map(|requires_python| {
LenientVersionSpecifiers::from_str(&requires_python).map(VersionSpecifiers::from)
})
.transpose()?;
let provides_extras = headers
.get_all_values("Provides-Extra")
.filter_map(|provides_extra| match ExtraName::new(provides_extra) {
Ok(extra_name) => Some(extra_name),
Err(err) => {
warn!("Ignoring invalid extra: {err}");
None
}
})
.collect::<Vec<_>>();
Ok(Self {
metadata_version,
name,
version,
requires_dist,
requires_python,
provides_extras,
})
}
} }
impl FromStr for Metadata21 { /// Parse a `Metadata-Version` field into a (major, minor) tuple.
type Err = Error; fn parse_version(metadata_version: &str) -> Result<(u8, u8), Error> {
fn from_str(s: &str) -> Result<Self, Self::Err> { let (major, minor) = metadata_version
Self::parse(s.as_bytes()) .split_once('.')
} .ok_or(Error::InvalidMetadataVersion(metadata_version.to_string()))?;
let major = major
.parse::<u8>()
.map_err(|_| Error::InvalidMetadataVersion(metadata_version.to_string()))?;
let minor = minor
.parse::<u8>()
.map_err(|_| Error::InvalidMetadataVersion(metadata_version.to_string()))?;
Ok((major, minor))
} }
/// The headers of a distribution metadata file. /// The headers of a distribution metadata file.
@ -174,38 +265,70 @@ mod tests {
use crate::Error; use crate::Error;
use super::Metadata21; use super::Metadata23;
#[test] #[test]
fn test_parse_from_str() { fn test_parse_metadata() {
let s = "Metadata-Version: 1.0"; let s = "Metadata-Version: 1.0";
let meta: Result<Metadata21, Error> = s.parse(); let meta = Metadata23::parse_metadata(s.as_bytes());
assert!(matches!(meta, Err(Error::FieldNotFound("Name")))); assert!(matches!(meta, Err(Error::FieldNotFound("Name"))));
let s = "Metadata-Version: 1.0\nName: asdf"; let s = "Metadata-Version: 1.0\nName: asdf";
let meta = Metadata21::parse(s.as_bytes()); let meta = Metadata23::parse_metadata(s.as_bytes());
assert!(matches!(meta, Err(Error::FieldNotFound("Version")))); assert!(matches!(meta, Err(Error::FieldNotFound("Version"))));
let s = "Metadata-Version: 1.0\nName: asdf\nVersion: 1.0"; let s = "Metadata-Version: 1.0\nName: asdf\nVersion: 1.0";
let meta = Metadata21::parse(s.as_bytes()).unwrap(); let meta = Metadata23::parse_metadata(s.as_bytes()).unwrap();
assert_eq!(meta.metadata_version, "1.0"); assert_eq!(meta.metadata_version, "1.0");
assert_eq!(meta.name, PackageName::from_str("asdf").unwrap()); assert_eq!(meta.name, PackageName::from_str("asdf").unwrap());
assert_eq!(meta.version, Version::new([1, 0])); assert_eq!(meta.version, Version::new([1, 0]));
let s = "Metadata-Version: 1.0\nName: asdf\nVersion: 1.0\nAuthor: 中文\n\n一个 Python 包"; let s = "Metadata-Version: 1.0\nName: asdf\nVersion: 1.0\nAuthor: 中文\n\n一个 Python 包";
let meta = Metadata21::parse(s.as_bytes()).unwrap(); let meta = Metadata23::parse_metadata(s.as_bytes()).unwrap();
assert_eq!(meta.metadata_version, "1.0"); assert_eq!(meta.metadata_version, "1.0");
assert_eq!(meta.name, PackageName::from_str("asdf").unwrap()); assert_eq!(meta.name, PackageName::from_str("asdf").unwrap());
assert_eq!(meta.version, Version::new([1, 0])); assert_eq!(meta.version, Version::new([1, 0]));
let s = "Metadata-Version: 1.0\nName: =?utf-8?q?foobar?=\nVersion: 1.0"; let s = "Metadata-Version: 1.0\nName: =?utf-8?q?foobar?=\nVersion: 1.0";
let meta = Metadata21::parse(s.as_bytes()).unwrap(); let meta = Metadata23::parse_metadata(s.as_bytes()).unwrap();
assert_eq!(meta.metadata_version, "1.0"); assert_eq!(meta.metadata_version, "1.0");
assert_eq!(meta.name, PackageName::from_str("foobar").unwrap()); assert_eq!(meta.name, PackageName::from_str("foobar").unwrap());
assert_eq!(meta.version, Version::new([1, 0])); assert_eq!(meta.version, Version::new([1, 0]));
let s = "Metadata-Version: 1.0\nName: =?utf-8?q?=C3=A4_space?= <x@y.org>\nVersion: 1.0"; let s = "Metadata-Version: 1.0\nName: =?utf-8?q?=C3=A4_space?= <x@y.org>\nVersion: 1.0";
let meta = Metadata21::parse(s.as_bytes()); let meta = Metadata23::parse_metadata(s.as_bytes());
assert!(matches!(meta, Err(Error::InvalidName(_)))); assert!(matches!(meta, Err(Error::InvalidName(_))));
} }
#[test]
fn test_parse_pkg_info() {
let s = "Metadata-Version: 2.1";
let meta = Metadata23::parse_pkg_info(s.as_bytes());
assert!(matches!(meta, Err(Error::UnsupportedMetadataVersion(_))));
let s = "Metadata-Version: 2.2\nName: asdf";
let meta = Metadata23::parse_pkg_info(s.as_bytes());
assert!(matches!(meta, Err(Error::FieldNotFound("Version"))));
let s = "Metadata-Version: 2.3\nName: asdf";
let meta = Metadata23::parse_pkg_info(s.as_bytes());
assert!(matches!(meta, Err(Error::FieldNotFound("Version"))));
let s = "Metadata-Version: 2.3\nName: asdf\nVersion: 1.0";
let meta = Metadata23::parse_pkg_info(s.as_bytes()).unwrap();
assert_eq!(meta.metadata_version, "2.3");
assert_eq!(meta.name, PackageName::from_str("asdf").unwrap());
assert_eq!(meta.version, Version::new([1, 0]));
let s = "Metadata-Version: 2.3\nName: asdf\nVersion: 1.0\nDynamic: Requires-Dist";
let meta = Metadata23::parse_pkg_info(s.as_bytes()).unwrap_err();
assert!(matches!(meta, Error::DynamicField("Requires-Dist")));
let s = "Metadata-Version: 2.3\nName: asdf\nVersion: 1.0\nRequires-Dist: foo";
let meta = Metadata23::parse_pkg_info(s.as_bytes()).unwrap();
assert_eq!(meta.metadata_version, "2.3");
assert_eq!(meta.name, PackageName::from_str("asdf").unwrap());
assert_eq!(meta.version, Version::new([1, 0]));
assert_eq!(meta.requires_dist, vec!["foo".parse().unwrap()]);
}
} }

View file

@ -21,7 +21,7 @@ use distribution_filename::{DistFilename, SourceDistFilename, WheelFilename};
use distribution_types::{BuiltDist, File, FileLocation, IndexUrl, IndexUrls, Name}; use distribution_types::{BuiltDist, File, FileLocation, IndexUrl, IndexUrls, Name};
use install_wheel_rs::{find_dist_info, is_metadata_entry}; use install_wheel_rs::{find_dist_info, is_metadata_entry};
use pep440_rs::Version; use pep440_rs::Version;
use pypi_types::{Metadata21, SimpleJson}; use pypi_types::{Metadata23, SimpleJson};
use uv_auth::safe_copy_url_auth; use uv_auth::safe_copy_url_auth;
use uv_cache::{Cache, CacheBucket, WheelCache}; use uv_cache::{Cache, CacheBucket, WheelCache};
use uv_normalize::PackageName; use uv_normalize::PackageName;
@ -343,7 +343,7 @@ impl RegistryClient {
/// 2. From a remote wheel by partial zip reading /// 2. From a remote wheel by partial zip reading
/// 3. From a (temp) download of a remote wheel (this is a fallback, the webserver should support range requests) /// 3. From a (temp) download of a remote wheel (this is a fallback, the webserver should support range requests)
#[instrument(skip_all, fields(% built_dist))] #[instrument(skip_all, fields(% built_dist))]
pub async fn wheel_metadata(&self, built_dist: &BuiltDist) -> Result<Metadata21, Error> { pub async fn wheel_metadata(&self, built_dist: &BuiltDist) -> Result<Metadata23, Error> {
let metadata = match &built_dist { let metadata = match &built_dist {
BuiltDist::Registry(wheel) => match &wheel.file.url { BuiltDist::Registry(wheel) => match &wheel.file.url {
FileLocation::RelativeUrl(base, url) => { FileLocation::RelativeUrl(base, url) => {
@ -399,7 +399,7 @@ impl RegistryClient {
index: &IndexUrl, index: &IndexUrl,
file: &File, file: &File,
url: &Url, url: &Url,
) -> Result<Metadata21, Error> { ) -> Result<Metadata23, Error> {
// If the metadata file is available at its own url (PEP 658), download it from there. // If the metadata file is available at its own url (PEP 658), download it from there.
let filename = WheelFilename::from_str(&file.filename).map_err(ErrorKind::WheelFilename)?; let filename = WheelFilename::from_str(&file.filename).map_err(ErrorKind::WheelFilename)?;
if file if file
@ -428,7 +428,7 @@ impl RegistryClient {
let bytes = response.bytes().await.map_err(ErrorKind::from)?; let bytes = response.bytes().await.map_err(ErrorKind::from)?;
info_span!("parse_metadata21") info_span!("parse_metadata21")
.in_scope(|| Metadata21::parse(bytes.as_ref())) .in_scope(|| Metadata23::parse_metadata(bytes.as_ref()))
.map_err(|err| { .map_err(|err| {
Error::from(ErrorKind::MetadataParseError( Error::from(ErrorKind::MetadataParseError(
filename, filename,
@ -462,7 +462,7 @@ impl RegistryClient {
filename: &'data WheelFilename, filename: &'data WheelFilename,
url: &'data Url, url: &'data Url,
cache_shard: WheelCache<'data>, cache_shard: WheelCache<'data>,
) -> Result<Metadata21, Error> { ) -> Result<Metadata23, Error> {
let cache_entry = self.cache.entry( let cache_entry = self.cache.entry(
CacheBucket::Wheels, CacheBucket::Wheels,
cache_shard.remote_wheel_dir(filename.name.as_ref()), cache_shard.remote_wheel_dir(filename.name.as_ref()),
@ -507,14 +507,14 @@ impl RegistryClient {
.map_err(ErrorKind::AsyncHttpRangeReader)?; .map_err(ErrorKind::AsyncHttpRangeReader)?;
trace!("Getting metadata for {filename} by range request"); trace!("Getting metadata for {filename} by range request");
let text = wheel_metadata_from_remote_zip(filename, &mut reader).await?; let text = wheel_metadata_from_remote_zip(filename, &mut reader).await?;
let metadata = Metadata21::parse(text.as_bytes()).map_err(|err| { let metadata = Metadata23::parse_metadata(text.as_bytes()).map_err(|err| {
Error::from(ErrorKind::MetadataParseError( Error::from(ErrorKind::MetadataParseError(
filename.clone(), filename.clone(),
url.to_string(), url.to_string(),
Box::new(err), Box::new(err),
)) ))
})?; })?;
Ok::<Metadata21, CachedClientError<Error>>(metadata) Ok::<Metadata23, CachedClientError<Error>>(metadata)
} }
.boxed() .boxed()
.instrument(info_span!("read_metadata_range_request", wheel = %filename)) .instrument(info_span!("read_metadata_range_request", wheel = %filename))
@ -591,7 +591,7 @@ async fn read_metadata_async_seek(
filename: &WheelFilename, filename: &WheelFilename,
debug_source: String, debug_source: String,
reader: impl tokio::io::AsyncRead + tokio::io::AsyncSeek + Unpin, reader: impl tokio::io::AsyncRead + tokio::io::AsyncSeek + Unpin,
) -> Result<Metadata21, Error> { ) -> Result<Metadata23, Error> {
let mut zip_reader = async_zip::tokio::read::seek::ZipFileReader::with_tokio(reader) let mut zip_reader = async_zip::tokio::read::seek::ZipFileReader::with_tokio(reader)
.await .await
.map_err(|err| ErrorKind::Zip(filename.clone(), err))?; .map_err(|err| ErrorKind::Zip(filename.clone(), err))?;
@ -617,7 +617,7 @@ async fn read_metadata_async_seek(
.await .await
.map_err(|err| ErrorKind::Zip(filename.clone(), err))?; .map_err(|err| ErrorKind::Zip(filename.clone(), err))?;
let metadata = Metadata21::parse(&contents).map_err(|err| { let metadata = Metadata23::parse_metadata(&contents).map_err(|err| {
ErrorKind::MetadataParseError(filename.clone(), debug_source, Box::new(err)) ErrorKind::MetadataParseError(filename.clone(), debug_source, Box::new(err))
})?; })?;
Ok(metadata) Ok(metadata)
@ -628,7 +628,7 @@ async fn read_metadata_async_stream<R: futures::AsyncRead + Unpin>(
filename: &WheelFilename, filename: &WheelFilename,
debug_source: String, debug_source: String,
reader: R, reader: R,
) -> Result<Metadata21, Error> { ) -> Result<Metadata23, Error> {
let mut zip = async_zip::base::read::stream::ZipFileReader::new(reader); let mut zip = async_zip::base::read::stream::ZipFileReader::new(reader);
while let Some(mut entry) = zip while let Some(mut entry) = zip
@ -649,7 +649,7 @@ async fn read_metadata_async_stream<R: futures::AsyncRead + Unpin>(
let mut contents = Vec::new(); let mut contents = Vec::new();
reader.read_to_end(&mut contents).await.unwrap(); reader.read_to_end(&mut contents).await.unwrap();
let metadata = Metadata21::parse(&contents).map_err(|err| { let metadata = Metadata23::parse_metadata(&contents).map_err(|err| {
ErrorKind::MetadataParseError(filename.clone(), debug_source, Box::new(err)) ErrorKind::MetadataParseError(filename.clone(), debug_source, Box::new(err))
})?; })?;
return Ok(metadata); return Ok(metadata);

View file

@ -12,7 +12,7 @@ use distribution_types::{
BuiltDist, DirectGitUrl, Dist, FileLocation, IndexLocations, LocalEditable, Name, SourceDist, BuiltDist, DirectGitUrl, Dist, FileLocation, IndexLocations, LocalEditable, Name, SourceDist,
}; };
use platform_tags::Tags; use platform_tags::Tags;
use pypi_types::Metadata21; use pypi_types::Metadata23;
use uv_cache::{ArchiveTarget, ArchiveTimestamp, Cache, CacheBucket, WheelCache}; use uv_cache::{ArchiveTarget, ArchiveTimestamp, Cache, CacheBucket, WheelCache};
use uv_client::{CacheControl, CachedClientError, Connectivity, RegistryClient}; use uv_client::{CacheControl, CachedClientError, Connectivity, RegistryClient};
@ -344,14 +344,14 @@ impl<'a, Context: BuildContext + Send + Sync> DistributionDatabase<'a, Context>
/// Either fetch the only wheel metadata (directly from the index or with range requests) or /// Either fetch the only wheel metadata (directly from the index or with range requests) or
/// fetch and build the source distribution. /// fetch and build the source distribution.
/// ///
/// Returns the [`Metadata21`], along with a "precise" URL for the source distribution, if /// Returns the [`Metadata23`], along with a "precise" URL for the source distribution, if
/// possible. For example, given a Git dependency with a reference to a branch or tag, return a /// possible. For example, given a Git dependency with a reference to a branch or tag, return a
/// URL with a precise reference to the current commit of that branch or tag. /// URL with a precise reference to the current commit of that branch or tag.
#[instrument(skip_all, fields(%dist))] #[instrument(skip_all, fields(%dist))]
pub async fn get_or_build_wheel_metadata( pub async fn get_or_build_wheel_metadata(
&self, &self,
dist: &Dist, dist: &Dist,
) -> Result<(Metadata21, Option<Url>), Error> { ) -> Result<(Metadata23, Option<Url>), Error> {
match dist { match dist {
Dist::Built(built_dist) => { Dist::Built(built_dist) => {
Ok((self.client.wheel_metadata(built_dist).boxed().await?, None)) Ok((self.client.wheel_metadata(built_dist).boxed().await?, None))
@ -393,7 +393,7 @@ impl<'a, Context: BuildContext + Send + Sync> DistributionDatabase<'a, Context>
&self, &self,
editable: &LocalEditable, editable: &LocalEditable,
editable_wheel_dir: &Path, editable_wheel_dir: &Path,
) -> Result<(LocalWheel, Metadata21), Error> { ) -> Result<(LocalWheel, Metadata23), Error> {
let (dist, disk_filename, filename, metadata) = self let (dist, disk_filename, filename, metadata) = self
.builder .builder
.build_editable(editable, editable_wheel_dir) .build_editable(editable, editable_wheel_dir)

View file

@ -59,6 +59,10 @@ pub enum Error {
Extract(#[from] uv_extract::Error), Extract(#[from] uv_extract::Error),
#[error("Source distribution not found at: {0}")] #[error("Source distribution not found at: {0}")]
NotFound(PathBuf), NotFound(PathBuf),
#[error("The source distribution is missing a `PKG-INFO` file")]
MissingPkgInfo,
#[error("The source distribution does not support static metadata")]
DynamicPkgInfo(#[source] pypi_types::Error),
/// Should not occur; only seen when another task panicked. /// Should not occur; only seen when another task panicked.
#[error("The task executor is broken, did some other task panic?")] #[error("The task executor is broken, did some other task panic?")]

View file

@ -22,7 +22,7 @@ use distribution_types::{
use install_wheel_rs::read_dist_info; use install_wheel_rs::read_dist_info;
use pep508_rs::VerbatimUrl; use pep508_rs::VerbatimUrl;
use platform_tags::Tags; use platform_tags::Tags;
use pypi_types::Metadata21; use pypi_types::Metadata23;
use uv_cache::{ use uv_cache::{
ArchiveTimestamp, CacheBucket, CacheEntry, CacheShard, CachedByTimestamp, Freshness, WheelCache, ArchiveTimestamp, CacheBucket, CacheEntry, CacheShard, CachedByTimestamp, Freshness, WheelCache,
}; };
@ -174,7 +174,7 @@ impl<'a, T: BuildContext> SourceDistCachedBuilder<'a, T> {
pub async fn download_and_build_metadata( pub async fn download_and_build_metadata(
&self, &self,
source_dist: &SourceDist, source_dist: &SourceDist,
) -> Result<Metadata21, Error> { ) -> Result<Metadata23, Error> {
let metadata = match &source_dist { let metadata = match &source_dist {
SourceDist::DirectUrl(direct_url_source_dist) => { SourceDist::DirectUrl(direct_url_source_dist) => {
let filename = direct_url_source_dist let filename = direct_url_source_dist
@ -376,7 +376,7 @@ impl<'a, T: BuildContext> SourceDistCachedBuilder<'a, T> {
url: &'data Url, url: &'data Url,
cache_shard: &CacheShard, cache_shard: &CacheShard,
subdirectory: Option<&'data Path>, subdirectory: Option<&'data Path>,
) -> Result<Metadata21, Error> { ) -> Result<Metadata23, Error> {
let cache_entry = cache_shard.entry(MANIFEST); let cache_entry = cache_shard.entry(MANIFEST);
let cache_control = match self.client.connectivity() { let cache_control = match self.client.connectivity() {
Connectivity::Online => CacheControl::from( Connectivity::Online => CacheControl::from(
@ -564,7 +564,7 @@ impl<'a, T: BuildContext> SourceDistCachedBuilder<'a, T> {
source_dist: &SourceDist, source_dist: &SourceDist,
path_source_dist: &PathSourceDist, path_source_dist: &PathSourceDist,
source_root: &Path, source_root: &Path,
) -> Result<Metadata21, Error> { ) -> Result<Metadata23, Error> {
let cache_shard = self.build_context.cache().shard( let cache_shard = self.build_context.cache().shard(
CacheBucket::BuiltWheels, CacheBucket::BuiltWheels,
WheelCache::Path(&path_source_dist.url) WheelCache::Path(&path_source_dist.url)
@ -712,7 +712,7 @@ impl<'a, T: BuildContext> SourceDistCachedBuilder<'a, T> {
&self, &self,
source_dist: &SourceDist, source_dist: &SourceDist,
git_source_dist: &GitSourceDist, git_source_dist: &GitSourceDist,
) -> Result<Metadata21, Error> { ) -> Result<Metadata23, Error> {
let (fetch, subdirectory) = self.download_source_dist_git(&git_source_dist.url).await?; let (fetch, subdirectory) = self.download_source_dist_git(&git_source_dist.url).await?;
let git_sha = fetch.git().precise().expect("Exact commit after checkout"); let git_sha = fetch.git().precise().expect("Exact commit after checkout");
@ -913,7 +913,7 @@ impl<'a, T: BuildContext> SourceDistCachedBuilder<'a, T> {
source_dist: &Path, source_dist: &Path,
subdirectory: Option<&Path>, subdirectory: Option<&Path>,
cache_shard: &CacheShard, cache_shard: &CacheShard,
) -> Result<(String, WheelFilename, Metadata21), Error> { ) -> Result<(String, WheelFilename, Metadata23), Error> {
debug!("Building: {dist}"); debug!("Building: {dist}");
// Guard against build of source distributions when disabled // Guard against build of source distributions when disabled
@ -966,16 +966,37 @@ impl<'a, T: BuildContext> SourceDistCachedBuilder<'a, T> {
async fn build_source_dist_metadata( async fn build_source_dist_metadata(
&self, &self,
dist: &SourceDist, dist: &SourceDist,
source_dist: &Path, source_tree: &Path,
subdirectory: Option<&Path>, subdirectory: Option<&Path>,
) -> Result<Option<Metadata21>, Error> { ) -> Result<Option<Metadata23>, Error> {
debug!("Preparing metadata for: {dist}"); debug!("Preparing metadata for: {dist}");
// Attempt to read static metadata from the source distribution.
match read_pkg_info(source_tree).await {
Ok(metadata) => {
debug!("Found static metadata for: {dist}");
// Validate the metadata.
if &metadata.name != dist.name() {
return Err(Error::NameMismatch {
metadata: metadata.name,
given: dist.name().clone(),
});
}
return Ok(Some(metadata));
}
Err(err @ (Error::MissingPkgInfo | Error::DynamicPkgInfo(_))) => {
debug!("No static metadata available for: {dist} ({err:?})");
}
Err(err) => return Err(err),
}
// Setup the builder. // Setup the builder.
let mut builder = self let mut builder = self
.build_context .build_context
.setup_build( .setup_build(
source_dist, source_tree,
subdirectory, subdirectory,
&dist.to_string(), &dist.to_string(),
Some(dist), Some(dist),
@ -998,7 +1019,7 @@ impl<'a, T: BuildContext> SourceDistCachedBuilder<'a, T> {
let content = fs::read(dist_info.join("METADATA")) let content = fs::read(dist_info.join("METADATA"))
.await .await
.map_err(Error::CacheRead)?; .map_err(Error::CacheRead)?;
let metadata = Metadata21::parse(&content)?; let metadata = Metadata23::parse_metadata(&content)?;
// Validate the metadata. // Validate the metadata.
if &metadata.name != dist.name() { if &metadata.name != dist.name() {
@ -1016,7 +1037,7 @@ impl<'a, T: BuildContext> SourceDistCachedBuilder<'a, T> {
&self, &self,
editable: &LocalEditable, editable: &LocalEditable,
editable_wheel_dir: &Path, editable_wheel_dir: &Path,
) -> Result<(Dist, String, WheelFilename, Metadata21), Error> { ) -> Result<(Dist, String, WheelFilename, Metadata23), Error> {
debug!("Building (editable) {editable}"); debug!("Building (editable) {editable}");
// Verify that the editable exists. // Verify that the editable exists.
@ -1074,6 +1095,25 @@ impl ExtractedSource<'_> {
} }
} }
/// Read the [`Metadata23`] from a source distribution's `PKG-INFO` file, if it uses Metadata 2.2
/// or later _and_ none of the required fields (`Requires-Python`, `Requires-Dist`, and
/// `Provides-Extra`) are marked as dynamic.
pub(crate) async fn read_pkg_info(source_tree: &Path) -> Result<Metadata23, Error> {
// Read the `PKG-INFO` file.
let content = match fs::read(source_tree.join("PKG-INFO")).await {
Ok(content) => content,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
return Err(Error::MissingPkgInfo);
}
Err(err) => return Err(Error::CacheRead(err)),
};
// Parse the metadata.
let metadata = Metadata23::parse_pkg_info(&content).map_err(Error::DynamicPkgInfo)?;
Ok(metadata)
}
/// Read an existing HTTP-cached [`Manifest`], if it exists. /// Read an existing HTTP-cached [`Manifest`], if it exists.
pub(crate) fn read_http_manifest(cache_entry: &CacheEntry) -> Result<Option<Manifest>, Error> { pub(crate) fn read_http_manifest(cache_entry: &CacheEntry) -> Result<Option<Manifest>, Error> {
match fs_err::File::open(cache_entry.path()) { match fs_err::File::open(cache_entry.path()) {
@ -1139,25 +1179,25 @@ pub(crate) async fn refresh_timestamp_manifest(
Ok(manifest) Ok(manifest)
} }
/// Read an existing cached [`Metadata21`], if it exists. /// Read an existing cached [`Metadata23`], if it exists.
pub(crate) async fn read_cached_metadata( pub(crate) async fn read_cached_metadata(
cache_entry: &CacheEntry, cache_entry: &CacheEntry,
) -> Result<Option<Metadata21>, Error> { ) -> Result<Option<Metadata23>, Error> {
match fs::read(&cache_entry.path()).await { match fs::read(&cache_entry.path()).await {
Ok(cached) => Ok(Some(rmp_serde::from_slice::<Metadata21>(&cached)?)), Ok(cached) => Ok(Some(rmp_serde::from_slice::<Metadata23>(&cached)?)),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None), Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(err) => Err(Error::CacheRead(err)), Err(err) => Err(Error::CacheRead(err)),
} }
} }
/// Read the [`Metadata21`] from a built wheel. /// Read the [`Metadata23`] from a built wheel.
fn read_wheel_metadata( fn read_wheel_metadata(
filename: &WheelFilename, filename: &WheelFilename,
wheel: impl Into<PathBuf>, wheel: impl Into<PathBuf>,
) -> Result<Metadata21, Error> { ) -> Result<Metadata23, Error> {
let file = fs_err::File::open(wheel).map_err(Error::CacheRead)?; let file = fs_err::File::open(wheel).map_err(Error::CacheRead)?;
let reader = std::io::BufReader::new(file); let reader = std::io::BufReader::new(file);
let mut archive = ZipArchive::new(reader)?; let mut archive = ZipArchive::new(reader)?;
let dist_info = read_dist_info(filename, &mut archive)?; let dist_info = read_dist_info(filename, &mut archive)?;
Ok(Metadata21::parse(&dist_info)?) Ok(Metadata23::parse_metadata(&dist_info)?)
} }

View file

@ -4,7 +4,7 @@ use serde::Deserialize;
use distribution_types::{ use distribution_types::{
CachedDist, InstalledDist, InstalledMetadata, InstalledVersion, LocalEditable, Name, CachedDist, InstalledDist, InstalledMetadata, InstalledVersion, LocalEditable, Name,
}; };
use pypi_types::Metadata21; use pypi_types::Metadata23;
use requirements_txt::EditableRequirement; use requirements_txt::EditableRequirement;
use uv_normalize::PackageName; use uv_normalize::PackageName;
@ -14,7 +14,7 @@ use uv_normalize::PackageName;
pub struct BuiltEditable { pub struct BuiltEditable {
pub editable: LocalEditable, pub editable: LocalEditable,
pub wheel: CachedDist, pub wheel: CachedDist,
pub metadata: Metadata21, pub metadata: Metadata23,
} }
/// An editable distribution that has been resolved to a concrete distribution. /// An editable distribution that has been resolved to a concrete distribution.

View file

@ -3,16 +3,16 @@ use std::hash::BuildHasherDefault;
use rustc_hash::FxHashMap; use rustc_hash::FxHashMap;
use distribution_types::LocalEditable; use distribution_types::LocalEditable;
use pypi_types::Metadata21; use pypi_types::Metadata23;
use uv_normalize::PackageName; use uv_normalize::PackageName;
/// A set of editable packages, indexed by package name. /// A set of editable packages, indexed by package name.
#[derive(Debug, Default, Clone)] #[derive(Debug, Default, Clone)]
pub(crate) struct Editables(FxHashMap<PackageName, (LocalEditable, Metadata21)>); pub(crate) struct Editables(FxHashMap<PackageName, (LocalEditable, Metadata23)>);
impl Editables { impl Editables {
/// Create a new set of editables from a set of requirements. /// Create a new set of editables from a set of requirements.
pub(crate) fn from_requirements(requirements: Vec<(LocalEditable, Metadata21)>) -> Self { pub(crate) fn from_requirements(requirements: Vec<(LocalEditable, Metadata23)>) -> Self {
let mut editables = let mut editables =
FxHashMap::with_capacity_and_hasher(requirements.len(), BuildHasherDefault::default()); FxHashMap::with_capacity_and_hasher(requirements.len(), BuildHasherDefault::default());
for (editable_requirement, metadata) in requirements { for (editable_requirement, metadata) in requirements {
@ -22,12 +22,12 @@ impl Editables {
} }
/// Get the editable for a package. /// Get the editable for a package.
pub(crate) fn get(&self, name: &PackageName) -> Option<&(LocalEditable, Metadata21)> { pub(crate) fn get(&self, name: &PackageName) -> Option<&(LocalEditable, Metadata23)> {
self.0.get(name) self.0.get(name)
} }
/// Iterate over all editables. /// Iterate over all editables.
pub(crate) fn iter(&self) -> impl Iterator<Item = &(LocalEditable, Metadata21)> { pub(crate) fn iter(&self) -> impl Iterator<Item = &(LocalEditable, Metadata23)> {
self.0.values() self.0.values()
} }
} }

View file

@ -1,6 +1,6 @@
use distribution_types::LocalEditable; use distribution_types::LocalEditable;
use pep508_rs::Requirement; use pep508_rs::Requirement;
use pypi_types::Metadata21; use pypi_types::Metadata23;
use uv_normalize::PackageName; use uv_normalize::PackageName;
/// A manifest of requirements, constraints, and preferences. /// A manifest of requirements, constraints, and preferences.
@ -11,7 +11,7 @@ pub struct Manifest {
pub(crate) overrides: Vec<Requirement>, pub(crate) overrides: Vec<Requirement>,
pub(crate) preferences: Vec<Requirement>, pub(crate) preferences: Vec<Requirement>,
pub(crate) project: Option<PackageName>, pub(crate) project: Option<PackageName>,
pub(crate) editables: Vec<(LocalEditable, Metadata21)>, pub(crate) editables: Vec<(LocalEditable, Metadata23)>,
} }
impl Manifest { impl Manifest {
@ -21,7 +21,7 @@ impl Manifest {
overrides: Vec<Requirement>, overrides: Vec<Requirement>,
preferences: Vec<Requirement>, preferences: Vec<Requirement>,
project: Option<PackageName>, project: Option<PackageName>,
editables: Vec<(LocalEditable, Metadata21)>, editables: Vec<(LocalEditable, Metadata23)>,
) -> Self { ) -> Self {
Self { Self {
requirements, requirements,

View file

@ -15,7 +15,7 @@ use url::Url;
use distribution_types::{Dist, DistributionMetadata, LocalEditable, Name, PackageId, Verbatim}; use distribution_types::{Dist, DistributionMetadata, LocalEditable, Name, PackageId, Verbatim};
use once_map::OnceMap; use once_map::OnceMap;
use pep440_rs::Version; use pep440_rs::Version;
use pypi_types::{Hashes, Metadata21}; use pypi_types::{Hashes, Metadata23};
use uv_normalize::{ExtraName, PackageName}; use uv_normalize::{ExtraName, PackageName};
use crate::editables::Editables; use crate::editables::Editables;
@ -57,7 +57,7 @@ impl ResolutionGraph {
selection: &SelectedDependencies<PubGrubPackage, Version>, selection: &SelectedDependencies<PubGrubPackage, Version>,
pins: &FilePins, pins: &FilePins,
packages: &OnceMap<PackageName, VersionsResponse>, packages: &OnceMap<PackageName, VersionsResponse>,
distributions: &OnceMap<PackageId, Metadata21>, distributions: &OnceMap<PackageId, Metadata23>,
redirects: &DashMap<Url, Url>, redirects: &DashMap<Url, Url>,
state: &State<PubGrubPackage, Range<Version>, PubGrubPriority>, state: &State<PubGrubPackage, Range<Version>, PubGrubPriority>,
editables: Editables, editables: Editables,

View file

@ -3,7 +3,7 @@ use url::Url;
use distribution_types::PackageId; use distribution_types::PackageId;
use once_map::OnceMap; use once_map::OnceMap;
use pypi_types::Metadata21; use pypi_types::Metadata23;
use uv_normalize::PackageName; use uv_normalize::PackageName;
use super::provider::VersionsResponse; use super::provider::VersionsResponse;
@ -16,7 +16,7 @@ pub struct InMemoryIndex {
pub(crate) packages: OnceMap<PackageName, VersionsResponse>, pub(crate) packages: OnceMap<PackageName, VersionsResponse>,
/// A map from package ID to metadata for that distribution. /// A map from package ID to metadata for that distribution.
pub(crate) distributions: OnceMap<PackageId, Metadata21>, pub(crate) distributions: OnceMap<PackageId, Metadata23>,
/// A map from source URL to precise URL. For example, the source URL /// A map from source URL to precise URL. For example, the source URL
/// `git+https://github.com/pallets/flask.git` could be redirected to /// `git+https://github.com/pallets/flask.git` could be redirected to

View file

@ -25,7 +25,7 @@ use distribution_types::{
use pep440_rs::{Version, VersionSpecifiers, MIN_VERSION}; use pep440_rs::{Version, VersionSpecifiers, MIN_VERSION};
use pep508_rs::{MarkerEnvironment, Requirement}; use pep508_rs::{MarkerEnvironment, Requirement};
use platform_tags::{IncompatibleTag, Tags}; use platform_tags::{IncompatibleTag, Tags};
use pypi_types::{Metadata21, Yanked}; use pypi_types::{Metadata23, Yanked};
pub(crate) use urls::Urls; pub(crate) use urls::Urls;
use uv_client::{FlatIndex, RegistryClient}; use uv_client::{FlatIndex, RegistryClient};
use uv_distribution::DistributionDatabase; use uv_distribution::DistributionDatabase;
@ -1133,7 +1133,7 @@ enum Response {
/// The returned metadata for a distribution. /// The returned metadata for a distribution.
Dist { Dist {
dist: Dist, dist: Dist,
metadata: Metadata21, metadata: Metadata23,
precise: Option<Url>, precise: Option<Url>,
}, },
} }

View file

@ -6,7 +6,7 @@ use url::Url;
use distribution_types::{Dist, IndexLocations}; use distribution_types::{Dist, IndexLocations};
use platform_tags::Tags; use platform_tags::Tags;
use pypi_types::Metadata21; use pypi_types::Metadata23;
use uv_client::{FlatIndex, RegistryClient}; use uv_client::{FlatIndex, RegistryClient};
use uv_distribution::DistributionDatabase; use uv_distribution::DistributionDatabase;
use uv_normalize::PackageName; use uv_normalize::PackageName;
@ -16,7 +16,7 @@ use crate::python_requirement::PythonRequirement;
use crate::version_map::VersionMap; use crate::version_map::VersionMap;
pub type PackageVersionsResult = Result<VersionsResponse, uv_client::Error>; pub type PackageVersionsResult = Result<VersionsResponse, uv_client::Error>;
pub type WheelMetadataResult = Result<(Metadata21, Option<Url>), uv_distribution::Error>; pub type WheelMetadataResult = Result<(Metadata23, Option<Url>), uv_distribution::Error>;
/// The response when requesting versions for a package /// The response when requesting versions for a package
#[derive(Debug)] #[derive(Debug)]

View file

@ -4952,3 +4952,29 @@ dev = ["setuptools"]
Ok(()) Ok(())
} }
/// Resolve a source distribution that leverages Metadata 2.2.
#[test]
fn metadata_2_2() -> Result<()> {
let context = TestContext::new("3.12");
let requirements_in = context.temp_dir.child("requirements.in");
requirements_in.write_str("pyo3-mixed @ https://files.pythonhosted.org/packages/2b/b8/e04b783d3569d5b61b1dcdfda683ac2e3617340539aecd0f099fbade0b4a/pyo3_mixed-2.1.5.tar.gz")?;
uv_snapshot!(context.compile()
.arg("requirements.in"), @r###"
success: true
exit_code: 0
----- stdout -----
# This file was autogenerated by uv via the following command:
# uv pip compile --cache-dir [CACHE_DIR] --exclude-newer 2023-11-18T12:00:00Z requirements.in
boltons==23.1.1
# via pyo3-mixed
pyo3-mixed @ https://files.pythonhosted.org/packages/2b/b8/e04b783d3569d5b61b1dcdfda683ac2e3617340539aecd0f099fbade0b4a/pyo3_mixed-2.1.5.tar.gz
----- stderr -----
Resolved 2 packages in [TIME]
"###
);
Ok(())
}