diff --git a/crates/uv-resolver/src/candidate_selector.rs b/crates/uv-resolver/src/candidate_selector.rs index fdcc6c4722e8..44f9197507b3 100644 --- a/crates/uv-resolver/src/candidate_selector.rs +++ b/crates/uv-resolver/src/candidate_selector.rs @@ -374,7 +374,11 @@ impl CandidateSelector { } /// Select the first-matching [`Candidate`] from a set of candidate versions and files, - /// preferring wheels over source distributions. + /// preferring wheels to source distributions. + /// + /// The returned [`Candidate`] _may not_ be compatible with the current platform; in such + /// cases, the resolver is responsible for tracking the incompatibility and re-running the + /// selection process with additional constraints. fn select_candidate<'a>( versions: impl Iterator)>, package_name: &'a PackageName, @@ -382,9 +386,21 @@ impl CandidateSelector { allow_prerelease: bool, ) -> Option> { let mut steps = 0usize; + let mut incompatible: Option = None; for (version, maybe_dist) in versions { steps += 1; + // If we have an incompatible candidate, and we've progressed past it, return it. + if incompatible + .as_ref() + .is_some_and(|incompatible| version != incompatible.version) + { + trace!( + "Returning incompatible candidate for package {package_name} with range {range} after {steps} steps", + ); + return incompatible; + } + let candidate = { if version.any_prerelease() && !allow_prerelease { continue; @@ -395,7 +411,7 @@ impl CandidateSelector { let Some(dist) = maybe_dist.prioritized_dist() else { continue; }; - trace!("found candidate for package {package_name:?} with range {range:?} after {steps} steps: {version:?} version"); + trace!("Found candidate for package {package_name} with range {range} after {steps} steps: {version} version"); Candidate::new(package_name, version, dist, VersionChoiceKind::Compatible) }; @@ -415,8 +431,44 @@ impl CandidateSelector { continue; } + // If the candidate isn't compatible, we store it as incompatible and continue + // searching. Typically, we want to return incompatible candidates so that PubGrub can + // track them (then continue searching, with additional constraints). However, we may + // see multiple entries for the same version (e.g., if the same version exists on + // multiple indexes and `--index-strategy unsafe-best-match` is enabled), and it's + // possible that one of them is compatible while the other is not. + // + // See, e.g., . At time of writing, + // markupsafe==3.0.2 exists on the PyTorch index, but there's only a single wheel: + // + // MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + // + // Meanwhile, there are a large number of wheels on PyPI for the same version. If the + // user is on Python 3.12, and we return the incompatible PyTorch wheel without + // considering the PyPI wheels, PubGrub will mark 3.0.2 as an incompatible version, + // even though there are compatible wheels on PyPI. Thus, we need to ensure that we + // return the first _compatible_ candidate across all indexes, if such a candidate + // exists. + if matches!(candidate.dist(), CandidateDist::Incompatible(_)) { + if incompatible.is_none() { + incompatible = Some(candidate); + } + continue; + } + + trace!( + "Returning candidate for package {package_name} with range {range} after {steps} steps", + ); return Some(candidate); } + + if incompatible.is_some() { + trace!( + "Returning incompatible candidate for package {package_name} with range {range} after {steps} steps", + ); + return incompatible; + } + trace!("Exhausted all candidates for package {package_name} with range {range} after {steps} steps"); None } diff --git a/crates/uv/tests/it/pip_compile.rs b/crates/uv/tests/it/pip_compile.rs index 49752cc686b9..e4c5bc796dd7 100644 --- a/crates/uv/tests/it/pip_compile.rs +++ b/crates/uv/tests/it/pip_compile.rs @@ -13190,3 +13190,40 @@ fn lowest_fork() -> Result<()> { Ok(()) } + +/// See: +#[test] +fn same_version_multi_index_incompatibility() -> Result<()> { + let context = TestContext::new("3.10"); + let requirements_in = context.temp_dir.child("requirements.in"); + requirements_in.write_str("cffi==1.15.1")?; + + // `cffi` is present on Test PyPI, but only as a single wheel: `cffi-1.15.1-cp311-cp311-win_arm64.whl`. + // If we don't check PyPI for the same version, we'll fail. + uv_snapshot!(context + .pip_compile() + .arg("requirements.in") + .arg("--extra-index-url") + .arg("https://test.pypi.org/simple") + .arg("--index-strategy") + .arg("unsafe-best-match") + .arg("--python-platform") + .arg("linux") + .arg("--python-version") + .arg("3.10"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + # This file was autogenerated by uv via the following command: + # uv pip compile --cache-dir [CACHE_DIR] requirements.in --index-strategy unsafe-best-match --python-platform linux --python-version 3.10 + cffi==1.15.1 + # via -r requirements.in + pycparser==2.21 + # via cffi + + ----- stderr ----- + Resolved 2 packages in [TIME] + "###); + + Ok(()) +}