diff --git a/Cargo.lock b/Cargo.lock index 7f0add0a71289..e3800047526a4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4803,6 +4803,7 @@ dependencies = [ "distribution-types", "fs-err", "futures", + "indexmap", "indoc", "insta", "nanoid", diff --git a/crates/distribution-types/src/index.rs b/crates/distribution-types/src/index.rs index 9a0ac31e95dc1..4d335a8f7302d 100644 --- a/crates/distribution-types/src/index.rs +++ b/crates/distribution-types/src/index.rs @@ -1,7 +1,7 @@ +use crate::{IndexUrl, IndexUrlError}; use std::str::FromStr; use thiserror::Error; - -use crate::{IndexUrl, IndexUrlError}; +use url::Url; #[derive(Debug, Clone, Hash, Eq, PartialEq, serde::Serialize, serde::Deserialize)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] @@ -92,6 +92,16 @@ impl Index { default: false, } } + + /// Return the [`IndexUrl`] of the index. + pub fn url(&self) -> &IndexUrl { + &self.url + } + + /// Return the raw [`URL`] of the index. + pub fn raw_url(&self) -> &Url { + self.url.url() + } } impl FromStr for Index { diff --git a/crates/distribution-types/src/index_url.rs b/crates/distribution-types/src/index_url.rs index 0a7ae16ff94ae..c6dcc0ea0ee6e 100644 --- a/crates/distribution-types/src/index_url.rs +++ b/crates/distribution-types/src/index_url.rs @@ -15,8 +15,9 @@ use crate::{Index, Verbatim}; static PYPI_URL: LazyLock = LazyLock::new(|| Url::parse("https://pypi.org/simple").unwrap()); -static DEFAULT_INDEX_URL: LazyLock = - LazyLock::new(|| IndexUrl::Pypi(VerbatimUrl::from_url(PYPI_URL.clone()))); +static DEFAULT_INDEX_URL: LazyLock = LazyLock::new(|| { + Index::from_index_url(IndexUrl::Pypi(VerbatimUrl::from_url(PYPI_URL.clone()))) +}); /// The URL of an index to use for fetching packages (e.g., PyPI). #[derive(Debug, Clone, Hash, Eq, PartialEq, Ord, PartialOrd)] @@ -297,7 +298,8 @@ impl From for FlatIndexLocation { /// The index locations to use for fetching packages. By default, uses the PyPI index. /// -/// From a pip perspective, this type merges `--index-url`, `--extra-index-url`, and `--find-links`. +/// From a pip perspective, this type merges `--index-url`, `--extra-index-url`, and `--find-links`, +/// along with the uv-specific `--index` and `--default-index` options. #[derive(Default, Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub struct IndexLocations { @@ -344,51 +346,64 @@ impl IndexLocations { } impl<'a> IndexLocations { - /// Return the primary [`IndexUrl`] entry. + /// Return the default [`Index`] entry. /// /// If `--no-index` is set, return `None`. /// /// If no index is provided, use the `PyPI` index. - pub fn index(&'a self) -> Option<&'a IndexUrl> { + pub fn default_index(&'a self) -> Option<&'a Index> { if self.no_index { None } else { + let mut seen = FxHashSet::default(); self.indexes .iter() - .find_map(|index| { - if !index.default || index.explicit { - None - } else { - Some(&index.url) - } - }) + .filter(move |index| index.name.as_ref().map_or(true, |name| seen.insert(name))) + .find(|index| index.default && !index.explicit) .or_else(|| Some(&DEFAULT_INDEX_URL)) } } - /// Return an iterator over the extra [`IndexUrl`] entries. - pub fn extra_index(&'a self) -> impl Iterator + 'a { + /// Return an iterator over the implicit [`Index`] entries. + pub fn implicit_indexes(&'a self) -> impl Iterator + 'a { if self.no_index { Either::Left(std::iter::empty()) } else { - Either::Right(self.indexes.iter().filter_map(|index| { - if index.default || index.explicit { - None - } else { - Some(&index.url) - } - })) + let mut seen = FxHashSet::default(); + Either::Right( + self.indexes + .iter() + .filter(move |index| index.name.as_ref().map_or(true, |name| seen.insert(name))) + .filter(|index| !(index.default || index.explicit)), + ) } } - /// Return an iterator over all [`IndexUrl`] entries in order. + /// Return an iterator over the explicit [`Index`] entries. + pub fn explicit_indexes(&'a self) -> impl Iterator + 'a { + if self.no_index { + Either::Left(std::iter::empty()) + } else { + let mut seen = FxHashSet::default(); + Either::Right( + self.indexes + .iter() + .filter(move |index| index.name.as_ref().map_or(true, |name| seen.insert(name))) + .filter(|index| index.explicit), + ) + } + } + + /// Return an iterator over all [`Index`] entries in order. /// - /// Prioritizes the extra indexes over the main index. + /// Explicit indexes are excluded. + /// + /// Prioritizes the extra indexes over the default index. /// /// If `no_index` was enabled, then this always returns an empty /// iterator. - pub fn indexes(&'a self) -> impl Iterator + 'a { - self.extra_index().chain(self.index()) + pub fn indexes(&'a self) -> impl Iterator + 'a { + self.implicit_indexes().chain(self.default_index()) } /// Return an iterator over the [`FlatIndexLocation`] entries. @@ -409,17 +424,35 @@ impl<'a> IndexLocations { } } - /// Return an iterator over all [`Url`] entries. - pub fn urls(&'a self) -> impl Iterator + 'a { - self.indexes() - .map(IndexUrl::url) + /// Return an iterator over all allowed [`IndexUrl`] entries. + /// + /// This includes both explicit and implicit indexes, as well as the default index. + /// + /// If `no_index` was enabled, then this always returns an empty + /// iterator. + pub fn allowed_indexes(&'a self) -> impl Iterator + 'a { + self.explicit_indexes() + .chain(self.implicit_indexes()) + .chain(self.default_index()) + } + + /// Return an iterator over all allowed [`Url`] entries. + /// + /// This includes both explicit and implicit index URLs, as well as the default index. + /// + /// If `no_index` was enabled, then this always returns an empty + /// iterator. + pub fn allowed_urls(&'a self) -> impl Iterator + 'a { + self.allowed_indexes() + .map(Index::raw_url) .chain(self.flat_index().map(FlatIndexLocation::url)) } } /// The index URLs to use for fetching packages. /// -/// From a pip perspective, this type merges `--index-url` and `--extra-index-url`. +/// From a pip perspective, this type merges `--index-url` and `--extra-index-url`, along with the +/// uv-specific `--index` and `--default-index` options. #[derive(Default, Debug, Clone, PartialEq, Eq)] pub struct IndexUrls { indexes: Vec, @@ -427,40 +460,36 @@ pub struct IndexUrls { } impl<'a> IndexUrls { - /// Return the primary [`IndexUrl`] entry. + /// Return the default [`Index`] entry. /// /// If `--no-index` is set, return `None`. /// /// If no index is provided, use the `PyPI` index. - fn index(&'a self) -> Option<&'a IndexUrl> { + fn default_index(&'a self) -> Option<&'a Index> { if self.no_index { None } else { + let mut seen = FxHashSet::default(); self.indexes .iter() - .find_map(|index| { - if !index.default || index.explicit { - None - } else { - Some(&index.url) - } - }) + .filter(move |index| index.name.as_ref().map_or(true, |name| seen.insert(name))) + .find(|index| index.default && !index.explicit) .or_else(|| Some(&DEFAULT_INDEX_URL)) } } - /// Return an iterator over the extra [`IndexUrl`] entries. - fn extra_index(&'a self) -> impl Iterator + 'a { + /// Return an iterator over the implicit [`Index`] entries. + fn implicit_indexes(&'a self) -> impl Iterator + 'a { if self.no_index { Either::Left(std::iter::empty()) } else { - Either::Right(self.indexes.iter().filter_map(|index| { - if index.default || index.explicit { - None - } else { - Some(&index.url) - } - })) + let mut seen = FxHashSet::default(); + Either::Right( + self.indexes + .iter() + .filter(move |index| index.name.as_ref().map_or(true, |name| seen.insert(name))) + .filter(|index| !(index.default || index.explicit)), + ) } } @@ -471,8 +500,8 @@ impl<'a> IndexUrls { /// /// If `no_index` was enabled, then this always returns an empty /// iterator. - pub fn indexes(&'a self) -> impl Iterator + 'a { - self.extra_index().chain(self.index()) + pub fn indexes(&'a self) -> impl Iterator + 'a { + self.implicit_indexes().chain(self.default_index()) } } diff --git a/crates/distribution-types/src/resolution.rs b/crates/distribution-types/src/resolution.rs index 8501d4a30855c..cfe62b3ba1c33 100644 --- a/crates/distribution-types/src/resolution.rs +++ b/crates/distribution-types/src/resolution.rs @@ -190,7 +190,7 @@ impl From<&ResolvedDist> for Requirement { wheels.best_wheel().filename.version.clone(), ), ), - index: None, + index: Some(wheels.best_wheel().index.url().clone()), }, Dist::Built(BuiltDist::DirectUrl(wheel)) => { let mut location = wheel.url.to_url(); @@ -211,7 +211,7 @@ impl From<&ResolvedDist> for Requirement { specifier: pep440_rs::VersionSpecifiers::from( pep440_rs::VersionSpecifier::equals_version(sdist.version.clone()), ), - index: None, + index: Some(sdist.index.url().clone()), }, Dist::Source(SourceDist::DirectUrl(sdist)) => { let mut location = sdist.url.to_url(); diff --git a/crates/uv-client/src/registry_client.rs b/crates/uv-client/src/registry_client.rs index acda0160bc47c..57b7f1d61586d 100644 --- a/crates/uv-client/src/registry_client.rs +++ b/crates/uv-client/src/registry_client.rs @@ -14,7 +14,7 @@ use url::Url; use distribution_filename::{DistFilename, SourceDistFilename, WheelFilename}; use distribution_types::{ - BuiltDist, File, FileLocation, IndexCapabilities, IndexUrl, IndexUrls, Name, + BuiltDist, File, FileLocation, Index, IndexCapabilities, IndexUrl, IndexUrls, Name, }; use pep440_rs::Version; use pep508_rs::MarkerEnvironment; @@ -210,7 +210,7 @@ impl RegistryClient { let indexes = if let Some(index) = index { Either::Left(std::iter::once(index)) } else { - Either::Right(self.index_urls.indexes()) + Either::Right(self.index_urls.indexes().map(Index::url)) }; let mut it = indexes.peekable(); diff --git a/crates/uv-distribution/Cargo.toml b/crates/uv-distribution/Cargo.toml index 1845a60951fdc..23fec1cbbb8fd 100644 --- a/crates/uv-distribution/Cargo.toml +++ b/crates/uv-distribution/Cargo.toml @@ -36,6 +36,7 @@ uv-workspace = { workspace = true } anyhow = { workspace = true } fs-err = { workspace = true } futures = { workspace = true } +indexmap = { workspace = true } nanoid = { workspace = true } owo-colors = { workspace = true } reqwest = { workspace = true } diff --git a/crates/uv-distribution/src/index/registry_wheel_index.rs b/crates/uv-distribution/src/index/registry_wheel_index.rs index 38a8373458b91..1e3e1d7e226d0 100644 --- a/crates/uv-distribution/src/index/registry_wheel_index.rs +++ b/crates/uv-distribution/src/index/registry_wheel_index.rs @@ -1,9 +1,9 @@ +use indexmap::IndexMap; +use rustc_hash::FxHashMap; use std::collections::hash_map::Entry; use std::collections::BTreeMap; -use rustc_hash::FxHashMap; - -use distribution_types::{CachedRegistryDist, Hashed, IndexLocations, IndexUrl}; +use distribution_types::{CachedRegistryDist, Hashed, Index, IndexLocations, IndexUrl}; use pep440_rs::Version; use platform_tags::Tags; use uv_cache::{Cache, CacheBucket, WheelCache}; @@ -21,7 +21,11 @@ pub struct RegistryWheelIndex<'a> { tags: &'a Tags, index_locations: &'a IndexLocations, hasher: &'a HashStrategy, - index: FxHashMap<&'a PackageName, BTreeMap>, + /// The cached distributions, indexed by package name and index. + /// + /// Index priority is respected, such that if a version is found in multiple indexes, the + /// highest priority index is + index: FxHashMap<&'a PackageName, IndexMap>>, } impl<'a> RegistryWheelIndex<'a> { @@ -47,24 +51,20 @@ impl<'a> RegistryWheelIndex<'a> { pub fn get( &mut self, name: &'a PackageName, - ) -> impl Iterator { - self.get_impl(name).iter().rev() + ) -> impl Iterator { + self.get_impl(name).iter().flat_map(|(index, versions)| { + versions + .iter() + .map(move |(version, dist)| (index, version, dist)) + }) } - /// Get the best wheel for the given package name and version. - /// - /// If the package is not yet indexed, this will index the package by reading from the cache. - pub fn get_version( + /// Get an entry in the index. + fn get_impl( &mut self, name: &'a PackageName, - version: &Version, - ) -> Option<&CachedRegistryDist> { - self.get_impl(name).get(version) - } - - /// Get an entry in the index. - fn get_impl(&mut self, name: &'a PackageName) -> &BTreeMap { - let versions = match self.index.entry(name) { + ) -> &IndexMap> { + let by_index = match self.index.entry(name) { Entry::Occupied(entry) => entry.into_mut(), Entry::Vacant(entry) => entry.insert(Self::index( name, @@ -74,7 +74,7 @@ impl<'a> RegistryWheelIndex<'a> { self.hasher, )), }; - versions + by_index } /// Add a package to the index by reading from the cache. @@ -84,26 +84,31 @@ impl<'a> RegistryWheelIndex<'a> { tags: &Tags, index_locations: &IndexLocations, hasher: &HashStrategy, - ) -> BTreeMap { - let mut versions = BTreeMap::new(); + ) -> IndexMap> { + let mut map = IndexMap::new(); // Collect into owned `IndexUrl`. - let flat_index_urls: Vec = index_locations + let flat_index_urls: Vec = index_locations .flat_index() - .map(|flat_index| IndexUrl::from(flat_index.clone())) + .map(|flat_index| Index::from_extra_index_url(IndexUrl::from(flat_index.clone()))) .collect(); - for index_url in index_locations.indexes().chain(flat_index_urls.iter()) { + for index in index_locations + .allowed_indexes() + .chain(flat_index_urls.iter()) + { + let mut versions = BTreeMap::new(); + // Index all the wheels that were downloaded directly from the registry. let wheel_dir = cache.shard( CacheBucket::Wheels, - WheelCache::Index(index_url).wheel_dir(package.to_string()), + WheelCache::Index(index.url()).wheel_dir(package.to_string()), ); // For registry wheels, the cache structure is: `//.http` // or `///.rev`. for file in files(&wheel_dir) { - match index_url { + match index.url() { // Add files from remote registries. IndexUrl::Pypi(_) | IndexUrl::Url(_) => { if file @@ -149,7 +154,7 @@ impl<'a> RegistryWheelIndex<'a> { // from the registry. let cache_shard = cache.shard( CacheBucket::SourceDistributions, - WheelCache::Index(index_url).wheel_dir(package.to_string()), + WheelCache::Index(index.url()).wheel_dir(package.to_string()), ); // For registry wheels, the cache structure is: `///`. @@ -158,7 +163,7 @@ impl<'a> RegistryWheelIndex<'a> { let cache_shard = cache_shard.shard(shard); // Read the revision from the cache. - let revision = match index_url { + let revision = match index.url() { // Add files from remote registries. IndexUrl::Pypi(_) | IndexUrl::Url(_) => { let revision_entry = cache_shard.entry(HTTP_REVISION); @@ -192,9 +197,11 @@ impl<'a> RegistryWheelIndex<'a> { } } } + + map.insert(index.clone(), versions); } - versions + map } /// Add the [`CachedWheel`] to the index. diff --git a/crates/uv-installer/src/plan.rs b/crates/uv-installer/src/plan.rs index f6562d906462b..3e2831f76c9a7 100644 --- a/crates/uv-installer/src/plan.rs +++ b/crates/uv-installer/src/plan.rs @@ -142,10 +142,28 @@ impl<'a> Planner<'a> { // Identify any cached distributions that satisfy the requirement. match &requirement.source { - RequirementSource::Registry { specifier, .. } => { - if let Some((_version, distribution)) = registry_index + RequirementSource::Registry { + specifier, + index: Some(url), + } => { + if let Some((_index, _version, distribution)) = registry_index + .get(&requirement.name) + .filter(|(index, _, _)| *index.raw_url() == *url) + .find(|(_index, version, _)| specifier.contains(version)) + { + debug!("Requirement already cached: {distribution}"); + cached.push(CachedDist::Registry(distribution.clone())); + continue; + } + } + RequirementSource::Registry { + specifier, + index: None, + } => { + if let Some((_index, _version, distribution)) = registry_index .get(&requirement.name) - .find(|(version, _)| specifier.contains(version)) + .filter(|(index, _, _)| !index.explicit) + .find(|(_, version, _)| specifier.contains(version)) { debug!("Requirement already cached: {distribution}"); cached.push(CachedDist::Registry(distribution.clone())); diff --git a/crates/uv-resolver/src/lock/mod.rs b/crates/uv-resolver/src/lock/mod.rs index 06a0f03d36af9..a238c16b3f1c2 100644 --- a/crates/uv-resolver/src/lock/mod.rs +++ b/crates/uv-resolver/src/lock/mod.rs @@ -1046,10 +1046,10 @@ impl Lock { // Collect the set of available indexes (both `--index-url` and `--find-links` entries). let remotes = indexes.map(|locations| { locations - .indexes() - .filter_map(|index_url| match index_url { + .allowed_indexes() + .filter_map(|index| match index.url() { IndexUrl::Pypi(_) | IndexUrl::Url(_) => { - Some(UrlString::from(index_url.redacted())) + Some(UrlString::from(index.url().redacted())) } IndexUrl::Path(_) => None, }) @@ -1068,11 +1068,11 @@ impl Lock { let locals = indexes.map(|locations| { locations - .indexes() - .filter_map(|index_url| match index_url { + .allowed_indexes() + .filter_map(|index| match index.url() { IndexUrl::Pypi(_) | IndexUrl::Url(_) => None, - IndexUrl::Path(index_url) => { - let path = index_url.to_file_path().ok()?; + IndexUrl::Path(url) => { + let path = url.to_file_path().ok()?; let path = relative_to(&path, workspace.install_path()) .or_else(|_| std::path::absolute(path)) .ok()?; diff --git a/crates/uv-resolver/src/pubgrub/report.rs b/crates/uv-resolver/src/pubgrub/report.rs index f272020bcbb56..b4922cb9331fc 100644 --- a/crates/uv-resolver/src/pubgrub/report.rs +++ b/crates/uv-resolver/src/pubgrub/report.rs @@ -8,7 +8,7 @@ use owo_colors::OwoColorize; use pubgrub::{DerivationTree, Derived, External, Map, Range, ReportFormatter, Term}; use rustc_hash::FxHashMap; -use distribution_types::{IndexLocations, IndexUrl}; +use distribution_types::{Index, IndexLocations, IndexUrl}; use pep440_rs::Version; use uv_configuration::IndexStrategy; use uv_normalize::PackageName; @@ -703,6 +703,7 @@ impl PubGrubReportFormatter<'_> { // indexes were not queried, and could contain a compatible version. if let Some(next_index) = index_locations .indexes() + .map(Index::url) .skip_while(|url| *url != found_index) .nth(1) { diff --git a/crates/uv/src/commands/build.rs b/crates/uv/src/commands/build.rs index 6bb42f3b2fe52..397689d613152 100644 --- a/crates/uv/src/commands/build.rs +++ b/crates/uv/src/commands/build.rs @@ -391,7 +391,7 @@ async fn build_package( .into_interpreter(); // Add all authenticated sources to the cache. - for url in index_locations.urls() { + for url in index_locations.allowed_urls() { store_credentials_from_url(url); } diff --git a/crates/uv/src/commands/pip/compile.rs b/crates/uv/src/commands/pip/compile.rs index fb8ab19862291..995b254d12653 100644 --- a/crates/uv/src/commands/pip/compile.rs +++ b/crates/uv/src/commands/pip/compile.rs @@ -285,7 +285,7 @@ pub(crate) async fn pip_compile( ); // Add all authenticated sources to the cache. - for url in index_locations.urls() { + for url in index_locations.allowed_urls() { store_credentials_from_url(url); } @@ -447,12 +447,12 @@ pub(crate) async fn pip_compile( // If necessary, include the `--index-url` and `--extra-index-url` locations. if include_index_url { - if let Some(index) = index_locations.index() { - writeln!(writer, "--index-url {}", index.verbatim())?; + if let Some(index) = index_locations.default_index() { + writeln!(writer, "--index-url {}", index.url().verbatim())?; wrote_preamble = true; } - for extra_index in index_locations.extra_index() { - writeln!(writer, "--extra-index-url {}", extra_index.verbatim())?; + for extra_index in index_locations.implicit_indexes() { + writeln!(writer, "--extra-index-url {}", extra_index.url().verbatim())?; wrote_preamble = true; } } diff --git a/crates/uv/src/commands/pip/install.rs b/crates/uv/src/commands/pip/install.rs index e8290f46c245e..b09741455d18f 100644 --- a/crates/uv/src/commands/pip/install.rs +++ b/crates/uv/src/commands/pip/install.rs @@ -283,7 +283,7 @@ pub(crate) async fn pip_install( ); // Add all authenticated sources to the cache. - for url in index_locations.urls() { + for url in index_locations.allowed_urls() { store_credentials_from_url(url); } diff --git a/crates/uv/src/commands/pip/sync.rs b/crates/uv/src/commands/pip/sync.rs index 0a0d075fdbbfb..562131cba6cd1 100644 --- a/crates/uv/src/commands/pip/sync.rs +++ b/crates/uv/src/commands/pip/sync.rs @@ -226,7 +226,7 @@ pub(crate) async fn pip_sync( ); // Add all authenticated sources to the cache. - for url in index_locations.urls() { + for url in index_locations.allowed_urls() { store_credentials_from_url(url); } diff --git a/crates/uv/src/commands/project/add.rs b/crates/uv/src/commands/project/add.rs index 89f3bd0c198bc..9037355d58b8c 100644 --- a/crates/uv/src/commands/project/add.rs +++ b/crates/uv/src/commands/project/add.rs @@ -242,7 +242,7 @@ pub(crate) async fn add( resolution_environment(python_version, python_platform, target.interpreter())?; // Add all authenticated sources to the cache. - for url in settings.index_locations.urls() { + for url in settings.index_locations.allowed_urls() { store_credentials_from_url(url); } diff --git a/crates/uv/src/commands/project/lock.rs b/crates/uv/src/commands/project/lock.rs index 40819ac4b3a01..f26dd470f7035 100644 --- a/crates/uv/src/commands/project/lock.rs +++ b/crates/uv/src/commands/project/lock.rs @@ -354,7 +354,7 @@ async fn do_lock( PythonRequirement::from_requires_python(interpreter, requires_python.clone()); // Add all authenticated sources to the cache. - for url in index_locations.urls() { + for url in index_locations.allowed_urls() { store_credentials_from_url(url); } diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index e1aad07593f04..53d2dfef16f4f 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -5,7 +5,9 @@ use itertools::Itertools; use owo_colors::OwoColorize; use tracing::debug; -use distribution_types::{Resolution, UnresolvedRequirement, UnresolvedRequirementSpecification}; +use distribution_types::{ + Index, Resolution, UnresolvedRequirement, UnresolvedRequirementSpecification, +}; use pep440_rs::{Version, VersionSpecifiers}; use pep508_rs::MarkerTreeContents; use pypi_types::Requirement; @@ -624,7 +626,7 @@ pub(crate) async fn resolve_names( } = settings; // Add all authenticated sources to the cache. - for url in index_locations.urls() { + for url in index_locations.allowed_urls() { store_credentials_from_url(url); } @@ -772,7 +774,7 @@ pub(crate) async fn resolve_environment<'a>( let python_requirement = PythonRequirement::from_interpreter(interpreter); // Add all authenticated sources to the cache. - for url in index_locations.urls() { + for url in index_locations.allowed_urls() { store_credentials_from_url(url); } @@ -931,7 +933,7 @@ pub(crate) async fn sync_environment( let markers = interpreter.resolver_markers(); // Add all authenticated sources to the cache. - for url in index_locations.urls() { + for url in index_locations.allowed_urls() { store_credentials_from_url(url); } @@ -1120,7 +1122,7 @@ pub(crate) async fn update_environment( } // Add all authenticated sources to the cache. - for url in index_locations.urls() { + for url in index_locations.allowed_urls() { store_credentials_from_url(url); } @@ -1330,7 +1332,7 @@ fn warn_on_requirements_txt_setting( warn_user_once!("Ignoring `--no-index` from requirements file. Instead, use the `--no-index` command-line argument, or set `no-index` in a `uv.toml` or `pyproject.toml` file."); } else { if let Some(index_url) = index_url { - if settings.index_locations.index() != Some(index_url) { + if settings.index_locations.default_index().map(Index::url) != Some(index_url) { warn_user_once!( "Ignoring `--index-url` from requirements file: `{index_url}`. Instead, use the `--index-url` command-line argument, or set `index-url` in a `uv.toml` or `pyproject.toml` file." ); @@ -1339,8 +1341,8 @@ fn warn_on_requirements_txt_setting( for extra_index_url in extra_index_urls { if !settings .index_locations - .extra_index() - .contains(extra_index_url) + .implicit_indexes() + .any(|index| index.url() == extra_index_url) { warn_user_once!( "Ignoring `--extra-index-url` from requirements file: `{extra_index_url}`. Instead, use the `--extra-index-url` command-line argument, or set `extra-index-url` in a `uv.toml` or `pyproject.toml` file.`" diff --git a/crates/uv/src/commands/project/sync.rs b/crates/uv/src/commands/project/sync.rs index d6a38516f0a40..406574d12c849 100644 --- a/crates/uv/src/commands/project/sync.rs +++ b/crates/uv/src/commands/project/sync.rs @@ -254,7 +254,7 @@ pub(super) async fn do_sync( let resolution = apply_editable_mode(resolution, editable); // Add all authenticated sources to the cache. - for url in index_locations.urls() { + for url in index_locations.allowed_urls() { uv_auth::store_credentials_from_url(url); } diff --git a/crates/uv/src/commands/venv.rs b/crates/uv/src/commands/venv.rs index 46c04c5459e64..af963e47c4305 100644 --- a/crates/uv/src/commands/venv.rs +++ b/crates/uv/src/commands/venv.rs @@ -226,7 +226,7 @@ async fn venv_impl( let interpreter = python.into_interpreter(); // Add all authenticated sources to the cache. - for url in index_locations.urls() { + for url in index_locations.allowed_urls() { store_credentials_from_url(url); } @@ -275,7 +275,7 @@ async fn venv_impl( let interpreter = venv.interpreter(); // Add all authenticated sources to the cache. - for url in index_locations.urls() { + for url in index_locations.allowed_urls() { store_credentials_from_url(url); } diff --git a/crates/uv/tests/lock.rs b/crates/uv/tests/lock.rs index 4bd7921617290..50a17366d5078 100644 --- a/crates/uv/tests/lock.rs +++ b/crates/uv/tests/lock.rs @@ -7537,7 +7537,7 @@ fn lock_local_index() -> Result<()> { [tool.uv] extra-index-url = ["{}"] "#, - Url::from_directory_path(&root).unwrap().as_str() + Url::from_file_path(&root).unwrap().as_str() })?; uv_snapshot!(context.filters(), context.lock().env_remove("UV_EXCLUDE_NEWER"), @r###" @@ -7551,7 +7551,7 @@ fn lock_local_index() -> Result<()> { let lock = fs_err::read_to_string(context.temp_dir.join("uv.lock")).unwrap(); - let index = Url::from_directory_path(&root).unwrap().to_string(); + let index = Url::from_file_path(&root).unwrap().to_string(); let filters = [(index.as_str(), "file://[TMP]")] .into_iter() .chain(context.filters()) @@ -11866,6 +11866,82 @@ fn lock_explicit_index_cli() -> Result<()> { Ok(()) } +/// If a name is reused, the higher-priority index should "overwrite" the lower-priority index. +/// In other words, the lower-priority index should be ignored entirely during implicit resolution. +/// +/// In this test, we should use PyPI (the default index) rather than falling back to Test PyPI, +/// which should be ignored. +#[test] +fn lock_repeat_named_index() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["iniconfig"] + + [[tool.uv.index]] + name = "pytorch" + url = "https://download.pytorch.org/whl/cu121" + + [[tool.uv.index]] + name = "pytorch" + url = "https://test.pypi.org/simple" + "#, + )?; + + uv_snapshot!(context.filters(), context.lock(), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 2 packages in [TIME] + "###); + + let lock = fs_err::read_to_string(context.temp_dir.join("uv.lock")).unwrap(); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r###" + version = 1 + requires-python = ">=3.12" + + [options] + exclude-newer = "2024-03-25T00:00:00Z" + + [[package]] + name = "iniconfig" + version = "2.0.0" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 } + wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 }, + ] + + [[package]] + name = "project" + version = "0.1.0" + source = { virtual = "." } + dependencies = [ + { name = "iniconfig" }, + ] + + [package.metadata] + requires-dist = [{ name = "iniconfig" }] + "### + ); + }); + + Ok(()) +} + /// Lock a project with `package = false`, making it a virtual project. #[test] fn lock_explicit_virtual_project() -> Result<()> { diff --git a/crates/uv/tests/sync.rs b/crates/uv/tests/sync.rs index eb3998cead1e0..bfe6a7daa82e6 100644 --- a/crates/uv/tests/sync.rs +++ b/crates/uv/tests/sync.rs @@ -2803,3 +2803,60 @@ fn sync_no_sources_missing_member() -> Result<()> { Ok(()) } + +#[test] +fn sync_explicit() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "root" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [ + "idna>2", + ] + + [[tool.uv.index]] + name = "test" + url = "https://test.pypi.org/simple" + explicit = true + + [tool.uv.sources] + idna = { index = "test" } + "#, + )?; + + uv_snapshot!(context.filters(), context.sync(), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 2 packages in [TIME] + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + idna==2.7 + "###); + + // Clear the environment. + fs_err::remove_dir_all(&context.venv)?; + + // The package should be drawn from the cache. + uv_snapshot!(context.filters(), context.sync(), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Creating virtual environment at: .venv + Resolved 2 packages in [TIME] + Installed 1 package in [TIME] + + idna==2.7 + "###); + + Ok(()) +}