Preserve index URL priority order when writing to pyproject.toml (#14831)

## Summary

A little nuanced, but... When you add multiple `--index` URLs on the CLI
(e.g., in `uv pip install`), we check the first-provided index, then the
second index, etc. However, when we _write_ those URLs to the
`pyproject.toml` in `uv add`, we were adding them in reverse-order. We
now add them in a way that preserves the priority order.

Closes https://github.com/astral-sh/uv/issues/14817.
This commit is contained in:
Charlie Marsh 2025-07-22 15:09:59 -04:00 committed by GitHub
parent 3d1fec2732
commit 27ade0676f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 146 additions and 13 deletions

View file

@ -392,6 +392,7 @@ impl PyProjectTomlMut {
/// Add an [`Index`] to `tool.uv.index`.
pub fn add_index(&mut self, index: &Index) -> Result<(), Error> {
let size = self.doc.len();
let existing = self
.doc
.entry("tool")
@ -472,8 +473,7 @@ impl PyProjectTomlMut {
if table
.get("url")
.and_then(|item| item.as_str())
.and_then(|url| DisplaySafeUrl::parse(url).ok())
.is_none_or(|url| CanonicalUrl::new(&url) != CanonicalUrl::new(index.url.url()))
.is_none_or(|url| url != index.url.without_credentials().as_str())
{
let mut formatted = Formatted::new(index.url.without_credentials().to_string());
if let Some(value) = table.get("url").and_then(Item::as_value) {
@ -552,6 +552,9 @@ impl PyProjectTomlMut {
table.set_position(position + 1);
}
}
} else {
let position = isize::try_from(size).expect("TOML table size fits in `isize`");
table.set_position(position);
}
// Push the item to the table.

View file

@ -644,7 +644,9 @@ pub(crate) async fn add(
// Add any indexes that were provided on the command-line, in priority order.
if !raw {
let urls = IndexUrls::from_indexes(indexes);
for index in urls.defined_indexes() {
let mut indexes = urls.defined_indexes().collect::<Vec<_>>();
indexes.reverse();
for index in indexes {
toml.add_index(index)?;
}
}

View file

@ -11307,6 +11307,115 @@ fn remove_all_with_comments() -> Result<()> {
Ok(())
}
/// If multiple indexes are provided on the CLI, the first-provided index should take precedence
/// during resolution, and should appear first in the `pyproject.toml` file.
///
/// See: <https://github.com/astral-sh/uv/issues/14817>
#[test]
fn multiple_index_cli() -> Result<()> {
let context = TestContext::new("3.12");
let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(indoc! {r#"
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = []
"#})?;
uv_snapshot!(context.filters(), context
.add()
.arg("requests")
.arg("--index")
.arg("https://test.pypi.org/simple")
.arg("--index")
.arg("https://pypi.org/simple"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 2 packages in [TIME]
Prepared 1 package in [TIME]
Installed 1 package in [TIME]
+ requests==2.5.4.1
");
let pyproject_toml = context.read("pyproject.toml");
insta::with_settings!({
filters => context.filters(),
}, {
assert_snapshot!(
pyproject_toml, @r#"
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"requests>=2.5.4.1",
]
[[tool.uv.index]]
url = "https://test.pypi.org/simple"
[[tool.uv.index]]
url = "https://pypi.org/simple"
"#
);
});
let lock = context.read("uv.lock");
insta::with_settings!({
filters => context.filters(),
}, {
assert_snapshot!(
lock, @r#"
version = 1
revision = 2
requires-python = ">=3.12"
[options]
exclude-newer = "2024-03-25T00:00:00Z"
[[package]]
name = "project"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "requests" },
]
[package.metadata]
requires-dist = [{ name = "requests", specifier = ">=2.5.4.1" }]
[[package]]
name = "requests"
version = "2.5.4.1"
source = { registry = "https://test.pypi.org/simple" }
sdist = { url = "https://test-files.pythonhosted.org/packages/6e/93/638dbb5f2c1f4120edaad4f3d45ffb1718e463733ad07d68f59e042901d6/requests-2.5.4.1.tar.gz", hash = "sha256:b19df51fa3e52a2bd7fc80a1ac11fb6b2f51a7c0bf31ba9ff6b5d11ea8605ae9", size = 448691, upload-time = "2015-03-13T21:30:03.228Z" }
wheels = [
{ url = "https://test-files.pythonhosted.org/packages/6d/00/8ed1b6ea43b10bfe28d08e6af29fd6aa5d8dab5e45ead9394a6268a2d2ec/requests-2.5.4.1-py2.py3-none-any.whl", hash = "sha256:0a2c98e46121e7507afb0edc89d342641a1fb9e8d56f7d592d4975ee6b685f9a", size = 468942, upload-time = "2015-03-13T21:29:55.769Z" },
]
"#
);
});
// Install from the lockfile.
uv_snapshot!(context.filters(), context.sync().arg("--frozen"), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Audited 1 package in [TIME]
"###);
Ok(())
}
/// If an index is repeated by the CLI and an environment variable, the CLI value should take
/// precedence.
///
@ -11418,7 +11527,7 @@ fn repeated_index_cli_environment_variable() -> Result<()> {
Ok(())
}
/// If an index is repeated on the CLI, the last-provided index should take precedence.
/// If an index is repeated on the CLI, the first-provided index should take precedence.
/// Newlines in `UV_INDEX` should be treated as separators.
///
/// The index that appears in the `pyproject.toml` should also be consistent with the index that
@ -11524,7 +11633,7 @@ fn repeated_index_cli_environment_variable_newline() -> Result<()> {
Ok(())
}
/// If an index is repeated on the CLI, the last-provided index should take precedence.
/// If an index is repeated on the CLI, the first-provided index should take precedence.
///
/// The index that appears in the `pyproject.toml` should also be consistent with the index that
/// appears in the `uv.lock`.
@ -11634,7 +11743,7 @@ fn repeated_index_cli() -> Result<()> {
Ok(())
}
/// If an index is repeated on the CLI, the last-provided index should take precedence.
/// If an index is repeated on the CLI, the first-provided index should take precedence.
///
/// The index that appears in the `pyproject.toml` should also be consistent with the index that
/// appears in the `uv.lock`.

View file

@ -28813,8 +28813,7 @@ fn lock_trailing_slash_index_url_in_pyproject_not_index_argument() -> Result<()>
let context = TestContext::new("3.12");
let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(
r#"
pyproject_toml.write_str(indoc! {r#"
[project]
name = "project"
version = "0.1.0"
@ -28824,8 +28823,7 @@ fn lock_trailing_slash_index_url_in_pyproject_not_index_argument() -> Result<()>
[[tool.uv.index]]
name = "pypi-proxy"
url = "https://pypi-proxy.fly.dev/simple/"
"#,
)?;
"#})?;
let no_trailing_slash_url = "https://pypi-proxy.fly.dev/simple";
@ -28843,6 +28841,28 @@ fn lock_trailing_slash_index_url_in_pyproject_not_index_argument() -> Result<()>
+ sniffio==1.3.1
");
let pyproject_toml = context.read("pyproject.toml");
insta::with_settings!({
filters => context.filters(),
}, {
assert_snapshot!(
pyproject_toml, @r#"
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"anyio>=4.3.0",
]
[[tool.uv.index]]
name = "pypi-proxy"
url = "https://pypi-proxy.fly.dev/simple"
"#
);
});
let lock = context.read("uv.lock");
insta::with_settings!({
@ -28904,13 +28924,12 @@ fn lock_trailing_slash_index_url_in_pyproject_not_index_argument() -> Result<()>
// Re-run with `--locked`.
uv_snapshot!(context.filters(), context.lock().arg("--locked"), @r"
success: false
exit_code: 1
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 4 packages in [TIME]
The lockfile at `uv.lock` needs to be updated, but `--locked` was provided. To update the lockfile, run `uv lock`.
");
Ok(())