diff --git a/crates/uv-workspace/src/pyproject.rs b/crates/uv-workspace/src/pyproject.rs index 173fad18d..a3c6383c9 100644 --- a/crates/uv-workspace/src/pyproject.rs +++ b/crates/uv-workspace/src/pyproject.rs @@ -14,6 +14,7 @@ use std::str::FromStr; use glob::Pattern; use owo_colors::OwoColorize; +use rustc_hash::{FxBuildHasher, FxHashSet}; use serde::{de::IntoDeserializer, de::SeqAccess, Deserialize, Deserializer, Serialize}; use thiserror::Error; use url::Url; @@ -280,6 +281,30 @@ pub struct Tool { pub uv: Option, } +/// Validates that index names in the `tool.uv.index` field are unique. +/// +/// This custom deserializer function checks for duplicate index names +/// and returns an error if any duplicates are found. +fn deserialize_index_vec<'de, D>(deserializer: D) -> Result>, D::Error> +where + D: Deserializer<'de>, +{ + let indexes = Option::>::deserialize(deserializer)?; + if let Some(indexes) = indexes.as_ref() { + let mut seen_names = FxHashSet::with_capacity_and_hasher(indexes.len(), FxBuildHasher); + for index in indexes { + if let Some(name) = index.name.as_ref() { + if !seen_names.insert(name) { + return Err(serde::de::Error::custom(format!( + "duplicate index name `{name}`" + ))); + } + } + } + } + Ok(indexes) +} + // NOTE(charlie): When adding fields to this struct, mark them as ignored on `Options` in // `crates/uv-settings/src/settings.rs`. #[derive(Deserialize, OptionsMetadata, Debug, Clone, PartialEq, Eq)] @@ -342,6 +367,7 @@ pub struct ToolUv { url = "https://download.pytorch.org/whl/cu121" "# )] + #[serde(deserialize_with = "deserialize_index_vec", default)] pub index: Option>, /// The workspace definition for the project, if any. diff --git a/crates/uv/tests/it/lock.rs b/crates/uv/tests/it/lock.rs index a94fb235c..1e6cda1ee 100644 --- a/crates/uv/tests/it/lock.rs +++ b/crates/uv/tests/it/lock.rs @@ -15328,11 +15328,7 @@ fn lock_named_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) and ignore `https://example.com` entirely. -/// (Querying `https://example.com` would fail with a 500.) +/// If a name is reused, within a single file, we should raise an error. #[test] fn lock_repeat_named_index() -> Result<()> { let context = TestContext::new("3.12"); @@ -15356,6 +15352,46 @@ fn lock_repeat_named_index() -> Result<()> { "#, )?; + uv_snapshot!(context.filters(), context.lock(), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Failed to parse: `pyproject.toml` + Caused by: TOML parse error at line 8, column 9 + | + 8 | [[tool.uv.index]] + | ^^^^^^^^^^^^^^^^^ + duplicate index name `pytorch` + "###); + + Ok(()) +} + +#[test] +fn lock_unique_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://astral-sh.github.io/pytorch-mirror/whl/cu121" + + [[tool.uv.index]] + name = "example" + url = "https://example.com" + "#, + )?; + // Fall back to PyPI, since `iniconfig` doesn't exist on the PyTorch index. uv_snapshot!(context.filters(), context.lock(), @r###" success: true diff --git a/uv.schema.json b/uv.schema.json index 12bce12be..9a4b6c876 100644 --- a/uv.schema.json +++ b/uv.schema.json @@ -202,6 +202,7 @@ }, "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). uv will only consider the first index that contains a given package, unless an alternative [index strategy](#index-strategy) is specified.\n\nIf an index is marked as `explicit = true`, it will be used exclusively for the 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 end of the prioritized list, such that it is given the lowest priority when resolving packages. Additionally, marking an index as default will disable the PyPI default index.", + "default": null, "type": [ "array", "null"