From 27ade0676ff88a3920a3965a921bbaa1e5ee82fc Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Tue, 22 Jul 2025 15:09:59 -0400 Subject: [PATCH] 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. --- crates/uv-workspace/src/pyproject_mut.rs | 7 +- crates/uv/src/commands/project/add.rs | 4 +- crates/uv/tests/it/edit.rs | 115 ++++++++++++++++++++++- crates/uv/tests/it/lock.rs | 33 +++++-- 4 files changed, 146 insertions(+), 13 deletions(-) diff --git a/crates/uv-workspace/src/pyproject_mut.rs b/crates/uv-workspace/src/pyproject_mut.rs index 73e3833ae..85c36d03d 100644 --- a/crates/uv-workspace/src/pyproject_mut.rs +++ b/crates/uv-workspace/src/pyproject_mut.rs @@ -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. diff --git a/crates/uv/src/commands/project/add.rs b/crates/uv/src/commands/project/add.rs index 4bf5905d2..64419bb02 100644 --- a/crates/uv/src/commands/project/add.rs +++ b/crates/uv/src/commands/project/add.rs @@ -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::>(); + indexes.reverse(); + for index in indexes { toml.add_index(index)?; } } diff --git a/crates/uv/tests/it/edit.rs b/crates/uv/tests/it/edit.rs index a7d11091b..05527b139 100644 --- a/crates/uv/tests/it/edit.rs +++ b/crates/uv/tests/it/edit.rs @@ -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: +#[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`. diff --git a/crates/uv/tests/it/lock.rs b/crates/uv/tests/it/lock.rs index cdc246e9f..8a006ffb6 100644 --- a/crates/uv/tests/it/lock.rs +++ b/crates/uv/tests/it/lock.rs @@ -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(())