diff --git a/Cargo.lock b/Cargo.lock index 560202850..f81fb04f7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4876,6 +4876,7 @@ dependencies = [ "reqwest-retry", "rkyv", "rmp-serde", + "rustc-hash", "serde", "serde_json", "sys-info", diff --git a/crates/uv-client/Cargo.toml b/crates/uv-client/Cargo.toml index 5c5aa67ea..64088676e 100644 --- a/crates/uv-client/Cargo.toml +++ b/crates/uv-client/Cargo.toml @@ -46,6 +46,7 @@ reqwest-middleware = { workspace = true } reqwest-retry = { workspace = true } rkyv = { workspace = true } rmp-serde = { workspace = true } +rustc-hash = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } sys-info = { workspace = true } diff --git a/crates/uv-client/src/error.rs b/crates/uv-client/src/error.rs index 12b110199..74111bf5a 100644 --- a/crates/uv-client/src/error.rs +++ b/crates/uv-client/src/error.rs @@ -8,8 +8,8 @@ use url::Url; use uv_distribution_filename::{WheelFilename, WheelFilenameError}; use uv_normalize::PackageName; -use crate::html; use crate::middleware::OfflineError; +use crate::{html, FlatIndexError}; #[derive(Debug, thiserror::Error)] #[error(transparent)] @@ -155,6 +155,9 @@ pub enum ErrorKind { #[error(transparent)] JoinRelativeUrl(#[from] uv_pypi_types::JoinRelativeError), + #[error(transparent)] + Flat(#[from] FlatIndexError), + #[error("Expected a file URL, but received: {0}")] NonFileUrl(Url), diff --git a/crates/uv-client/src/lib.rs b/crates/uv-client/src/lib.rs index 2f5ce771c..49ee1d955 100644 --- a/crates/uv-client/src/lib.rs +++ b/crates/uv-client/src/lib.rs @@ -4,11 +4,11 @@ pub use base_client::{ }; pub use cached_client::{CacheControl, CachedClient, CachedClientError, DataWithCachePolicy}; pub use error::{Error, ErrorKind, WrappedReqwestError}; -pub use flat_index::{FlatIndexClient, FlatIndexEntries, FlatIndexError}; +pub use flat_index::{FlatIndexClient, FlatIndexEntries, FlatIndexEntry, FlatIndexError}; pub use linehaul::LineHaul; pub use registry_client::{ - Connectivity, RegistryClient, RegistryClientBuilder, SimpleMetadata, SimpleMetadatum, - VersionFiles, + Connectivity, MetadataFormat, RegistryClient, RegistryClientBuilder, SimpleMetadata, + SimpleMetadatum, VersionFiles, }; pub use rkyvutil::{Deserializer, OwnedArchive, Serializer, Validator}; diff --git a/crates/uv-client/src/registry_client.rs b/crates/uv-client/src/registry_client.rs index b3f15a59c..cb6a33b68 100644 --- a/crates/uv-client/src/registry_client.rs +++ b/crates/uv-client/src/registry_client.rs @@ -2,6 +2,7 @@ use std::collections::BTreeMap; use std::fmt::Debug; use std::path::PathBuf; use std::str::FromStr; +use std::sync::Arc; use std::time::Duration; use async_http_range_reader::AsyncHttpRangeReader; @@ -10,7 +11,8 @@ use http::HeaderMap; use itertools::Either; use reqwest::{Proxy, Response, StatusCode}; use reqwest_middleware::ClientWithMiddleware; -use tokio::sync::Semaphore; +use rustc_hash::FxHashMap; +use tokio::sync::{Mutex, Semaphore}; use tracing::{info_span, instrument, trace, warn, Instrument}; use url::Url; @@ -20,7 +22,8 @@ use uv_configuration::KeyringProviderType; use uv_configuration::{IndexStrategy, TrustedHost}; use uv_distribution_filename::{DistFilename, SourceDistFilename, WheelFilename}; use uv_distribution_types::{ - BuiltDist, File, FileLocation, IndexCapabilities, IndexMetadataRef, IndexUrl, IndexUrls, Name, + BuiltDist, File, FileLocation, IndexCapabilities, IndexFormat, IndexMetadataRef, IndexUrl, + IndexUrls, Name, }; use uv_metadata::{read_metadata_async_seek, read_metadata_async_stream}; use uv_normalize::PackageName; @@ -33,10 +36,14 @@ use uv_torch::TorchStrategy; use crate::base_client::{BaseClientBuilder, ExtraMiddleware}; use crate::cached_client::CacheControl; +use crate::flat_index::FlatIndexEntry; use crate::html::SimpleHtml; use crate::remote_metadata::wheel_metadata_from_remote_zip; use crate::rkyvutil::OwnedArchive; -use crate::{BaseClient, CachedClient, CachedClientError, Error, ErrorKind}; +use crate::{ + BaseClient, CachedClient, CachedClientError, Error, ErrorKind, FlatIndexClient, + FlatIndexEntries, +}; /// A builder for an [`RegistryClient`]. #[derive(Debug, Clone)] @@ -169,6 +176,7 @@ impl<'a> RegistryClientBuilder<'a> { connectivity, client, timeout, + flat_indexes: Arc::default(), } } @@ -191,6 +199,7 @@ impl<'a> RegistryClientBuilder<'a> { connectivity, client, timeout, + flat_indexes: Arc::default(), } } } @@ -226,6 +235,17 @@ pub struct RegistryClient { connectivity: Connectivity, /// Configured client timeout, in seconds. timeout: Duration, + /// The flat index entries for each `--find-links`-style index URL. + flat_indexes: Arc>, +} + +/// The format of the package metadata returned by querying an index. +#[derive(Debug)] +pub enum MetadataFormat { + /// The metadata adheres to the Simple Repository API format. + Simple(OwnedArchive), + /// The metadata consists of a list of distributions from a "flat" index. + Flat(Vec), } impl RegistryClient { @@ -280,19 +300,21 @@ impl RegistryClient { .unwrap_or(self.index_strategy) } - /// Fetch a package from the `PyPI` simple API. + /// Fetch package metadata from an index. /// - /// "simple" here refers to [PEP 503 – Simple Repository API](https://peps.python.org/pep-0503/) + /// Supports both the "Simple" API and `--find-links`-style flat indexes. + /// + /// "Simple" here refers to [PEP 503 – Simple Repository API](https://peps.python.org/pep-0503/) /// and [PEP 691 – JSON-based Simple API for Python Package Indexes](https://peps.python.org/pep-0691/), - /// which the pypi json api approximately implements. - #[instrument("simple_api", skip_all, fields(package = % package_name))] - pub async fn simple<'index>( + /// which the PyPI JSON API implements. + #[instrument(skip_all, fields(package = % package_name))] + pub async fn package_metadata<'index>( &'index self, package_name: &PackageName, index: Option>, capabilities: &IndexCapabilities, download_concurrency: &Semaphore, - ) -> Result, OwnedArchive)>, Error> { + ) -> Result, Error> { // If `--no-index` is specified, avoid fetching regardless of whether the index is implicit, // explicit, etc. if self.index_urls.no_index() { @@ -312,12 +334,23 @@ impl RegistryClient { IndexStrategy::FirstIndex => { for index in indexes { let _permit = download_concurrency.acquire().await; - if let Some(metadata) = self - .simple_single_index(package_name, index.url(), capabilities) - .await? - { - results.push((index, metadata)); - break; + match index.format { + IndexFormat::Simple => { + if let Some(metadata) = self + .simple_single_index(package_name, index.url, capabilities) + .await? + { + results.push((index.url, MetadataFormat::Simple(metadata))); + break; + } + } + IndexFormat::Flat => { + let entries = self.flat_single_index(package_name, index.url).await?; + if !entries.is_empty() { + results.push((index.url, MetadataFormat::Flat(entries))); + break; + } + } } } } @@ -327,10 +360,19 @@ impl RegistryClient { results = futures::stream::iter(indexes) .map(|index| async move { let _permit = download_concurrency.acquire().await; - let metadata = self - .simple_single_index(package_name, index.url(), capabilities) - .await?; - Ok((index, metadata)) + match index.format { + IndexFormat::Simple => { + let metadata = self + .simple_single_index(package_name, index.url, capabilities) + .await?; + Ok((index.url, metadata.map(MetadataFormat::Simple))) + } + IndexFormat::Flat => { + let entries = + self.flat_single_index(package_name, index.url).await?; + Ok((index.url, Some(MetadataFormat::Flat(entries)))) + } + } }) .buffered(8) .filter_map(|result: Result<_, Error>| async move { @@ -357,6 +399,46 @@ impl RegistryClient { Ok(results) } + /// Fetch the [`FlatIndexEntry`] entries for a given package from a single `--find-links` index. + async fn flat_single_index( + &self, + package_name: &PackageName, + index: &IndexUrl, + ) -> Result, Error> { + // Store the flat index entries in a cache, to avoid redundant fetches. A flat index will + // typically contain entries for multiple packages; as such, it's more efficient to cache + // the entire index rather than re-fetching it for each package. + let mut cache = self.flat_indexes.lock().await; + if let Some(entries) = cache.get(index) { + return Ok(entries.get(package_name).cloned().unwrap_or_default()); + } + + let client = FlatIndexClient::new(self.cached_client(), self.connectivity, &self.cache); + + // Fetch the entries for the index. + let FlatIndexEntries { entries, .. } = + client.fetch_index(index).await.map_err(ErrorKind::Flat)?; + + // Index by package name. + let mut entries_by_package: FxHashMap> = + FxHashMap::default(); + for entry in entries { + entries_by_package + .entry(entry.filename.name().clone()) + .or_default() + .push(entry); + } + let package_entries = entries_by_package + .get(package_name) + .cloned() + .unwrap_or_default(); + + // Write to the cache. + cache.insert(index.clone(), entries_by_package); + + Ok(package_entries) + } + /// Fetch the [`SimpleMetadata`] from a single index for a given package. /// /// The index can either be a PEP 503-compatible remote repository, or a local directory laid @@ -883,6 +965,27 @@ impl RegistryClient { } } +/// A map from [`IndexUrl`] to [`FlatIndexEntry`] entries found at the given URL, indexed by +/// [`PackageName`]. +#[derive(Default, Debug, Clone)] +struct FlatIndexCache(FxHashMap>>); + +impl FlatIndexCache { + /// Get the entries for a given index URL. + fn get(&self, index: &IndexUrl) -> Option<&FxHashMap>> { + self.0.get(index) + } + + /// Insert the entries for a given index URL. + fn insert( + &mut self, + index: IndexUrl, + entries: FxHashMap>, + ) -> Option>> { + self.0.insert(index, entries) + } +} + #[derive(Default, Debug, rkyv::Archive, rkyv::Deserialize, rkyv::Serialize)] #[rkyv(derive(Debug))] pub struct VersionFiles { diff --git a/crates/uv-distribution-types/src/index.rs b/crates/uv-distribution-types/src/index.rs index 7f5468b4c..77fc9f008 100644 --- a/crates/uv-distribution-types/src/index.rs +++ b/crates/uv-distribution-types/src/index.rs @@ -64,13 +64,13 @@ pub struct Index { /// The origin of the index (e.g., a CLI flag, a user-level configuration file, etc.). #[serde(skip)] pub origin: Option, - // /// The type of the index. - // /// - // /// Indexes can either be PEP 503-compliant (i.e., a registry implementing the Simple API) or - // /// structured as a flat list of distributions (e.g., `--find-links`). In both cases, indexes - // /// can point to either local or remote resources. - // #[serde(default)] - // pub r#type: IndexKind, + /// The format used by the index. + /// + /// Indexes can either be PEP 503-compliant (i.e., a PyPI-style registry implementing the Simple + /// API) or structured as a flat list of distributions (e.g., `--find-links`). In both cases, + /// indexes can point to either local or remote resources. + #[serde(default)] + pub format: IndexFormat, /// The URL of the upload endpoint. /// /// When using `uv publish --index `, this URL is used for publishing. @@ -96,17 +96,28 @@ pub struct Index { pub authenticate: AuthPolicy, } -// #[derive( -// Default, Debug, Copy, Clone, Hash, Eq, PartialEq, serde::Serialize, serde::Deserialize, -// )] -// #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -// pub enum IndexKind { -// /// A PEP 503 and/or PEP 691-compliant index. -// #[default] -// Simple, -// /// An index containing a list of links to distributions (e.g., `--find-links`). -// Flat, -// } +#[derive( + Default, + Debug, + Copy, + Clone, + Hash, + Eq, + PartialEq, + Ord, + PartialOrd, + serde::Serialize, + serde::Deserialize, +)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(rename_all = "kebab-case")] +pub enum IndexFormat { + /// A PyPI-style index implementing the Simple Repository API. + #[default] + Simple, + /// A `--find-links`-style index containing a flat list of wheels and source distributions. + Flat, +} impl Index { /// Initialize an [`Index`] from a pip-style `--index-url`. @@ -117,6 +128,7 @@ impl Index { explicit: false, default: true, origin: None, + format: IndexFormat::Simple, publish_url: None, authenticate: AuthPolicy::default(), } @@ -130,6 +142,7 @@ impl Index { explicit: false, default: false, origin: None, + format: IndexFormat::Simple, publish_url: None, authenticate: AuthPolicy::default(), } @@ -143,6 +156,7 @@ impl Index { explicit: false, default: false, origin: None, + format: IndexFormat::Flat, publish_url: None, authenticate: AuthPolicy::default(), } @@ -210,6 +224,7 @@ impl From for Index { explicit: false, default: false, origin: None, + format: IndexFormat::Simple, publish_url: None, authenticate: AuthPolicy::default(), } @@ -231,6 +246,7 @@ impl FromStr for Index { explicit: false, default: false, origin: None, + format: IndexFormat::Simple, publish_url: None, authenticate: AuthPolicy::default(), }); @@ -245,6 +261,7 @@ impl FromStr for Index { explicit: false, default: false, origin: None, + format: IndexFormat::Simple, publish_url: None, authenticate: AuthPolicy::default(), }) @@ -254,21 +271,17 @@ impl FromStr for Index { /// An [`IndexUrl`] along with the metadata necessary to query the index. #[derive(Debug, Clone, Hash, Eq, PartialEq, Ord, PartialOrd)] pub struct IndexMetadata { + /// The URL of the index. pub url: IndexUrl, - // /// The type of the index. - // /// - // /// Indexes can either be PEP 503-compliant (i.e., a registry implementing the Simple API) or - // /// structured as a flat list of distributions (e.g., `--find-links`). In both cases, indexes - // /// can point to either local or remote resources. - // #[serde(default)] - // pub r#type: IndexKind, + /// The format used by the index. + pub format: IndexFormat, } impl IndexMetadata { /// Return a reference to the [`IndexMetadata`]. pub fn as_ref(&self) -> IndexMetadataRef<'_> { - let Self { url } = self; - IndexMetadataRef { url } + let Self { url, format: kind } = self; + IndexMetadataRef { url, format: *kind } } /// Consume the [`IndexMetadata`] and return the [`IndexUrl`]. @@ -280,7 +293,10 @@ impl IndexMetadata { /// A reference to an [`IndexMetadata`]. #[derive(Debug, Copy, Clone)] pub struct IndexMetadataRef<'a> { + /// The URL of the index. pub url: &'a IndexUrl, + /// The format used by the index. + pub format: IndexFormat, } impl IndexMetadata { @@ -297,27 +313,39 @@ impl IndexMetadataRef<'_> { } } -impl<'a> From<&'a IndexMetadata> for IndexMetadataRef<'a> { - fn from(value: &'a IndexMetadata) -> Self { - Self { url: &value.url } - } -} - -impl<'a> From<&'a IndexUrl> for IndexMetadataRef<'a> { - fn from(value: &'a IndexUrl) -> Self { - Self { url: value } - } -} - impl<'a> From<&'a Index> for IndexMetadataRef<'a> { fn from(value: &'a Index) -> Self { - Self { url: &value.url } + Self { + url: &value.url, + format: value.format, + } + } +} + +impl<'a> From<&'a IndexMetadata> for IndexMetadataRef<'a> { + fn from(value: &'a IndexMetadata) -> Self { + Self { + url: &value.url, + format: value.format, + } } } impl From for IndexMetadata { fn from(value: IndexUrl) -> Self { - Self { url: value } + Self { + url: value, + format: IndexFormat::Simple, + } + } +} + +impl<'a> From<&'a IndexUrl> for IndexMetadataRef<'a> { + fn from(value: &'a IndexUrl) -> Self { + Self { + url: value, + format: IndexFormat::Simple, + } } } diff --git a/crates/uv-distribution-types/src/index_url.rs b/crates/uv-distribution-types/src/index_url.rs index a88981a7d..9b68cb9c9 100644 --- a/crates/uv-distribution-types/src/index_url.rs +++ b/crates/uv-distribution-types/src/index_url.rs @@ -98,7 +98,7 @@ impl schemars::JsonSchema for IndexUrl { schemars::schema::SchemaObject { instance_type: Some(schemars::schema::InstanceType::String.into()), metadata: Some(Box::new(schemars::schema::Metadata { - description: Some("The URL of an index to use for fetching packages (e.g., `https://pypi.org/simple`).".to_string()), + description: Some("The URL of an index to use for fetching packages (e.g., `https://pypi.org/simple`), or a local path.".to_string()), ..schemars::schema::Metadata::default() })), ..schemars::schema::SchemaObject::default() diff --git a/crates/uv-distribution/src/metadata/lowering.rs b/crates/uv-distribution/src/metadata/lowering.rs index f89cf5cf3..a9cc382a6 100644 --- a/crates/uv-distribution/src/metadata/lowering.rs +++ b/crates/uv-distribution/src/metadata/lowering.rs @@ -222,9 +222,14 @@ impl LoweredRequirement { .find(|Index { name, .. }| { name.as_ref().is_some_and(|name| *name == index) }) - .map(|index| IndexMetadata { - url: index.url.clone(), - }) + .map( + |Index { + url, format: kind, .. + }| IndexMetadata { + url: url.clone(), + format: *kind, + }, + ) else { return Err(LoweringError::MissingIndex( requirement.name.clone(), @@ -447,9 +452,14 @@ impl LoweredRequirement { .find(|Index { name, .. }| { name.as_ref().is_some_and(|name| *name == index) }) - .map(|index| IndexMetadata { - url: index.url.clone(), - }) + .map( + |Index { + url, format: kind, .. + }| IndexMetadata { + url: url.clone(), + format: *kind, + }, + ) else { return Err(LoweringError::MissingIndex( requirement.name.clone(), diff --git a/crates/uv-publish/src/lib.rs b/crates/uv-publish/src/lib.rs index fdb9429ff..ba8a6ba8d 100644 --- a/crates/uv-publish/src/lib.rs +++ b/crates/uv-publish/src/lib.rs @@ -28,7 +28,8 @@ use trusted_publishing::TrustedPublishingToken; use url::Url; use uv_cache::{Cache, Refresh}; use uv_client::{ - BaseClient, OwnedArchive, RegistryClientBuilder, UvRetryableStrategy, DEFAULT_RETRIES, + BaseClient, MetadataFormat, OwnedArchive, RegistryClientBuilder, UvRetryableStrategy, + DEFAULT_RETRIES, }; use uv_configuration::{KeyringProviderType, TrustedPublishing}; use uv_distribution_filename::{DistFilename, SourceDistExtension, SourceDistFilename}; @@ -477,7 +478,7 @@ pub async fn check_url( debug!("Checking for {filename} in the registry"); let response = match registry_client - .simple( + .package_metadata( filename.name(), Some(index_url.into()), index_capabilities, @@ -499,7 +500,7 @@ pub async fn check_url( }; } }; - let [(_, simple_metadata)] = response.as_slice() else { + let [(_, MetadataFormat::Simple(simple_metadata))] = response.as_slice() else { unreachable!("We queried a single index, we must get a single response"); }; let simple_metadata = OwnedArchive::deserialize(simple_metadata); diff --git a/crates/uv-resolver/src/flat_index.rs b/crates/uv-resolver/src/flat_index.rs index a34fafe9d..3272e27bc 100644 --- a/crates/uv-resolver/src/flat_index.rs +++ b/crates/uv-resolver/src/flat_index.rs @@ -4,7 +4,7 @@ use std::collections::BTreeMap; use rustc_hash::FxHashMap; use tracing::instrument; -use uv_client::FlatIndexEntries; +use uv_client::{FlatIndexEntries, FlatIndexEntry}; use uv_configuration::BuildOptions; use uv_distribution_filename::{DistFilename, SourceDistFilename, WheelFilename}; use uv_distribution_types::{ @@ -39,11 +39,10 @@ impl FlatIndex { build_options: &BuildOptions, ) -> Self { // Collect compatible distributions. - let mut index = FxHashMap::default(); + let mut index = FxHashMap::::default(); for entry in entries.entries { let distributions = index.entry(entry.filename.name().clone()).or_default(); - Self::add_file( - distributions, + distributions.add_file( entry.file, entry.filename, tags, @@ -59,8 +58,59 @@ impl FlatIndex { Self { index, offline } } + /// Get the [`FlatDistributions`] for the given package name. + pub fn get(&self, package_name: &PackageName) -> Option<&FlatDistributions> { + self.index.get(package_name) + } + + /// Whether any `--find-links` entries could not be resolved due to a lack of network + /// connectivity. + pub fn offline(&self) -> bool { + self.offline + } +} + +/// A set of [`PrioritizedDist`] from a `--find-links` entry for a single package, indexed +/// by [`Version`]. +#[derive(Debug, Clone, Default)] +pub struct FlatDistributions(BTreeMap); + +impl FlatDistributions { + /// Collect all files from a `--find-links` target into a [`FlatIndex`]. + #[instrument(skip_all)] + pub fn from_entries( + entries: Vec, + tags: Option<&Tags>, + hasher: &HashStrategy, + build_options: &BuildOptions, + ) -> Self { + let mut distributions = Self::default(); + for entry in entries { + distributions.add_file( + entry.file, + entry.filename, + tags, + hasher, + build_options, + entry.index, + ); + } + distributions + } + + /// Returns an [`Iterator`] over the distributions. + pub fn iter(&self) -> impl Iterator { + self.0.iter() + } + + /// Removes the [`PrioritizedDist`] for the given version. + pub fn remove(&mut self, version: &Version) -> Option { + self.0.remove(version) + } + + /// Add the given [`File`] to the [`FlatDistributions`] for the given package. fn add_file( - distributions: &mut FlatDistributions, + &mut self, file: File, filename: DistFilename, tags: Option<&Tags>, @@ -86,7 +136,7 @@ impl FlatIndex { file: Box::new(file), index, }; - match distributions.0.entry(version) { + match self.0.entry(version) { Entry::Occupied(mut entry) => { entry.get_mut().insert_built(dist, vec![], compatibility); } @@ -110,7 +160,7 @@ impl FlatIndex { index, wheels: vec![], }; - match distributions.0.entry(filename.version) { + match self.0.entry(filename.version) { Entry::Occupied(mut entry) => { entry.get_mut().insert_source(dist, vec![], compatibility); } @@ -194,31 +244,6 @@ impl FlatIndex { WheelCompatibility::Compatible(hash, priority, build_tag) } - - /// Get the [`FlatDistributions`] for the given package name. - pub fn get(&self, package_name: &PackageName) -> Option<&FlatDistributions> { - self.index.get(package_name) - } - - /// Returns `true` if there are any offline `--find-links` entries. - pub fn offline(&self) -> bool { - self.offline - } -} - -/// A set of [`PrioritizedDist`] from a `--find-links` entry for a single package, indexed -/// by [`Version`]. -#[derive(Debug, Clone, Default)] -pub struct FlatDistributions(BTreeMap); - -impl FlatDistributions { - pub fn iter(&self) -> impl Iterator { - self.0.iter() - } - - pub fn remove(&mut self, version: &Version) -> Option { - self.0.remove(version) - } } impl IntoIterator for FlatDistributions { diff --git a/crates/uv-resolver/src/resolver/indexes.rs b/crates/uv-resolver/src/resolver/indexes.rs index 8a5b2a8af..c4f955402 100644 --- a/crates/uv-resolver/src/resolver/indexes.rs +++ b/crates/uv-resolver/src/resolver/indexes.rs @@ -45,9 +45,7 @@ impl Indexes { else { continue; }; - let index = IndexMetadata { - url: index.url.clone(), - }; + let index = index.clone(); let conflict = conflict.clone(); indexes.add(&requirement, Entry { index, conflict }); } diff --git a/crates/uv-resolver/src/resolver/provider.rs b/crates/uv-resolver/src/resolver/provider.rs index bf19f690b..7bf79d1a9 100644 --- a/crates/uv-resolver/src/resolver/provider.rs +++ b/crates/uv-resolver/src/resolver/provider.rs @@ -1,6 +1,6 @@ use std::future::Future; use std::sync::Arc; - +use uv_client::MetadataFormat; use uv_configuration::BuildOptions; use uv_distribution::{ArchiveMetadata, DistributionDatabase, Reporter}; use uv_distribution_types::{ @@ -158,7 +158,7 @@ impl ResolverProvider for DefaultResolverProvider<'_, Con .fetcher .client() .manual(|client, semaphore| { - client.simple( + client.package_metadata( package_name, index.map(IndexMetadataRef::from), self.capabilities, @@ -174,11 +174,11 @@ impl ResolverProvider for DefaultResolverProvider<'_, Con Ok(results) => Ok(VersionsResponse::Found( results .into_iter() - .map(|(index, metadata)| { - VersionMap::from_metadata( + .map(|(index, metadata)| match metadata { + MetadataFormat::Simple(metadata) => VersionMap::from_simple_metadata( metadata, package_name, - index.url(), + index, self.tags.as_ref(), &self.requires_python, &self.allowed_yanks, @@ -188,7 +188,13 @@ impl ResolverProvider for DefaultResolverProvider<'_, Con .and_then(|flat_index| flat_index.get(package_name)) .cloned(), self.build_options, - ) + ), + MetadataFormat::Flat(metadata) => VersionMap::from_flat_metadata( + metadata, + self.tags.as_ref(), + &self.hasher, + self.build_options, + ), }) .collect(), )), diff --git a/crates/uv-resolver/src/version_map.rs b/crates/uv-resolver/src/version_map.rs index 9b5ab24a8..7176a5b13 100644 --- a/crates/uv-resolver/src/version_map.rs +++ b/crates/uv-resolver/src/version_map.rs @@ -6,7 +6,7 @@ use std::sync::OnceLock; use pubgrub::Ranges; use tracing::instrument; -use uv_client::{OwnedArchive, SimpleMetadata, VersionFiles}; +use uv_client::{FlatIndexEntry, OwnedArchive, SimpleMetadata, VersionFiles}; use uv_configuration::BuildOptions; use uv_distribution_filename::{DistFilename, WheelFilename}; use uv_distribution_types::{ @@ -41,7 +41,7 @@ impl VersionMap { /// /// PEP 592: #[instrument(skip_all, fields(package_name))] - pub(crate) fn from_metadata( + pub(crate) fn from_simple_metadata( simple_metadata: OwnedArchive, package_name: &PackageName, index: &IndexUrl, @@ -116,6 +116,30 @@ impl VersionMap { } } + #[instrument(skip_all, fields(package_name))] + pub(crate) fn from_flat_metadata( + flat_metadata: Vec, + tags: Option<&Tags>, + hasher: &HashStrategy, + build_options: &BuildOptions, + ) -> Self { + let mut stable = false; + let mut local = false; + let mut map = BTreeMap::new(); + + for (version, prioritized_dist) in + FlatDistributions::from_entries(flat_metadata, tags, hasher, build_options) + { + stable |= version.is_stable(); + local |= version.is_local(); + map.insert(version, prioritized_dist); + } + + Self { + inner: VersionMapInner::Eager(VersionMapEager { map, stable, local }), + } + } + /// Return the [`DistFile`] for the given version, if any. pub(crate) fn get(&self, version: &Version) -> Option<&PrioritizedDist> { match self.inner { diff --git a/crates/uv/src/commands/pip/latest.rs b/crates/uv/src/commands/pip/latest.rs index 7c6eee834..c99fcc53f 100644 --- a/crates/uv/src/commands/pip/latest.rs +++ b/crates/uv/src/commands/pip/latest.rs @@ -1,6 +1,7 @@ use tokio::sync::Semaphore; use tracing::debug; -use uv_client::{RegistryClient, VersionFiles}; + +use uv_client::{MetadataFormat, RegistryClient, VersionFiles}; use uv_distribution_filename::DistFilename; use uv_distribution_types::{IndexCapabilities, IndexMetadataRef, IndexUrl}; use uv_normalize::PackageName; @@ -34,7 +35,7 @@ impl LatestClient<'_> { let archives = match self .client - .simple( + .package_metadata( package, index.map(IndexMetadataRef::from), self.capabilities, @@ -55,6 +56,10 @@ impl LatestClient<'_> { let mut latest: Option = None; for (_, archive) in archives { + let MetadataFormat::Simple(archive) = archive else { + continue; + }; + for datum in archive.iter().rev() { // Find the first compatible distribution. let files = rkyv::deserialize::(&datum.files) diff --git a/crates/uv/src/commands/project/tree.rs b/crates/uv/src/commands/project/tree.rs index f0633db7e..f88b4a381 100644 --- a/crates/uv/src/commands/project/tree.rs +++ b/crates/uv/src/commands/project/tree.rs @@ -172,6 +172,7 @@ pub(crate) async fn tree( .packages() .iter() .filter_map(|package| { + // TODO(charlie): We would need to know the format here. let index = match package.index(target.install_path()) { Ok(Some(index)) => index, Ok(None) => return None, @@ -232,6 +233,7 @@ pub(crate) async fn tree( let download_concurrency = &download_concurrency; let mut fetches = futures::stream::iter(packages) .map(|(package, index)| async move { + // This probably already doesn't work for `--find-links`? let Some(filename) = client .find_latest(package.name(), Some(&index), download_concurrency) .await? diff --git a/crates/uv/tests/it/lock.rs b/crates/uv/tests/it/lock.rs index 1a4c286b8..e5a8870a8 100644 --- a/crates/uv/tests/it/lock.rs +++ b/crates/uv/tests/it/lock.rs @@ -9551,7 +9551,7 @@ fn lock_find_links_local_wheel() -> Result<()> { /// Prefer an explicit index over any `--find-links` entries. #[test] -fn lock_find_links_explicit_index() -> Result<()> { +fn lock_find_links_ignore_explicit_index() -> Result<()> { let context = TestContext::new("3.12"); // Populate the `--find-links` entries. @@ -9669,6 +9669,123 @@ fn lock_find_links_explicit_index() -> Result<()> { Ok(()) } +/// Ensure that `[[tool.uv.index]]` entries with `format = "flat"` can use relative paths in the +/// `url` field. +#[test] +fn lock_find_links_relative_url() -> Result<()> { + let context = TestContext::new("3.12"); + + // Populate the `--find-links` entries. + fs_err::create_dir_all(context.temp_dir.join("links"))?; + + for entry in fs_err::read_dir(context.workspace_root.join("scripts/links"))? { + let entry = entry?; + let path = entry.path(); + if path + .file_name() + .and_then(|file_name| file_name.to_str()) + .is_some_and(|file_name| file_name.starts_with("tqdm-")) + { + let dest = context + .temp_dir + .join("links") + .join(path.file_name().unwrap()); + fs_err::copy(&path, &dest)?; + } + } + + let workspace = context.temp_dir.child("workspace"); + + let pyproject_toml = workspace.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["tqdm"] + + [[tool.uv.index]] + name = "local" + format = "flat" + url = "./links" + explicit = true + "#, + )?; + + uv_snapshot!(context.filters(), context.lock().current_dir(&workspace), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Resolved 3 packages in [TIME] + "); + + let lock = fs_err::read_to_string(workspace.join("uv.lock")).unwrap(); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r#" + version = 1 + revision = 1 + requires-python = ">=3.12" + + [options] + exclude-newer = "2024-03-25T00:00:00Z" + + [[package]] + name = "colorama" + version = "0.4.6" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } + wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, + ] + + [[package]] + name = "project" + version = "0.1.0" + source = { virtual = "." } + dependencies = [ + { name = "tqdm" }, + ] + + [package.metadata] + requires-dist = [{ name = "tqdm" }] + + [[package]] + name = "tqdm" + version = "4.66.2" + source = { registry = "https://pypi.org/simple" } + dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + ] + sdist = { url = "https://files.pythonhosted.org/packages/ea/85/3ce0f9f7d3f596e7ea57f4e5ce8c18cb44e4a9daa58ddb46ee0d13d6bff8/tqdm-4.66.2.tar.gz", hash = "sha256:6cd52cdf0fef0e0f543299cfc96fec90d7b8a7e88745f411ec33eb44d5ed3531", size = 169462 } + wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/14/e75e52d521442e2fcc9f1df3c5e456aead034203d4797867980de558ab34/tqdm-4.66.2-py3-none-any.whl", hash = "sha256:1ee4f8a893eb9bef51c6e35730cebf234d5d0b6bd112b0271e10ed7c24a02bd9", size = 78296 }, + ] + "# + ); + }); + + // Re-run with `--locked`. + uv_snapshot!(context.filters(), context.lock().arg("--locked").current_dir(&workspace), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Resolved 3 packages in [TIME] + "); + + Ok(()) +} + /// Lock a local source distribution via `--find-links`. #[test] fn lock_find_links_local_sdist() -> Result<()> { @@ -9963,6 +10080,311 @@ fn lock_find_links_http_sdist() -> Result<()> { Ok(()) } +/// Use an explicit `--find-links` index. +#[test] +fn lock_find_links_explicit_index() -> Result<()> { + let context = TestContext::new("3.12"); + + // Populate the `--find-links` entries. + fs_err::create_dir_all(context.temp_dir.join("links"))?; + + for entry in fs_err::read_dir(context.workspace_root.join("scripts/links"))? { + let entry = entry?; + let path = entry.path(); + if path + .file_name() + .and_then(|file_name| file_name.to_str()) + .is_some_and(|file_name| file_name.starts_with("tqdm-")) + { + let dest = context + .temp_dir + .join("links") + .join(path.file_name().unwrap()); + fs_err::copy(&path, &dest)?; + } + } + + let workspace = context.temp_dir.child("workspace"); + + let pyproject_toml = workspace.child("pyproject.toml"); + pyproject_toml.write_str(&formatdoc! { r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["tqdm"] + + [[tool.uv.index]] + name = "local" + format = "flat" + url = "{}" + explicit = true + + [tool.uv.sources] + tqdm = {{ index = "local" }} + "#, + Url::from_file_path(context.temp_dir.join("links/")).unwrap() + })?; + + uv_snapshot!(context.filters(), context.lock().current_dir(&workspace), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Resolved 2 packages in [TIME] + "); + + let lock = fs_err::read_to_string(workspace.join("uv.lock")).unwrap(); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r#" + version = 1 + revision = 1 + requires-python = ">=3.12" + + [options] + exclude-newer = "2024-03-25T00:00:00Z" + + [[package]] + name = "project" + version = "0.1.0" + source = { virtual = "." } + dependencies = [ + { name = "tqdm" }, + ] + + [package.metadata] + requires-dist = [{ name = "tqdm", index = "file://[TEMP_DIR]/links" }] + + [[package]] + name = "tqdm" + version = "1000.0.0" + source = { registry = "../links" } + wheels = [ + { path = "tqdm-1000.0.0-py3-none-any.whl" }, + ] + "# + ); + }); + + // Re-run with `--locked`. + uv_snapshot!(context.filters(), context.lock().arg("--locked").current_dir(&workspace), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Resolved 2 packages in [TIME] + "); + + Ok(()) +} + +/// Use the same index priority rules, interchangeably, for `--find-links` and Simple API indexes. +#[test] +fn lock_find_links_higher_priority_index() -> Result<()> { + let context = TestContext::new("3.12"); + + // Populate the `--find-links` entries. + fs_err::create_dir_all(context.temp_dir.join("links"))?; + + for entry in fs_err::read_dir(context.workspace_root.join("scripts/links"))? { + let entry = entry?; + let path = entry.path(); + if path + .file_name() + .and_then(|file_name| file_name.to_str()) + .is_some_and(|file_name| file_name.starts_with("tqdm-")) + { + let dest = context + .temp_dir + .join("links") + .join(path.file_name().unwrap()); + fs_err::copy(&path, &dest)?; + } + } + + let workspace = context.temp_dir.child("workspace"); + + let pyproject_toml = workspace.child("pyproject.toml"); + pyproject_toml.write_str(&formatdoc! { r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["tqdm"] + + [[tool.uv.index]] + name = "local" + format = "flat" + url = "{}" + "#, + Url::from_file_path(context.temp_dir.join("links/")).unwrap() + })?; + + uv_snapshot!(context.filters(), context.lock().current_dir(&workspace), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Resolved 2 packages in [TIME] + "); + + let lock = fs_err::read_to_string(workspace.join("uv.lock")).unwrap(); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r#" + version = 1 + revision = 1 + requires-python = ">=3.12" + + [options] + exclude-newer = "2024-03-25T00:00:00Z" + + [[package]] + name = "project" + version = "0.1.0" + source = { virtual = "." } + dependencies = [ + { name = "tqdm" }, + ] + + [package.metadata] + requires-dist = [{ name = "tqdm" }] + + [[package]] + name = "tqdm" + version = "1000.0.0" + source = { registry = "../links" } + wheels = [ + { path = "tqdm-1000.0.0-py3-none-any.whl" }, + ] + "# + ); + }); + + Ok(()) +} + +/// Use the same index priority rules, interchangeably, for `--find-links` and Simple API indexes. +#[test] +fn lock_find_links_lower_priority_index() -> Result<()> { + let context = TestContext::new("3.12"); + + // Populate the `--find-links` entries. + fs_err::create_dir_all(context.temp_dir.join("links"))?; + + for entry in fs_err::read_dir(context.workspace_root.join("scripts/links"))? { + let entry = entry?; + let path = entry.path(); + if path + .file_name() + .and_then(|file_name| file_name.to_str()) + .is_some_and(|file_name| file_name.starts_with("tqdm-")) + { + let dest = context + .temp_dir + .join("links") + .join(path.file_name().unwrap()); + fs_err::copy(&path, &dest)?; + } + } + + let workspace = context.temp_dir.child("workspace"); + + let pyproject_toml = workspace.child("pyproject.toml"); + pyproject_toml.write_str(&formatdoc! { r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["tqdm"] + + [[tool.uv.index]] + name = "pypi" + url = "https://pypi.org/simple" + + [[tool.uv.index]] + name = "local" + format = "flat" + url = "{}" + "#, + Url::from_file_path(context.temp_dir.join("links/")).unwrap() + })?; + + uv_snapshot!(context.filters(), context.lock().current_dir(&workspace), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Resolved 3 packages in [TIME] + "); + + let lock = fs_err::read_to_string(workspace.join("uv.lock")).unwrap(); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r#" + version = 1 + revision = 1 + requires-python = ">=3.12" + + [options] + exclude-newer = "2024-03-25T00:00:00Z" + + [[package]] + name = "colorama" + version = "0.4.6" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } + wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, + ] + + [[package]] + name = "project" + version = "0.1.0" + source = { virtual = "." } + dependencies = [ + { name = "tqdm" }, + ] + + [package.metadata] + requires-dist = [{ name = "tqdm" }] + + [[package]] + name = "tqdm" + version = "4.66.2" + source = { registry = "https://pypi.org/simple" } + dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + ] + sdist = { url = "https://files.pythonhosted.org/packages/ea/85/3ce0f9f7d3f596e7ea57f4e5ce8c18cb44e4a9daa58ddb46ee0d13d6bff8/tqdm-4.66.2.tar.gz", hash = "sha256:6cd52cdf0fef0e0f543299cfc96fec90d7b8a7e88745f411ec33eb44d5ed3531", size = 169462 } + wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/14/e75e52d521442e2fcc9f1df3c5e456aead034203d4797867980de558ab34/tqdm-4.66.2-py3-none-any.whl", hash = "sha256:1ee4f8a893eb9bef51c6e35730cebf234d5d0b6bd112b0271e10ed7c24a02bd9", size = 78296 }, + ] + "# + ); + }); + + Ok(()) +} + /// Lock against a local directory laid out as a PEP 503-compatible index. #[test] fn lock_local_index() -> Result<()> { @@ -15331,7 +15753,7 @@ fn lock_explicit_default_index() -> Result<()> { DEBUG No workspace root found, using project root DEBUG Ignoring existing lockfile due to mismatched requirements for: `project==0.1.0` Requested: {Requirement { name: PackageName("anyio"), extras: [], groups: [], marker: true, source: Registry { specifier: VersionSpecifiers([]), index: None, conflict: None }, origin: None }} - Existing: {Requirement { name: PackageName("iniconfig"), extras: [], groups: [], marker: true, source: Registry { specifier: VersionSpecifiers([VersionSpecifier { operator: Equal, version: "2.0.0" }]), index: Some(IndexMetadata { url: Url(VerbatimUrl { url: Url { scheme: "https", cannot_be_a_base: false, username: "", password: None, host: Some(Domain("test.pypi.org")), port: None, path: "/simple", query: None, fragment: None }, given: None }) }), conflict: None }, origin: None }} + Existing: {Requirement { name: PackageName("iniconfig"), extras: [], groups: [], marker: true, source: Registry { specifier: VersionSpecifiers([VersionSpecifier { operator: Equal, version: "2.0.0" }]), index: Some(IndexMetadata { url: Url(VerbatimUrl { url: Url { scheme: "https", cannot_be_a_base: false, username: "", password: None, host: Some(Domain("test.pypi.org")), port: None, path: "/simple", query: None, fragment: None }, given: None }), format: Simple }), conflict: None }, origin: None }} DEBUG Solving with installed Python version: 3.12.[X] DEBUG Solving with target Python version: >=3.12 DEBUG Adding direct dependency: project* diff --git a/crates/uv/tests/it/show_settings.rs b/crates/uv/tests/it/show_settings.rs index 837756ef8..76170ac1e 100644 --- a/crates/uv/tests/it/show_settings.rs +++ b/crates/uv/tests/it/show_settings.rs @@ -134,6 +134,7 @@ fn resolve_uv_toml() -> anyhow::Result<()> { explicit: false, default: true, origin: None, + format: Simple, publish_url: None, authenticate: Auto, }, @@ -293,6 +294,7 @@ fn resolve_uv_toml() -> anyhow::Result<()> { explicit: false, default: true, origin: None, + format: Simple, publish_url: None, authenticate: Auto, }, @@ -453,6 +455,7 @@ fn resolve_uv_toml() -> anyhow::Result<()> { explicit: false, default: true, origin: None, + format: Simple, publish_url: None, authenticate: Auto, }, @@ -645,6 +648,7 @@ fn resolve_pyproject_toml() -> anyhow::Result<()> { explicit: false, default: true, origin: None, + format: Simple, publish_url: None, authenticate: Auto, }, @@ -945,6 +949,7 @@ fn resolve_pyproject_toml() -> anyhow::Result<()> { explicit: false, default: true, origin: None, + format: Simple, publish_url: None, authenticate: Auto, }, @@ -1129,6 +1134,7 @@ fn resolve_index_url() -> anyhow::Result<()> { explicit: false, default: false, origin: None, + format: Simple, publish_url: None, authenticate: Auto, }, @@ -1159,6 +1165,7 @@ fn resolve_index_url() -> anyhow::Result<()> { explicit: false, default: true, origin: None, + format: Simple, publish_url: None, authenticate: Auto, }, @@ -1322,6 +1329,7 @@ fn resolve_index_url() -> anyhow::Result<()> { origin: Some( Cli, ), + format: Simple, publish_url: None, authenticate: Auto, }, @@ -1352,6 +1360,7 @@ fn resolve_index_url() -> anyhow::Result<()> { explicit: false, default: false, origin: None, + format: Simple, publish_url: None, authenticate: Auto, }, @@ -1382,6 +1391,7 @@ fn resolve_index_url() -> anyhow::Result<()> { explicit: false, default: true, origin: None, + format: Simple, publish_url: None, authenticate: Auto, }, @@ -1567,6 +1577,7 @@ fn resolve_find_links() -> anyhow::Result<()> { explicit: false, default: false, origin: None, + format: Flat, publish_url: None, authenticate: Auto, }, @@ -1894,6 +1905,7 @@ fn resolve_top_level() -> anyhow::Result<()> { explicit: false, default: false, origin: None, + format: Simple, publish_url: None, authenticate: Auto, }, @@ -1924,6 +1936,7 @@ fn resolve_top_level() -> anyhow::Result<()> { explicit: false, default: false, origin: None, + format: Simple, publish_url: None, authenticate: Auto, }, @@ -2083,6 +2096,7 @@ fn resolve_top_level() -> anyhow::Result<()> { explicit: false, default: false, origin: None, + format: Simple, publish_url: None, authenticate: Auto, }, @@ -2113,6 +2127,7 @@ fn resolve_top_level() -> anyhow::Result<()> { explicit: false, default: false, origin: None, + format: Simple, publish_url: None, authenticate: Auto, }, @@ -3184,6 +3199,7 @@ fn resolve_both() -> anyhow::Result<()> { explicit: false, default: true, origin: None, + format: Simple, publish_url: None, authenticate: Auto, }, @@ -3468,6 +3484,7 @@ fn resolve_config_file() -> anyhow::Result<()> { explicit: false, default: true, origin: None, + format: Simple, publish_url: None, authenticate: Auto, }, @@ -4174,6 +4191,7 @@ fn index_priority() -> anyhow::Result<()> { origin: Some( Cli, ), + format: Simple, publish_url: None, authenticate: Auto, }, @@ -4204,6 +4222,7 @@ fn index_priority() -> anyhow::Result<()> { explicit: false, default: false, origin: None, + format: Simple, publish_url: None, authenticate: Auto, }, @@ -4365,6 +4384,7 @@ fn index_priority() -> anyhow::Result<()> { origin: Some( Cli, ), + format: Simple, publish_url: None, authenticate: Auto, }, @@ -4395,6 +4415,7 @@ fn index_priority() -> anyhow::Result<()> { explicit: false, default: false, origin: None, + format: Simple, publish_url: None, authenticate: Auto, }, @@ -4562,6 +4583,7 @@ fn index_priority() -> anyhow::Result<()> { origin: Some( Cli, ), + format: Simple, publish_url: None, authenticate: Auto, }, @@ -4592,6 +4614,7 @@ fn index_priority() -> anyhow::Result<()> { explicit: false, default: true, origin: None, + format: Simple, publish_url: None, authenticate: Auto, }, @@ -4754,6 +4777,7 @@ fn index_priority() -> anyhow::Result<()> { origin: Some( Cli, ), + format: Simple, publish_url: None, authenticate: Auto, }, @@ -4784,6 +4808,7 @@ fn index_priority() -> anyhow::Result<()> { explicit: false, default: true, origin: None, + format: Simple, publish_url: None, authenticate: Auto, }, @@ -4953,6 +4978,7 @@ fn index_priority() -> anyhow::Result<()> { origin: Some( Cli, ), + format: Simple, publish_url: None, authenticate: Auto, }, @@ -4983,6 +5009,7 @@ fn index_priority() -> anyhow::Result<()> { explicit: false, default: true, origin: None, + format: Simple, publish_url: None, authenticate: Auto, }, @@ -5145,6 +5172,7 @@ fn index_priority() -> anyhow::Result<()> { origin: Some( Cli, ), + format: Simple, publish_url: None, authenticate: Auto, }, @@ -5175,6 +5203,7 @@ fn index_priority() -> anyhow::Result<()> { explicit: false, default: true, origin: None, + format: Simple, publish_url: None, authenticate: Auto, }, diff --git a/docs/configuration/indexes.md b/docs/configuration/indexes.md index e14452d5d..cd758a476 100644 --- a/docs/configuration/indexes.md +++ b/docs/configuration/indexes.md @@ -223,6 +223,25 @@ authenticate = "never" When `authenticate` is set to `never`, uv will never search for credentials for the given index and will error if credentials are provided directly. +## "Flat" indexes + +By default, `[[tool.uv.index]]` entries are assumed to be PyPI-style registries that implement the +[PEP 503](https://peps.python.org/pep-0503/) Simple Repository API. However, uv also supports "flat" +indexes, which are local directories or HTML pages that contain flat lists of wheels and source +distributions. In pip, such indexes are specified using the `--find-links` option. + +To define a flat index in your `pyproject.toml`, use the `kind = "flat"` option: + +```toml +[[tool.uv.index]] +name = "example" +url = "/path/to/directory" +kind = "flat" +``` + +Flat indexes support the same feature set as Simple Repository API indexes (e.g., +`explicit = true`); you can also pin a package to a flat index using `tool.uv.sources`. + ## `--index-url` and `--extra-index-url` In addition to the `[[tool.uv.index]]` configuration option, uv supports pip-style `--index-url` and diff --git a/uv.schema.json b/uv.schema.json index 7988a57f6..008aa6327 100644 --- a/uv.schema.json +++ b/uv.schema.json @@ -796,6 +796,15 @@ "default": false, "type": "boolean" }, + "format": { + "description": "The format used by the index.\n\nIndexes can either be PEP 503-compliant (i.e., a PyPI-style registry implementing the Simple API) or structured as a flat list of distributions (e.g., `--find-links`). In both cases, indexes can point to either local or remote resources.", + "default": "simple", + "allOf": [ + { + "$ref": "#/definitions/IndexFormat" + } + ] + }, "name": { "description": "The name of the index.\n\nIndex names can be used to reference indexes elsewhere in the configuration. For example, you can pin a package to a specific index by name:\n\n```toml [[tool.uv.index]] name = \"pytorch\" url = \"https://download.pytorch.org/whl/cu121\"\n\n[tool.uv.sources] torch = { index = \"pytorch\" } ```", "anyOf": [ @@ -825,6 +834,24 @@ } } }, + "IndexFormat": { + "oneOf": [ + { + "description": "A PyPI-style index implementing the Simple Repository API.", + "type": "string", + "enum": [ + "simple" + ] + }, + { + "description": "A `--find-links`-style index containing a flat list of wheels and source distributions.", + "type": "string", + "enum": [ + "flat" + ] + } + ] + }, "IndexName": { "description": "The normalized name of an index.\n\nIndex names may contain letters, digits, hyphens, underscores, and periods, and must be ASCII.", "type": "string" @@ -855,7 +882,7 @@ ] }, "IndexUrl": { - "description": "The URL of an index to use for fetching packages (e.g., `https://pypi.org/simple`).", + "description": "The URL of an index to use for fetching packages (e.g., `https://pypi.org/simple`), or a local path.", "type": "string" }, "KeyringProviderType": {