diff --git a/crates/uv-resolver/src/preferences.rs b/crates/uv-resolver/src/preferences.rs index 5683e8a17..c5fc760ee 100644 --- a/crates/uv-resolver/src/preferences.rs +++ b/crates/uv-resolver/src/preferences.rs @@ -163,7 +163,11 @@ impl PreferenceIndex { match self { Self::Any => true, Self::Implicit => false, - Self::Explicit(preference) => preference == index, + Self::Explicit(preference) => { + // Preferences are stored in the lockfile without credentials, while the index URL + // in locations such as `pyproject.toml` may contain credentials. + *preference.url() == *index.without_credentials() + } } } } @@ -381,3 +385,30 @@ impl From for Pin { } } } + +#[cfg(test)] +mod tests { + use super::*; + use std::str::FromStr; + + /// Test that [`PreferenceIndex::matches`] correctly ignores credentials when comparing URLs. + /// + /// This is relevant for matching lockfile preferences (stored without credentials) + /// against index URLs from pyproject.toml (which may include usernames for auth). + #[test] + fn test_preference_index_matches_ignores_credentials() { + // URL without credentials (as stored in lockfile) + let index_without_creds = IndexUrl::from_str("https:/pypi_index.com/simple").unwrap(); + + // URL with username (as specified in pyproject.toml) + let index_with_username = + IndexUrl::from_str("https://username@pypi_index.com/simple").unwrap(); + + let preference = PreferenceIndex::Explicit(index_without_creds.clone()); + + assert!( + preference.matches(&index_with_username), + "PreferenceIndex should match URLs that differ only in username" + ); + } +} diff --git a/crates/uv/tests/it/lock.rs b/crates/uv/tests/it/lock.rs index 6f7256932..6c9bd7858 100644 --- a/crates/uv/tests/it/lock.rs +++ b/crates/uv/tests/it/lock.rs @@ -9600,6 +9600,84 @@ fn lock_redact_https() -> Result<()> { Ok(()) } +/// Test that packages aren't unnecessarily updated when an index URL contains a username. +#[test] +fn lock_index_url_username_change_no_update() -> Result<()> { + let context = TestContext::new("3.12"); + + // Create initial lockfile with exact version constraint + 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 = ["anyio==4.0.0"] + + [[tool.uv.index]] + name = "test-index" + url = "https://fakeuser@pypi.org/simple" + + [tool.uv.sources] + anyio = { index = "test-index" } + "#, + )?; + + uv_snapshot!(context.filters(), context.lock(), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 4 packages in [TIME] + "###); + + let lock = context.read("uv.lock"); + + // Verify anyio 4.0.0 is locked + assert!(lock.contains("name = \"anyio\"")); + assert!(lock.contains("version = \"4.0.0\"")); + + // Update pyproject.toml to simulate availability of newer package with more open but still compatible constraint + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["anyio>=4.0.0"] + + [[tool.uv.index]] + name = "test-index" + url = "https://fakeuser@pypi.org/simple" + + [tool.uv.sources] + anyio = { index = "test-index" } + "#, + )?; + + // Run `uv lock` to update the lockfile + // The package should stay at 4.0.0 + uv_snapshot!(context.filters(), context.lock(), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 4 packages in [TIME] + "###); + + let lock_after = context.read("uv.lock"); + + assert!( + lock_after.contains("version = \"4.0.0\""), + "anyio should remain at version 4.0.0, not update despite >=4.0.0 constraint" + ); + + Ok(()) +} + #[test] #[cfg(feature = "git")] fn lock_redact_git_pep508() -> Result<()> {