Warn when duplicate index names found in single file (#11824)

<!--
Thank you for contributing to uv! To help us out with reviewing, please
consider the following:
- Does this pull request include a summary of the change? (See below.)
- Does this pull request include a descriptive title?
- Does this pull request include references to any relevant issues?
-->
## Summary

This pull request introduces validation for unique index names in the
`tool.uv.index` field and adds corresponding tests to ensure the
functionality. The most important changes include adding a custom
deserializer function, updating the `ToolUv` struct to use the new
deserializer, and adding tests to verify the behavior.

Validation and deserialization:

*
[`crates/uv-workspace/src/pyproject.rs`](diffhunk://#diff-e12cd255985adfd45ab06f398cb420d2f543841ccbeea4175ccf827aa9215b9dR283-R311):
Added a custom deserializer function `deserialize_index_vec` to validate
that index names in the `tool.uv.index` field are unique.
*
[`crates/uv-workspace/src/pyproject.rs`](diffhunk://#diff-e12cd255985adfd45ab06f398cb420d2f543841ccbeea4175ccf827aa9215b9dR374):
Updated the `ToolUv` struct to use the `deserialize_index_vec` function
for the `index` field.

Testing:

*
[`crates/uv/tests/it/lock.rs`](diffhunk://#diff-82edd36151736f44055f699a34c8b19a63ffc4cf3c86bf5fb34d69f8ac88a957R15336):
Added a test `lock_repeat_named_index` to verify that duplicate index
names result in an error.
[[1]](diffhunk://#diff-82edd36151736f44055f699a34c8b19a63ffc4cf3c86bf5fb34d69f8ac88a957R15336)
[[2]](diffhunk://#diff-82edd36151736f44055f699a34c8b19a63ffc4cf3c86bf5fb34d69f8ac88a957R15360-R15402)
*
[`crates/uv/tests/it/lock.rs`](diffhunk://#diff-82edd36151736f44055f699a34c8b19a63ffc4cf3c86bf5fb34d69f8ac88a957R15360-R15402):
Added a test `lock_unique_named_index` to verify that unique index names
result in successful lock file generation.

Schema update:

*
[`uv.schema.json`](diffhunk://#diff-c669473b258a19ba6d3557d0369126773b68b27171989f265333a77bc5cb935bR205):
Updated the schema to set the default value of the `index` field to
`null`.

Fixes #11804

## Test Plan
### Steps to reproduce and verify the fix:

1. Clone the repository and checkout the feature branch
   ```bash
   git clone https://github.com/astral-sh/uv.git
   cd uv
   git checkout feature/warn-duplicate-index-names
   ```

2. Build the modified binary
   ```bash
   cargo build
   ```

3. Create a test project using the system installed uv
   ```bash
   uv init uv-test
   cd uv-test
   ```

4. Manually edit pyproject.toml to add duplicate index names
   ```toml
   [[tool.uv.index]]
   name = "alpha_b"
   url = "<omitted>"

   [[tool.uv.index]]
   name = "alpha_b"
   url = "<omitted>"
   ```

5. Try to add a package using the modified binary
   ```bash
   ../target/debug/uv add numpy
   ```

### Results
Before: use release binary
![スクリーンショット 2025-02-27 15 52
28](https://github.com/user-attachments/assets/2823a4b4-b3ba-40aa-aa41-e593d35c3f3b)

After: use self build binary
![スクリーンショット 2025-02-27 15 51
58](https://github.com/user-attachments/assets/9ac773a9-58cd-4d4b-8685-148bf6dc85fb)

Now when attempting to use a pyproject.toml with duplicate index names,
the modified binary correctly detects the issue and produces an error
message:
```
error: Failed to parse: `pyproject.toml`
  Caused by: TOML parse error at line 9, column 1
  |
9 | [[tool.uv.index]]
  | ^^^^^^^^^^^^^^^^^
duplicate index name `alpha_b`
```

---------

Co-authored-by: Charlie Marsh <charlie.r.marsh@gmail.com>
This commit is contained in:
KATO So 2025-02-28 06:33:57 +09:00 committed by GitHub
parent 4d9c861506
commit ad86005e9a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 68 additions and 5 deletions

View File

@ -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<ToolUv>,
}
/// 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<Option<Vec<Index>>, D::Error>
where
D: Deserializer<'de>,
{
let indexes = Option::<Vec<Index>>::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<Vec<Index>>,
/// The workspace definition for the project, if any.

View File

@ -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

1
uv.schema.json generated
View File

@ -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"