From c2f8019a74870f5619f39c83e3776280c0bc6954 Mon Sep 17 00:00:00 2001
From: Charlie Marsh
Date: Tue, 17 Sep 2024 15:30:26 -0400
Subject: [PATCH] Add explicit index support
---
Cargo.lock | 3 +
crates/distribution-types/src/index.rs | 146 ++
crates/distribution-types/src/index_url.rs | 203 +-
crates/distribution-types/src/lib.rs | 2 +
crates/distribution-types/src/resolution.rs | 4 +-
crates/pep508-rs/src/verbatim_url.rs | 5 +
crates/pypi-types/src/requirement.rs | 4 +-
crates/uv-cli/src/lib.rs | 73 +-
crates/uv-cli/src/options.rs | 28 +-
crates/uv-client/src/registry_client.rs | 12 +-
crates/uv-distribution/Cargo.toml | 1 +
.../src/index/registry_wheel_index.rs | 65 +-
.../uv-distribution/src/metadata/lowering.rs | 42 +-
.../src/metadata/requires_dist.rs | 16 +
crates/uv-installer/src/plan.rs | 24 +-
crates/uv-resolver/src/error.rs | 3 +
crates/uv-resolver/src/lock/mod.rs | 14 +-
crates/uv-resolver/src/pubgrub/report.rs | 3 +-
crates/uv-resolver/src/resolver/indexes.rs | 65 +
crates/uv-resolver/src/resolver/mod.rs | 33 +-
crates/uv-resolver/src/resolver/provider.rs | 9 +-
crates/uv-scripts/Cargo.toml | 1 +
crates/uv-scripts/src/lib.rs | 7 +-
crates/uv-settings/src/settings.rs | 92 +-
crates/uv-workspace/Cargo.toml | 3 +-
crates/uv-workspace/src/pyproject.rs | 54 +-
crates/uv-workspace/src/workspace.rs | 52 +-
crates/uv/src/commands/build.rs | 2 +-
crates/uv/src/commands/pip/compile.rs | 23 +-
crates/uv/src/commands/pip/install.rs | 15 +-
crates/uv/src/commands/pip/sync.rs | 15 +-
crates/uv/src/commands/project/add.rs | 2 +-
crates/uv/src/commands/project/lock.rs | 2 +-
crates/uv/src/commands/project/mod.rs | 18 +-
crates/uv/src/commands/project/run.rs | 16 +-
crates/uv/src/commands/project/sync.rs | 2 +-
crates/uv/src/commands/venv.rs | 4 +-
crates/uv/src/settings.rs | 58 +-
crates/uv/tests/lock.rs | 441 +++-
crates/uv/tests/pip_install.rs | 2 +-
crates/uv/tests/show_settings.rs | 1851 +++++++++++++----
crates/uv/tests/sync.rs | 57 +
docs/concepts/dependencies.md | 24 +
docs/configuration/environment.md | 7 +-
docs/configuration/index.md | 1 +
docs/configuration/indexes.md | 114 +
docs/pip/compatibility.md | 7 +-
docs/reference/cli.md | 300 ++-
docs/reference/settings.md | 113 +-
mkdocs.template.yml | 1 +
uv.schema.json | 51 +-
51 files changed, 3454 insertions(+), 636 deletions(-)
create mode 100644 crates/distribution-types/src/index.rs
create mode 100644 crates/uv-resolver/src/resolver/indexes.rs
create mode 100644 docs/configuration/indexes.md
diff --git a/Cargo.lock b/Cargo.lock
index 221eb1fafc9b..d338b35442ad 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4804,6 +4804,7 @@ dependencies = [
"either",
"fs-err",
"futures",
+ "indexmap",
"indoc",
"insta",
"nanoid",
@@ -5185,6 +5186,7 @@ dependencies = [
name = "uv-scripts"
version = "0.0.1"
dependencies = [
+ "distribution-types",
"fs-err",
"indoc",
"memchr",
@@ -5329,6 +5331,7 @@ version = "0.0.1"
dependencies = [
"anyhow",
"assert_fs",
+ "distribution-types",
"either",
"fs-err",
"glob",
diff --git a/crates/distribution-types/src/index.rs b/crates/distribution-types/src/index.rs
new file mode 100644
index 000000000000..4d335a8f7302
--- /dev/null
+++ b/crates/distribution-types/src/index.rs
@@ -0,0 +1,146 @@
+use crate::{IndexUrl, IndexUrlError};
+use std::str::FromStr;
+use thiserror::Error;
+use url::Url;
+
+#[derive(Debug, Clone, Hash, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
+#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
+pub struct Index {
+ /// The name of the index.
+ ///
+ /// Index names can be used to reference indexes elsewhere in the configuration. For example,
+ /// you can pin a package to a specific index by name:
+ ///
+ /// ```toml
+ /// [[tool.uv.index]]
+ /// name = "pytorch"
+ /// url = "https://download.pytorch.org/whl/cu121"
+ ///
+ /// [tool.uv.sources]
+ /// torch = { index = "pytorch" }
+ /// ```
+ pub name: Option,
+ /// The URL of the index.
+ ///
+ /// Expects to receive a URL (e.g., `https://pypi.org/simple`) or a local path.
+ pub url: IndexUrl,
+ /// Mark the index as explicit.
+ ///
+ /// Explicit indexes will _only_ be used when explicitly enabled via a `[tool.uv.sources]`
+ /// definition, as in:
+ ///
+ /// ```toml
+ /// [[tool.uv.index]]
+ /// name = "pytorch"
+ /// url = "https://download.pytorch.org/whl/cu121"
+ /// explicit = true
+ ///
+ /// [tool.uv.sources]
+ /// torch = { index = "pytorch" }
+ /// ```
+ #[serde(default)]
+ pub explicit: bool,
+ /// Mark the index as the default index.
+ ///
+ /// By default, uv uses PyPI as the default index, such that even if additional indexes are
+ /// defined via `[[tool.uv.index]]`, PyPI will still be used as a fallback for packages that
+ /// aren't found elsewhere. To disable the PyPI default, set `default = true` on at least one
+ /// other index.
+ ///
+ /// Marking an index as default will move it to the front of the list of indexes, such that it
+ /// is given the highest priority when resolving packages.
+ #[serde(default)]
+ pub default: bool,
+ // /// 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,
+}
+
+// #[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,
+// }
+
+impl Index {
+ /// Initialize an [`Index`] from a pip-style `--index-url`.
+ pub fn from_index_url(url: IndexUrl) -> Self {
+ Self {
+ url,
+ name: None,
+ explicit: false,
+ default: true,
+ }
+ }
+
+ /// Initialize an [`Index`] from a pip-style `--extra-index-url`.
+ pub fn from_extra_index_url(url: IndexUrl) -> Self {
+ Self {
+ url,
+ name: None,
+ explicit: false,
+ 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 {
+ type Err = IndexSourceError;
+
+ fn from_str(s: &str) -> Result {
+ // Determine whether the source is prefixed with a name, as in `name=https://pypi.org/simple`.
+ if let Some((name, url)) = s.split_once('=') {
+ if name.is_empty() {
+ return Err(IndexSourceError::EmptyName);
+ }
+
+ if name.chars().all(char::is_alphanumeric) {
+ let url = IndexUrl::from_str(url)?;
+ return Ok(Self {
+ name: Some(name.to_string()),
+ url,
+ explicit: false,
+ default: false,
+ });
+ }
+ }
+
+ // Otherwise, assume the source is a URL.
+ let url = IndexUrl::from_str(s)?;
+ Ok(Self {
+ name: None,
+ url,
+ explicit: false,
+ default: false,
+ })
+ }
+}
+
+/// An error that can occur when parsing an [`Index`].
+#[derive(Error, Debug)]
+pub enum IndexSourceError {
+ #[error(transparent)]
+ Url(#[from] IndexUrlError),
+ #[error("Index included a name, but the name was empty")]
+ EmptyName,
+}
diff --git a/crates/distribution-types/src/index_url.rs b/crates/distribution-types/src/index_url.rs
index 19175c608c61..c6dcc0ea0ee6 100644
--- a/crates/distribution-types/src/index_url.rs
+++ b/crates/distribution-types/src/index_url.rs
@@ -11,12 +11,13 @@ use url::{ParseError, Url};
use pep508_rs::{VerbatimUrl, VerbatimUrlError};
-use crate::Verbatim;
+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)]
@@ -55,6 +56,15 @@ impl IndexUrl {
}
}
+ /// Convert the index URL into a [`Url`].
+ pub fn into_url(self) -> Url {
+ match self {
+ Self::Pypi(url) => url.into_url(),
+ Self::Url(url) => url.into_url(),
+ Self::Path(url) => url.into_url(),
+ }
+ }
+
/// Return the redacted URL for the index, omitting any sensitive credentials.
pub fn redacted(&self) -> Cow<'_, Url> {
let url = self.url();
@@ -288,39 +298,21 @@ 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`.
-#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
+/// 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 {
- index: Option,
- extra_index: Vec,
+ indexes: Vec,
flat_index: Vec,
no_index: bool,
}
-impl Default for IndexLocations {
- /// By default, use the `PyPI` index.
- fn default() -> Self {
- Self {
- index: Some(DEFAULT_INDEX_URL.clone()),
- extra_index: Vec::new(),
- flat_index: Vec::new(),
- no_index: false,
- }
- }
-}
-
impl IndexLocations {
/// Determine the index URLs to use for fetching packages.
- pub fn new(
- index: Option,
- extra_index: Vec,
- flat_index: Vec,
- no_index: bool,
- ) -> Self {
+ pub fn new(indexes: Vec, flat_index: Vec, no_index: bool) -> Self {
Self {
- index,
- extra_index,
+ indexes,
flat_index,
no_index,
}
@@ -335,14 +327,12 @@ impl IndexLocations {
#[must_use]
pub fn combine(
self,
- index: Option,
- extra_index: Vec,
+ indexes: Vec,
flat_index: Vec,
no_index: bool,
) -> Self {
Self {
- index: self.index.or(index),
- extra_index: self.extra_index.into_iter().chain(extra_index).collect(),
+ indexes: self.indexes.into_iter().chain(indexes).collect(),
flat_index: self.flat_index.into_iter().chain(flat_index).collect(),
no_index: self.no_index || no_index,
}
@@ -351,47 +341,69 @@ impl IndexLocations {
/// Returns `true` if no index configuration is set, i.e., the [`IndexLocations`] matches the
/// default configuration.
pub fn is_none(&self) -> bool {
- self.index.is_none()
- && self.extra_index.is_empty()
- && self.flat_index.is_empty()
- && !self.no_index
+ *self == Self::default()
}
}
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 {
- match self.index.as_ref() {
- Some(index) => Some(index),
- None => Some(&DEFAULT_INDEX_URL),
- }
+ let mut seen = FxHashSet::default();
+ self.indexes
+ .iter()
+ .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.extra_index.iter())
+ 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.
+ ///
+ /// Explicit indexes are excluded.
///
- /// Prioritizes the extra indexes over the main index.
+ /// 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.
@@ -407,88 +419,89 @@ impl<'a> IndexLocations {
/// Clone the index locations into a [`IndexUrls`] instance.
pub fn index_urls(&'a self) -> IndexUrls {
IndexUrls {
- index: self.index.clone(),
- extra_index: self.extra_index.clone(),
+ indexes: self.indexes.clone(),
no_index: self.no_index,
}
}
- /// Return an iterator over all [`Url`] entries.
- pub fn urls(&'a self) -> impl Iterator + 'a {
- self.indexes()
- .map(IndexUrl::url)
- .chain(self.flat_index.iter().filter_map(|index| match index {
- FlatIndexLocation::Path(_) => None,
- FlatIndexLocation::Url(url) => Some(url.raw()),
- }))
+ /// 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`.
-#[derive(Debug, Clone)]
+/// 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 {
- index: Option,
- extra_index: Vec,
+ indexes: Vec,
no_index: bool,
}
-impl Default for IndexUrls {
- /// By default, use the `PyPI` index.
- fn default() -> Self {
- Self {
- index: Some(DEFAULT_INDEX_URL.clone()),
- extra_index: Vec::new(),
- no_index: false,
- }
- }
-}
-
impl<'a> IndexUrls {
- /// Return the fallback [`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 {
- match self.index.as_ref() {
- Some(index) => Some(index),
- None => Some(&DEFAULT_INDEX_URL),
- }
+ let mut seen = FxHashSet::default();
+ self.indexes
+ .iter()
+ .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.extra_index.iter())
+ 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.
///
- /// Prioritizes the extra indexes over the main index.
+ /// Prioritizes the `[tool.uv.index]` definitions over the `--extra-index-url` definitions
+ /// over the `--index-url` definition.
///
/// 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())
- }
-}
-
-impl From for IndexUrls {
- fn from(locations: IndexLocations) -> Self {
- Self {
- index: locations.index,
- extra_index: locations.extra_index,
- no_index: locations.no_index,
- }
+ pub fn indexes(&'a self) -> impl Iterator + 'a {
+ self.implicit_indexes().chain(self.default_index())
}
}
diff --git a/crates/distribution-types/src/lib.rs b/crates/distribution-types/src/lib.rs
index 7c48fb38e050..c5f3abddcd1c 100644
--- a/crates/distribution-types/src/lib.rs
+++ b/crates/distribution-types/src/lib.rs
@@ -57,6 +57,7 @@ pub use crate::error::*;
pub use crate::file::*;
pub use crate::hash::*;
pub use crate::id::*;
+pub use crate::index::*;
pub use crate::index_url::*;
pub use crate::installed::*;
pub use crate::prioritized_distribution::*;
@@ -75,6 +76,7 @@ mod error;
mod file;
mod hash;
mod id;
+mod index;
mod index_url;
mod installed;
mod prioritized_distribution;
diff --git a/crates/distribution-types/src/resolution.rs b/crates/distribution-types/src/resolution.rs
index 8501d4a30855..cfe62b3ba1c3 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/pep508-rs/src/verbatim_url.rs b/crates/pep508-rs/src/verbatim_url.rs
index 9445b13cfd78..d48b979515ab 100644
--- a/crates/pep508-rs/src/verbatim_url.rs
+++ b/crates/pep508-rs/src/verbatim_url.rs
@@ -134,6 +134,11 @@ impl VerbatimUrl {
self.url.clone()
}
+ /// Convert a [`VerbatimUrl`] into a [`Url`].
+ pub fn into_url(self) -> Url {
+ self.url
+ }
+
/// Return the underlying [`Path`], if the URL is a file URL.
pub fn as_path(&self) -> Result {
self.url
diff --git a/crates/pypi-types/src/requirement.rs b/crates/pypi-types/src/requirement.rs
index 2d1854e2fbe8..d600e33c9a91 100644
--- a/crates/pypi-types/src/requirement.rs
+++ b/crates/pypi-types/src/requirement.rs
@@ -319,7 +319,7 @@ pub enum RequirementSource {
Registry {
specifier: VersionSpecifiers,
/// Choose a version from the index with this name.
- index: Option,
+ index: Option,
},
// TODO(konsti): Track and verify version specifier from `project.dependencies` matches the
// version in remote location.
@@ -607,7 +607,7 @@ enum RequirementSourceWire {
Registry {
#[serde(skip_serializing_if = "VersionSpecifiers::is_empty", default)]
specifier: VersionSpecifiers,
- index: Option,
+ index: Option,
},
}
diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs
index c57522ac47ba..074da2589dd2 100644
--- a/crates/uv-cli/src/lib.rs
+++ b/crates/uv-cli/src/lib.rs
@@ -7,7 +7,7 @@ use anyhow::{anyhow, Result};
use clap::builder::styling::{AnsiColor, Effects, Style};
use clap::builder::Styles;
use clap::{Args, Parser, Subcommand};
-use distribution_types::{FlatIndexLocation, IndexUrl};
+use distribution_types::{FlatIndexLocation, Index, IndexUrl};
use pep508_rs::Requirement;
use pypi_types::VerbatimParsedUrl;
use url::Url;
@@ -779,6 +779,36 @@ fn parse_index_url(input: &str) -> Result, String> {
}
}
+/// Parse a string into an [`Index`], mapping the empty string to `None`.
+fn parse_index_source(input: &str) -> Result, String> {
+ if input.is_empty() {
+ Ok(Maybe::None)
+ } else {
+ match Index::from_str(input) {
+ Ok(index) => Ok(Maybe::Some(Index {
+ default: false,
+ ..index
+ })),
+ Err(err) => Err(err.to_string()),
+ }
+ }
+}
+
+/// Parse a string into an [`Index`], mapping the empty string to `None`.
+fn parse_default_index_source(input: &str) -> Result, String> {
+ if input.is_empty() {
+ Ok(Maybe::None)
+ } else {
+ match Index::from_str(input) {
+ Ok(index) => Ok(Maybe::Some(Index {
+ default: true,
+ ..index
+ })),
+ Err(err) => Err(err.to_string()),
+ }
+ }
+}
+
/// Parse a string into an [`Url`], mapping the empty string to `None`.
fn parse_insecure_host(input: &str) -> Result, String> {
if input.is_empty() {
@@ -2236,8 +2266,8 @@ pub struct VenvArgs {
///
/// By default, uv will stop at the first index on which a given package is available, and
/// limit resolutions to those present on that first index (`first-match`). This prevents
- /// "dependency confusion" attacks, whereby an attack can upload a malicious package under the
- /// same name to a secondary.
+ /// "dependency confusion" attacks, whereby an attacker can upload a malicious package under the
+ /// same name to an alternate index.
#[arg(long, value_enum, env = "UV_INDEX_STRATEGY")]
pub index_strategy: Option,
@@ -3694,6 +3724,27 @@ pub struct GenerateShellCompletionArgs {
#[derive(Args)]
#[allow(clippy::struct_excessive_bools)]
pub struct IndexArgs {
+ /// The URLs to use when resolving dependencies, in addition to the default index.
+ ///
+ /// Accepts either a repository compliant with PEP 503 (the simple repository API), or a local
+ /// directory laid out in the same format.
+ ///
+ /// All indexes provided via this flag take priority over the index specified by
+ /// `--default-index` (which defaults to PyPI). When multiple `--index` flags are
+ /// provided, earlier values take priority.
+ #[arg(long, env = "UV_INDEX", value_delimiter = ' ', value_parser = parse_index_source, help_heading = "Index options")]
+ pub index: Option>>,
+
+ /// The URL of the default package index (by default: ).
+ ///
+ /// Accepts either a repository compliant with PEP 503 (the simple repository API), or a local
+ /// directory laid out in the same format.
+ ///
+ /// The index given by this flag is given lower priority than all other indexes specified via
+ /// the `--index` flag.
+ #[arg(long, env = "UV_DEFAULT_INDEX", value_parser = parse_default_index_source, help_heading = "Index options")]
+ pub default_index: Option>,
+
/// The URL of the Python package index (by default: ).
///
/// Accepts either a repository compliant with PEP 503 (the simple repository API), or a local
@@ -3701,6 +3752,8 @@ pub struct IndexArgs {
///
/// The index given by this flag is given lower priority than all other
/// indexes specified via the `--extra-index-url` flag.
+ ///
+ /// (Deprecated: use `--default-index` instead.)
#[arg(long, short, env = "UV_INDEX_URL", value_parser = parse_index_url, help_heading = "Index options")]
pub index_url: Option>,
@@ -3712,6 +3765,8 @@ pub struct IndexArgs {
/// All indexes provided via this flag take priority over the index specified by
/// `--index-url` (which defaults to PyPI). When multiple `--extra-index-url` flags are
/// provided, earlier values take priority.
+ ///
+ /// (Deprecated: use `--index` instead.)
#[arg(long, env = "UV_EXTRA_INDEX_URL", value_delimiter = ' ', value_parser = parse_index_url, help_heading = "Index options")]
pub extra_index_url: Option>>,
@@ -3835,8 +3890,8 @@ pub struct InstallerArgs {
///
/// By default, uv will stop at the first index on which a given package is available, and
/// limit resolutions to those present on that first index (`first-match`). This prevents
- /// "dependency confusion" attacks, whereby an attack can upload a malicious package under the
- /// same name to a secondary.
+ /// "dependency confusion" attacks, whereby an attacker can upload a malicious package under the
+ /// same name to an alternate index.
#[arg(
long,
value_enum,
@@ -3997,8 +4052,8 @@ pub struct ResolverArgs {
///
/// By default, uv will stop at the first index on which a given package is available, and
/// limit resolutions to those present on that first index (`first-match`). This prevents
- /// "dependency confusion" attacks, whereby an attack can upload a malicious package under the
- /// same name to a secondary.
+ /// "dependency confusion" attacks, whereby an attacker can upload a malicious package under the
+ /// same name to an alternate index.
#[arg(
long,
value_enum,
@@ -4189,8 +4244,8 @@ pub struct ResolverInstallerArgs {
///
/// By default, uv will stop at the first index on which a given package is available, and
/// limit resolutions to those present on that first index (`first-match`). This prevents
- /// "dependency confusion" attacks, whereby an attack can upload a malicious package under the
- /// same name to a secondary.
+ /// "dependency confusion" attacks, whereby an attacker can upload a malicious package under the
+ /// same name to an alternate index.
#[arg(
long,
value_enum,
diff --git a/crates/uv-cli/src/options.rs b/crates/uv-cli/src/options.rs
index fb7cd5235c58..6a99f42c7727 100644
--- a/crates/uv-cli/src/options.rs
+++ b/crates/uv-cli/src/options.rs
@@ -1,7 +1,7 @@
use uv_cache::Refresh;
use uv_configuration::ConfigSettings;
use uv_resolver::PrereleaseMode;
-use uv_settings::{PipOptions, ResolverInstallerOptions, ResolverOptions};
+use uv_settings::{Combine, PipOptions, ResolverInstallerOptions, ResolverOptions};
use crate::{
BuildOptionsArgs, IndexArgs, InstallerArgs, Maybe, RefreshArgs, ResolverArgs,
@@ -186,6 +186,8 @@ impl From for PipOptions {
impl From for PipOptions {
fn from(args: IndexArgs) -> Self {
let IndexArgs {
+ default_index,
+ index,
index_url,
extra_index_url,
no_index,
@@ -193,6 +195,12 @@ impl From for PipOptions {
} = args;
Self {
+ index: default_index
+ .and_then(Maybe::into_option)
+ .map(|default_index| vec![default_index])
+ .combine(
+ index.map(|index| index.into_iter().filter_map(Maybe::into_option).collect()),
+ ),
index_url: index_url.and_then(Maybe::into_option),
extra_index_url: extra_index_url.map(|extra_index_urls| {
extra_index_urls
@@ -242,6 +250,15 @@ pub fn resolver_options(
} = build_args;
ResolverOptions {
+ index: index_args
+ .default_index
+ .and_then(Maybe::into_option)
+ .map(|default_index| vec![default_index])
+ .combine(
+ index_args
+ .index
+ .map(|index| index.into_iter().filter_map(Maybe::into_option).collect()),
+ ),
index_url: index_args.index_url.and_then(Maybe::into_option),
extra_index_url: index_args.extra_index_url.map(|extra_index_urls| {
extra_index_urls
@@ -325,7 +342,16 @@ pub fn resolver_installer_options(
no_binary_package,
} = build_args;
+ let default_index = index_args
+ .default_index
+ .and_then(Maybe::into_option)
+ .map(|default_index| vec![default_index]);
+ let index = index_args
+ .index
+ .map(|index| index.into_iter().filter_map(Maybe::into_option).collect());
+
ResolverInstallerOptions {
+ index: default_index.combine(index),
index_url: index_args.index_url.and_then(Maybe::into_option),
extra_index_url: index_args.extra_index_url.map(|extra_index_urls| {
extra_index_urls
diff --git a/crates/uv-client/src/registry_client.rs b/crates/uv-client/src/registry_client.rs
index 392e17647cf5..57b7f1d61586 100644
--- a/crates/uv-client/src/registry_client.rs
+++ b/crates/uv-client/src/registry_client.rs
@@ -6,6 +6,7 @@ use std::str::FromStr;
use async_http_range_reader::AsyncHttpRangeReader;
use futures::{FutureExt, TryStreamExt};
use http::HeaderMap;
+use itertools::Either;
use reqwest::{Client, Response, StatusCode};
use reqwest_middleware::ClientWithMiddleware;
use tracing::{info_span, instrument, trace, warn, Instrument};
@@ -13,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;
@@ -204,8 +205,15 @@ impl RegistryClient {
pub async fn simple(
&self,
package_name: &PackageName,
+ index: Option<&IndexUrl>,
) -> Result)>, Error> {
- let mut it = self.index_urls.indexes().peekable();
+ let indexes = if let Some(index) = index {
+ Either::Left(std::iter::once(index))
+ } else {
+ Either::Right(self.index_urls.indexes().map(Index::url))
+ };
+
+ let mut it = indexes.peekable();
if it.peek().is_none() {
return Err(ErrorKind::NoIndex(package_name.to_string()).into());
}
diff --git a/crates/uv-distribution/Cargo.toml b/crates/uv-distribution/Cargo.toml
index 868673c10e95..26910ddc0dad 100644
--- a/crates/uv-distribution/Cargo.toml
+++ b/crates/uv-distribution/Cargo.toml
@@ -37,6 +37,7 @@ anyhow = { workspace = true }
either = { 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 38a8373458b9..1e3e1d7e226d 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-distribution/src/metadata/lowering.rs b/crates/uv-distribution/src/metadata/lowering.rs
index ad1109691b81..bb6d08ebba29 100644
--- a/crates/uv-distribution/src/metadata/lowering.rs
+++ b/crates/uv-distribution/src/metadata/lowering.rs
@@ -6,6 +6,7 @@ use thiserror::Error;
use url::Url;
use distribution_filename::DistExtension;
+use distribution_types::Index;
use pep440_rs::VersionSpecifiers;
use pep508_rs::{MarkerTree, VerbatimUrl, VersionOrUrl};
use pypi_types::{ParsedUrlError, Requirement, RequirementSource, VerbatimParsedUrl};
@@ -33,6 +34,7 @@ impl LoweredRequirement {
project_name: &'data PackageName,
project_dir: &'data Path,
project_sources: &'data BTreeMap,
+ project_indexes: &'data [Index],
workspace: &'data Workspace,
) -> impl Iterator> + 'data {
let (source, origin) = if let Some(source) = project_sources.get(&requirement.name) {
@@ -151,7 +153,26 @@ impl LoweredRequirement {
(source, marker)
}
Source::Registry { index, marker } => {
- let source = registry_source(&requirement, index)?;
+ // Identify the named index from either the project indexes or the workspace indexes,
+ // in that order.
+ let Some(index) = project_indexes
+ .iter()
+ .find(|Index { name, .. }| {
+ name.as_ref().is_some_and(|name| *name == index)
+ })
+ .or_else(|| {
+ workspace.indexes().iter().find(|Index { name, .. }| {
+ name.as_ref().is_some_and(|name| *name == index)
+ })
+ })
+ .map(|Index { url: index, .. }| index.clone())
+ else {
+ return Err(LoweringError::MissingIndex(
+ requirement.name.clone(),
+ index,
+ ));
+ };
+ let source = registry_source(&requirement, index.into_url())?;
let marker = marker.map(MarkerTree::from).unwrap_or_default();
(source, marker)
}
@@ -244,6 +265,7 @@ impl LoweredRequirement {
requirement: pep508_rs::Requirement,
dir: &'data Path,
sources: &'data BTreeMap,
+ indexes: &'data [Index],
) -> impl Iterator> + 'data {
let source = sources.get(&requirement.name).cloned();
@@ -327,7 +349,19 @@ impl LoweredRequirement {
(source, marker)
}
Source::Registry { index, marker } => {
- let source = registry_source(&requirement, index)?;
+ let Some(index) = indexes
+ .iter()
+ .find(|Index { name, .. }| {
+ name.as_ref().is_some_and(|name| *name == index)
+ })
+ .map(|Index { url: index, .. }| index.clone())
+ else {
+ return Err(LoweringError::MissingIndex(
+ requirement.name.clone(),
+ index,
+ ));
+ };
+ let source = registry_source(&requirement, index.into_url())?;
let marker = marker.map(MarkerTree::from).unwrap_or_default();
(source, marker)
}
@@ -375,6 +409,8 @@ pub enum LoweringError {
MoreThanOneGitRef,
#[error("Unable to combine options in `tool.uv.sources`")]
InvalidEntry,
+ #[error("Package `{0}` references an undeclared index: `{1}`")]
+ MissingIndex(PackageName, String),
#[error("Workspace members are not allowed in non-workspace contexts")]
WorkspaceMember,
#[error(transparent)]
@@ -465,7 +501,7 @@ fn url_source(url: Url, subdirectory: Option) -> Result,
- index: String,
+ index: Url,
) -> Result {
match &requirement.version_or_url {
None => {
diff --git a/crates/uv-distribution/src/metadata/requires_dist.rs b/crates/uv-distribution/src/metadata/requires_dist.rs
index 2e98c9465ecb..e1d81ce5fbf1 100644
--- a/crates/uv-distribution/src/metadata/requires_dist.rs
+++ b/crates/uv-distribution/src/metadata/requires_dist.rs
@@ -56,6 +56,20 @@ impl RequiresDist {
project_workspace: &ProjectWorkspace,
source_strategy: SourceStrategy,
) -> Result {
+ // Collect any `tool.uv.index` entries.
+ let empty = vec![];
+ let indexes = match source_strategy {
+ SourceStrategy::Enabled => project_workspace
+ .current_project()
+ .pyproject_toml()
+ .tool
+ .as_ref()
+ .and_then(|tool| tool.uv.as_ref())
+ .and_then(|uv| uv.index.as_deref())
+ .unwrap_or(&empty),
+ SourceStrategy::Disabled => &empty,
+ };
+
// Collect any `tool.uv.sources` and `tool.uv.dev_dependencies` from `pyproject.toml`.
let empty = BTreeMap::default();
let sources = match source_strategy {
@@ -91,6 +105,7 @@ impl RequiresDist {
&metadata.name,
project_workspace.project_root(),
sources,
+ indexes,
project_workspace.workspace(),
)
.map(move |requirement| match requirement {
@@ -123,6 +138,7 @@ impl RequiresDist {
&metadata.name,
project_workspace.project_root(),
sources,
+ indexes,
project_workspace.workspace(),
)
.map(move |requirement| match requirement {
diff --git a/crates/uv-installer/src/plan.rs b/crates/uv-installer/src/plan.rs
index f6562d906462..3e2831f76c9a 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/error.rs b/crates/uv-resolver/src/error.rs
index b5e5bdfc9f41..b6bc323ddf7e 100644
--- a/crates/uv-resolver/src/error.rs
+++ b/crates/uv-resolver/src/error.rs
@@ -52,6 +52,9 @@ pub enum ResolveError {
fork_markers: MarkerTree,
},
+ #[error("Requirements contain conflicting indexes for package `{0}`: `{1}` vs. `{2}`")]
+ ConflictingIndexes(PackageName, String, String),
+
#[error("Package `{0}` attempted to resolve via URL: {1}. URL dependencies must be expressed as direct requirements or constraints. Consider adding `{0} @ {1}` to your dependencies or constraints file.")]
DisallowedUrl(PackageName, String),
diff --git a/crates/uv-resolver/src/lock/mod.rs b/crates/uv-resolver/src/lock/mod.rs
index 06a0f03d36af..a238c16b3f1c 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 f272020bcbb5..b4922cb9331f 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-resolver/src/resolver/indexes.rs b/crates/uv-resolver/src/resolver/indexes.rs
new file mode 100644
index 000000000000..1a0b94c2b3ec
--- /dev/null
+++ b/crates/uv-resolver/src/resolver/indexes.rs
@@ -0,0 +1,65 @@
+use crate::{DependencyMode, Manifest, ResolveError, ResolverMarkers};
+use distribution_types::IndexUrl;
+use pep508_rs::{PackageName, VerbatimUrl};
+use pypi_types::RequirementSource;
+use rustc_hash::FxHashMap;
+use std::collections::hash_map::Entry;
+
+/// A map of package names to their explicit index.
+///
+/// For example, given:
+/// ```toml
+/// [[tool.uv.index]]
+/// name = "pytorch"
+/// url = "https://download.pytorch.org/whl/cu121"
+///
+/// [tool.uv.sources]
+/// torch = { index = "pytorch" }
+/// ```
+///
+/// [`Indexes`] would contain a single entry mapping `torch` to `https://download.pytorch.org/whl/cu121`.
+#[derive(Debug, Default, Clone)]
+pub(crate) struct Indexes(FxHashMap);
+
+impl Indexes {
+ /// Determine the set of explicit, pinned indexes in the [`Manifest`].
+ pub(crate) fn from_manifest(
+ manifest: &Manifest,
+ markers: &ResolverMarkers,
+ dependencies: DependencyMode,
+ ) -> Result {
+ let mut indexes = FxHashMap::::default();
+
+ for requirement in manifest.requirements(markers, dependencies) {
+ let RequirementSource::Registry {
+ index: Some(index), ..
+ } = &requirement.source
+ else {
+ continue;
+ };
+ let index = IndexUrl::from(VerbatimUrl::from_url(index.clone()));
+ match indexes.entry(requirement.name.clone()) {
+ Entry::Occupied(entry) => {
+ let existing = entry.get();
+ if *existing != index {
+ return Err(ResolveError::ConflictingIndexes(
+ requirement.name.clone(),
+ existing.to_string(),
+ index.to_string(),
+ ));
+ }
+ }
+ Entry::Vacant(entry) => {
+ entry.insert(index);
+ }
+ }
+ }
+
+ Ok(Self(indexes))
+ }
+
+ /// Return the explicit index for a given [`PackageName`].
+ pub(crate) fn get(&self, package_name: &PackageName) -> Option<&IndexUrl> {
+ self.0.get(package_name)
+ }
+}
diff --git a/crates/uv-resolver/src/resolver/mod.rs b/crates/uv-resolver/src/resolver/mod.rs
index 29c96fe23b4a..3952e02005c6 100644
--- a/crates/uv-resolver/src/resolver/mod.rs
+++ b/crates/uv-resolver/src/resolver/mod.rs
@@ -22,8 +22,9 @@ use tracing::{debug, info, instrument, trace, warn, Level};
use distribution_types::{
BuiltDist, CompatibleDist, Dist, DistributionMetadata, IncompatibleDist, IncompatibleSource,
- IncompatibleWheel, IndexCapabilities, IndexLocations, InstalledDist, PythonRequirementKind,
- RemoteSource, ResolvedDist, ResolvedDistRef, SourceDist, VersionOrUrlRef,
+ IncompatibleWheel, IndexCapabilities, IndexLocations, IndexUrl, InstalledDist,
+ PythonRequirementKind, RemoteSource, ResolvedDist, ResolvedDistRef, SourceDist,
+ VersionOrUrlRef,
};
pub(crate) use fork_map::{ForkMap, ForkSet};
use locals::Locals;
@@ -60,6 +61,7 @@ pub(crate) use crate::resolver::availability::{
use crate::resolver::batch_prefetch::BatchPrefetcher;
use crate::resolver::groups::Groups;
pub use crate::resolver::index::InMemoryIndex;
+use crate::resolver::indexes::Indexes;
pub use crate::resolver::provider::{
DefaultResolverProvider, MetadataResponse, PackageVersionsResult, ResolverProvider,
VersionsResponse, WheelMetadataResult,
@@ -74,6 +76,7 @@ mod batch_prefetch;
mod fork_map;
mod groups;
mod index;
+mod indexes;
mod locals;
mod provider;
mod reporter;
@@ -99,6 +102,7 @@ struct ResolverState {
exclusions: Exclusions,
urls: Urls,
locals: Locals,
+ indexes: Indexes,
dependency_mode: DependencyMode,
hasher: HashStrategy,
markers: ResolverMarkers,
@@ -201,6 +205,7 @@ impl
dependency_mode: options.dependency_mode,
urls: Urls::from_manifest(&manifest, &markers, git, options.dependency_mode)?,
locals: Locals::from_manifest(&manifest, &markers, options.dependency_mode),
+ indexes: Indexes::from_manifest(&manifest, &markers, options.dependency_mode)?,
groups: Groups::from_manifest(&manifest, &markers),
project: manifest.project,
workspace_members: manifest.workspace_members,
@@ -376,7 +381,9 @@ impl ResolverState ResolverState ResolverState ResolverState ResolverState,
+ index: Option<&IndexUrl>,
request_sink: &Sender,
) -> Result<(), ResolveError> {
// Ignore unresolved URL packages.
@@ -731,13 +741,14 @@ impl ResolverState,
+ index: Option<&IndexUrl>,
request_sink: &Sender,
) -> Result<(), ResolveError> {
// Only request real package
@@ -759,7 +770,7 @@ impl ResolverState ResolverState Result
May also be set with the UV_CONSTRAINT environment variable.
+
--default-indexdefault-index
The URL of the default package index (by default: <https://pypi.org/simple>).
+
+
Accepts either a repository compliant with PEP 503 (the simple repository API), or a local directory laid out in the same format.
+
+
The index given by this flag is given lower priority than all other indexes specified via the --index flag.
+
+
May also be set with the UV_DEFAULT_INDEX environment variable.
--directorydirectory
Change to the given directory prior to running the command.
Relative paths are resolved with the given directory as the base.
@@ -5430,6 +5653,8 @@ uv pip install [OPTIONS] |--editable All indexes provided via this flag take priority over the index specified by --index-url (which defaults to PyPI). When multiple --extra-index-url flags are provided, earlier values take priority.
+
(Deprecated: use --index instead.)
+
May also be set with the UV_EXTRA_INDEX_URL environment variable.
--find-links, -ffind-links
Locations to search for candidate distributions, in addition to those found in the registry indexes.
The URLs to use when resolving dependencies, in addition to the default index.
+
+
Accepts either a repository compliant with PEP 503 (the simple repository API), or a local directory laid out in the same format.
+
+
All indexes provided via this flag take priority over the index specified by --default-index (which defaults to PyPI). When multiple --index flags are provided, earlier values take priority.
+
+
May also be set with the UV_INDEX environment variable.
--index-strategyindex-strategy
The strategy to use when resolving against multiple index URLs.
-
By default, uv will stop at the first index on which a given package is available, and limit resolutions to those present on that first index (first-match). This prevents "dependency confusion" attacks, whereby an attack can upload a malicious package under the same name to a secondary.
+
By default, uv will stop at the first index on which a given package is available, and limit resolutions to those present on that first index (first-match). This prevents "dependency confusion" attacks, whereby an attacker can upload a malicious package under the same name to an alternate index.
May also be set with the UV_INDEX_STRATEGY environment variable.
Possible values:
@@ -5459,6 +5691,8 @@ uv pip install [OPTIONS] |--editable The index given by this flag is given lower priority than all other indexes specified via the --extra-index-url flag.
+
(Deprecated: use --default-index instead.)
+
May also be set with the UV_INDEX_URL environment variable.
--keyring-providerkeyring-provider
Attempt to use keyring for authentication for index URLs.
@@ -6582,6 +6816,13 @@ uv venv [OPTIONS] [PATH]
While uv configuration can be included in a pyproject.toml file, it is not allowed in this context.
May also be set with the UV_CONFIG_FILE environment variable.
+
--default-indexdefault-index
The URL of the default package index (by default: <https://pypi.org/simple>).
+
+
Accepts either a repository compliant with PEP 503 (the simple repository API), or a local directory laid out in the same format.
+
+
The index given by this flag is given lower priority than all other indexes specified via the --index flag.
+
+
May also be set with the UV_DEFAULT_INDEX environment variable.
--directorydirectory
Change to the given directory prior to running the command.
Relative paths are resolved with the given directory as the base.
@@ -6599,6 +6840,8 @@ uv venv [OPTIONS] [PATH]
All indexes provided via this flag take priority over the index specified by --index-url (which defaults to PyPI). When multiple --extra-index-url flags are provided, earlier values take priority.
+
(Deprecated: use --index instead.)
+
May also be set with the UV_EXTRA_INDEX_URL environment variable.
--find-links, -ffind-links
Locations to search for candidate distributions, in addition to those found in the registry indexes.
@@ -6608,9 +6851,16 @@ uv venv [OPTIONS] [PATH]
--help, -h
Display the concise help for this command
+
--indexindex
The URLs to use when resolving dependencies, in addition to the default index.
+
+
Accepts either a repository compliant with PEP 503 (the simple repository API), or a local directory laid out in the same format.
+
+
All indexes provided via this flag take priority over the index specified by --default-index (which defaults to PyPI). When multiple --index flags are provided, earlier values take priority.
+
+
May also be set with the UV_INDEX environment variable.
--index-strategyindex-strategy
The strategy to use when resolving against multiple index URLs.
-
By default, uv will stop at the first index on which a given package is available, and limit resolutions to those present on that first index (first-match). This prevents "dependency confusion" attacks, whereby an attack can upload a malicious package under the same name to a secondary.
+
By default, uv will stop at the first index on which a given package is available, and limit resolutions to those present on that first index (first-match). This prevents "dependency confusion" attacks, whereby an attacker can upload a malicious package under the same name to an alternate index.
May also be set with the UV_INDEX_STRATEGY environment variable.
Possible values:
@@ -6628,6 +6878,8 @@ uv venv [OPTIONS] [PATH]
The index given by this flag is given lower priority than all other indexes specified via the --extra-index-url flag.
+
(Deprecated: use --default-index instead.)
+
May also be set with the UV_INDEX_URL environment variable.
--keyring-providerkeyring-provider
Attempt to use keyring for authentication for index URLs.
@@ -6829,6 +7081,13 @@ uv build [OPTIONS] [SRC]
May also be set with the UV_CONFIG_FILE environment variable.
--config-setting, -Cconfig-setting
Settings to pass to the PEP 517 build backend, specified as KEY=VALUE pairs
+
--default-indexdefault-index
The URL of the default package index (by default: <https://pypi.org/simple>).
+
+
Accepts either a repository compliant with PEP 503 (the simple repository API), or a local directory laid out in the same format.
+
+
The index given by this flag is given lower priority than all other indexes specified via the --index flag.
+
+
May also be set with the UV_DEFAULT_INDEX environment variable.
--directorydirectory
Change to the given directory prior to running the command.
Relative paths are resolved with the given directory as the base.
@@ -6846,6 +7105,8 @@ uv build [OPTIONS] [SRC]
All indexes provided via this flag take priority over the index specified by --index-url (which defaults to PyPI). When multiple --extra-index-url flags are provided, earlier values take priority.
+
(Deprecated: use --index instead.)
+
May also be set with the UV_EXTRA_INDEX_URL environment variable.
--find-links, -ffind-links
Locations to search for candidate distributions, in addition to those found in the registry indexes.
@@ -6855,9 +7116,16 @@ uv build [OPTIONS] [SRC]
--help, -h
Display the concise help for this command
+
--indexindex
The URLs to use when resolving dependencies, in addition to the default index.
+
+
Accepts either a repository compliant with PEP 503 (the simple repository API), or a local directory laid out in the same format.
+
+
All indexes provided via this flag take priority over the index specified by --default-index (which defaults to PyPI). When multiple --index flags are provided, earlier values take priority.
+
+
May also be set with the UV_INDEX environment variable.
--index-strategyindex-strategy
The strategy to use when resolving against multiple index URLs.
-
By default, uv will stop at the first index on which a given package is available, and limit resolutions to those present on that first index (first-match). This prevents "dependency confusion" attacks, whereby an attack can upload a malicious package under the same name to a secondary.
+
By default, uv will stop at the first index on which a given package is available, and limit resolutions to those present on that first index (first-match). This prevents "dependency confusion" attacks, whereby an attacker can upload a malicious package under the same name to an alternate index.
May also be set with the UV_INDEX_STRATEGY environment variable.
Possible values:
@@ -6875,6 +7143,8 @@ uv build [OPTIONS] [SRC]
The index given by this flag is given lower priority than all other indexes specified via the --extra-index-url flag.
+
(Deprecated: use --default-index instead.)
+
May also be set with the UV_INDEX_URL environment variable.
--keyring-providerkeyring-provider
Attempt to use keyring for authentication for index URLs.
diff --git a/docs/reference/settings.md b/docs/reference/settings.md
index 4e05ccb40aa9..03e7dd2c3c8a 100644
--- a/docs/reference/settings.md
+++ b/docs/reference/settings.md
@@ -73,6 +73,50 @@ environments = ["sys_platform == 'darwin'"]
---
+### [`index`](#index) {: #index }
+
+The indexes to use when resolving dependencies.
+
+Accepts either a repository compliant with [PEP 503](https://peps.python.org/pep-0503/)
+(the simple repository API), or a local directory laid out in the same format.
+
+Indexes are considered in the order in which they're defined, such that the first-defined
+index has the highest priority. Further, the indexes provided by this setting are given
+higher priority than any indexes specified via [`index_url`](#index-url) or
+[`extra_index_url`](#extra-index-url).
+
+If an index is marked as `explicit = true`, it will be used exclusively for those
+dependencies that select it explicitly via `[tool.uv.sources]`, as in:
+
+```toml
+[[tool.uv.index]]
+name = "pytorch"
+url = "https://download.pytorch.org/whl/cu121"
+explicit = true
+
+[tool.uv.sources]
+torch = { index = "pytorch" }
+```
+
+If an index is marked as `default = true`, it will be moved to the front of the list of
+the list of indexes, such that it is given the highest priority when resolving packages.
+Additionally, marking an index as default will disable the PyPI default index.
+
+**Default value**: `"[]"`
+
+**Type**: `dict`
+
+**Example usage**:
+
+```toml title="pyproject.toml"
+[tool.uv]
+[[tool.uv.index]]
+name = "pytorch"
+url = "https://download.pytorch.org/whl/cu121"
+```
+
+---
+
### [`managed`](#managed) {: #managed }
Whether the project is managed by uv. If `false`, uv will ignore the project when
@@ -533,11 +577,14 @@ Accepts either a repository compliant with [PEP 503](https://peps.python.org/pep
(the simple repository API), or a local directory laid out in the same format.
All indexes provided via this flag take priority over the index specified by
-[`index_url`](#index-url). When multiple indexes are provided, earlier values take priority.
+[`index_url`](#index-url) or [`index`](#index) with `default = true`. When multiple indexes
+are provided, earlier values take priority.
To control uv's resolution strategy when multiple indexes are present, see
[`index_strategy`](#index-strategy).
+(Deprecated: use `index` instead.)
+
**Default value**: `[]`
**Type**: `list[str]`
@@ -591,14 +638,66 @@ formats described above.
---
+### [`index`](#index) {: #index }
+
+The package indexes to use when resolving dependencies.
+
+Accepts either a repository compliant with [PEP 503](https://peps.python.org/pep-0503/)
+(the simple repository API), or a local directory laid out in the same format.
+
+Indexes are considered in the order in which they're defined, such that the first-defined
+index has the highest priority.
+
+If an index is marked as `explicit = true`, it will be used exclusively for those
+dependencies that select it explicitly via `[tool.uv.sources]`, as in:
+
+```toml
+[[tool.uv.index]]
+name = "pytorch"
+url = "https://download.pytorch.org/whl/cu121"
+explicit = true
+
+[tool.uv.sources]
+torch = { index = "pytorch" }
+```
+
+Marking an index as `default = true` will disable the PyPI default index and move the
+index to the end of the prioritized list, such that it is used when a package is not found
+on any other index.
+
+**Default value**: `"[]"`
+
+**Type**: `dict`
+
+**Example usage**:
+
+=== "pyproject.toml"
+
+ ```toml
+ [tool.uv]
+ [[tool.uv.index]]
+ name = "pytorch"
+ url = "https://download.pytorch.org/whl/cu121"
+ ```
+=== "uv.toml"
+
+ ```toml
+
+ [[tool.uv.index]]
+ name = "pytorch"
+ url = "https://download.pytorch.org/whl/cu121"
+ ```
+
+---
+
### [`index-strategy`](#index-strategy) {: #index-strategy }
The strategy to use when resolving against multiple index URLs.
By default, uv will stop at the first index on which a given package is available, and
limit resolutions to those present on that first index (`first-match`). This prevents
-"dependency confusion" attacks, whereby an attack can upload a malicious package under the
-same name to a secondary.
+"dependency confusion" attacks, whereby an attacker can upload a malicious package under the
+same name to an alternate index.
**Default value**: `"first-index"`
@@ -633,7 +732,9 @@ Accepts either a repository compliant with [PEP 503](https://peps.python.org/pep
(the simple repository API), or a local directory laid out in the same format.
The index provided by this setting is given lower priority than any indexes specified via
-[`extra_index_url`](#extra-index-url).
+[`extra_index_url`](#extra-index-url) or [`index`](#index).
+
+(Deprecated: use `index` instead.)
**Default value**: `"https://pypi.org/simple"`
@@ -1911,8 +2012,8 @@ The strategy to use when resolving against multiple index URLs.
By default, uv will stop at the first index on which a given package is available, and
limit resolutions to those present on that first index (`first-match`). This prevents
-"dependency confusion" attacks, whereby an attack can upload a malicious package under the
-same name to a secondary.
+"dependency confusion" attacks, whereby an attacker can upload a malicious package under the
+same name to an alternate index.
**Default value**: `"first-index"`
diff --git a/mkdocs.template.yml b/mkdocs.template.yml
index db95f32ae44c..3c61340eea27 100644
--- a/mkdocs.template.yml
+++ b/mkdocs.template.yml
@@ -106,6 +106,7 @@ nav:
- Configuration files: configuration/files.md
- Environment variables: configuration/environment.md
- Authentication: configuration/authentication.md
+ - Package indexes: configuration/indexes.md
- Integration guides:
- guides/integration/index.md
- Docker: guides/integration/docker.md
diff --git a/uv.schema.json b/uv.schema.json
index 3b1b61cd7106..a83af9eeba5f 100644
--- a/uv.schema.json
+++ b/uv.schema.json
@@ -128,7 +128,7 @@
]
},
"extra-index-url": {
- "description": "Extra URLs of package indexes to use, in addition to `--index-url`.\n\nAccepts either a repository compliant with [PEP 503](https://peps.python.org/pep-0503/) (the simple repository API), or a local directory laid out in the same format.\n\nAll indexes provided via this flag take priority over the index specified by [`index_url`](#index-url). When multiple indexes are provided, earlier values take priority.\n\nTo control uv's resolution strategy when multiple indexes are present, see [`index_strategy`](#index-strategy).",
+ "description": "Extra URLs of package indexes to use, in addition to `--index-url`.\n\nAccepts either a repository compliant with [PEP 503](https://peps.python.org/pep-0503/) (the simple repository API), or a local directory laid out in the same format.\n\nAll indexes provided via this flag take priority over the index specified by [`index_url`](#index-url) or [`index`](#index) with `default = true`. When multiple indexes are provided, earlier values take priority.\n\nTo control uv's resolution strategy when multiple indexes are present, see [`index_strategy`](#index-strategy).\n\n(Deprecated: use `index` instead.)",
"type": [
"array",
"null"
@@ -147,8 +147,18 @@
"$ref": "#/definitions/FlatIndexLocation"
}
},
+ "index": {
+ "description": "The indexes to use when resolving dependencies.\n\nAccepts either a repository compliant with [PEP 503](https://peps.python.org/pep-0503/) (the simple repository API), or a local directory laid out in the same format.\n\nIndexes are considered in the order in which they're defined, such that the first-defined index has the highest priority. Further, the indexes provided by this setting are given higher priority than any indexes specified via [`index_url`](#index-url) or [`extra_index_url`](#extra-index-url).\n\nIf an index is marked as `explicit = true`, it will be used exclusively for those dependencies that select it explicitly via `[tool.uv.sources]`, as in:\n\n```toml [[tool.uv.index]] name = \"pytorch\" url = \"https://download.pytorch.org/whl/cu121\" explicit = true\n\n[tool.uv.sources] torch = { index = \"pytorch\" } ```\n\nIf an index is marked as `default = true`, it will be moved to the front of the list of the list of indexes, such that it is given the highest priority when resolving packages. Additionally, marking an index as default will disable the PyPI default index.",
+ "type": [
+ "array",
+ "null"
+ ],
+ "items": {
+ "$ref": "#/definitions/Index"
+ }
+ },
"index-strategy": {
- "description": "The strategy to use when resolving against multiple index URLs.\n\nBy default, uv will stop at the first index on which a given package is available, and limit resolutions to those present on that first index (`first-match`). This prevents \"dependency confusion\" attacks, whereby an attack can upload a malicious package under the same name to a secondary.",
+ "description": "The strategy to use when resolving against multiple index URLs.\n\nBy default, uv will stop at the first index on which a given package is available, and limit resolutions to those present on that first index (`first-match`). This prevents \"dependency confusion\" attacks, whereby an attacker can upload a malicious package under the same name to an alternate index.",
"anyOf": [
{
"$ref": "#/definitions/IndexStrategy"
@@ -159,7 +169,7 @@
]
},
"index-url": {
- "description": "The URL of the Python package index (by default: ).\n\nAccepts either a repository compliant with [PEP 503](https://peps.python.org/pep-0503/) (the simple repository API), or a local directory laid out in the same format.\n\nThe index provided by this setting is given lower priority than any indexes specified via [`extra_index_url`](#extra-index-url).",
+ "description": "The URL of the Python package index (by default: ).\n\nAccepts either a repository compliant with [PEP 503](https://peps.python.org/pep-0503/) (the simple repository API), or a local directory laid out in the same format.\n\nThe index provided by this setting is given lower priority than any indexes specified via [`extra_index_url`](#extra-index-url) or [`index`](#index).\n\n(Deprecated: use `index` instead.)",
"anyOf": [
{
"$ref": "#/definitions/IndexUrl"
@@ -545,6 +555,39 @@
"description": "The path to a directory of distributions, or a URL to an HTML file with a flat listing of distributions.",
"type": "string"
},
+ "Index": {
+ "type": "object",
+ "required": [
+ "url"
+ ],
+ "properties": {
+ "default": {
+ "description": "Mark the index as the default index.\n\nBy default, uv uses PyPI as the default index, such that even if additional indexes are defined via `[[tool.uv.index]]`, PyPI will still be used as a fallback for packages that aren't found elsewhere. To disable the PyPI default, set `default = true` on at least one other index.\n\nMarking an index as default will move it to the front of the list of indexes, such that it is given the highest priority when resolving packages.",
+ "default": false,
+ "type": "boolean"
+ },
+ "explicit": {
+ "description": "Mark the index as explicit.\n\nExplicit indexes will _only_ be used when explicitly enabled via a `[tool.uv.sources]` definition, as in:\n\n```toml [[tool.uv.index]] name = \"pytorch\" url = \"https://download.pytorch.org/whl/cu121\" explicit = true\n\n[tool.uv.sources] torch = { index = \"pytorch\" } ```",
+ "default": false,
+ "type": "boolean"
+ },
+ "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\" } ```",
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "url": {
+ "description": "The URL of the index.\n\nExpects to receive a URL (e.g., `https://pypi.org/simple`) or a local path.",
+ "allOf": [
+ {
+ "$ref": "#/definitions/IndexUrl"
+ }
+ ]
+ }
+ }
+ },
"IndexStrategy": {
"oneOf": [
{
@@ -803,7 +846,7 @@
]
},
"index-strategy": {
- "description": "The strategy to use when resolving against multiple index URLs.\n\nBy default, uv will stop at the first index on which a given package is available, and limit resolutions to those present on that first index (`first-match`). This prevents \"dependency confusion\" attacks, whereby an attack can upload a malicious package under the same name to a secondary.",
+ "description": "The strategy to use when resolving against multiple index URLs.\n\nBy default, uv will stop at the first index on which a given package is available, and limit resolutions to those present on that first index (`first-match`). This prevents \"dependency confusion\" attacks, whereby an attacker can upload a malicious package under the same name to an alternate index.",
"anyOf": [
{
"$ref": "#/definitions/IndexStrategy"