Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion crates/uv-resolver/src/preferences.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
}
}
Expand Down Expand Up @@ -381,3 +385,30 @@ impl From<Version> 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"
);
}
}
78 changes: 78 additions & 0 deletions crates/uv/tests/it/lock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<()> {
Expand Down
Loading