Skip to content

Commit d4cfb0b

Browse files
charliermarshkonstin
authored andcommitted
Prefer compatible to incompatible distributions when packages exist on multiple indexes (#8961)
## Summary 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. Closes #8922.
1 parent 0b15684 commit d4cfb0b

File tree

2 files changed

+91
-2
lines changed

2 files changed

+91
-2
lines changed

crates/uv-resolver/src/candidate_selector.rs

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -374,17 +374,33 @@ impl CandidateSelector {
374374
}
375375

376376
/// Select the first-matching [`Candidate`] from a set of candidate versions and files,
377-
/// preferring wheels over source distributions.
377+
/// preferring wheels to source distributions.
378+
///
379+
/// The returned [`Candidate`] _may not_ be compatible with the current platform; in such
380+
/// cases, the resolver is responsible for tracking the incompatibility and re-running the
381+
/// selection process with additional constraints.
378382
fn select_candidate<'a>(
379383
versions: impl Iterator<Item = (&'a Version, VersionMapDistHandle<'a>)>,
380384
package_name: &'a PackageName,
381385
range: &Range<Version>,
382386
allow_prerelease: bool,
383387
) -> Option<Candidate<'a>> {
384388
let mut steps = 0usize;
389+
let mut incompatible: Option<Candidate> = None;
385390
for (version, maybe_dist) in versions {
386391
steps += 1;
387392

393+
// If we have an incompatible candidate, and we've progressed past it, return it.
394+
if incompatible
395+
.as_ref()
396+
.is_some_and(|incompatible| version != incompatible.version)
397+
{
398+
trace!(
399+
"Returning incompatible candidate for package {package_name} with range {range} after {steps} steps",
400+
);
401+
return incompatible;
402+
}
403+
388404
let candidate = {
389405
if version.any_prerelease() && !allow_prerelease {
390406
continue;
@@ -395,7 +411,7 @@ impl CandidateSelector {
395411
let Some(dist) = maybe_dist.prioritized_dist() else {
396412
continue;
397413
};
398-
trace!("found candidate for package {package_name:?} with range {range:?} after {steps} steps: {version:?} version");
414+
trace!("Found candidate for package {package_name} with range {range} after {steps} steps: {version} version");
399415
Candidate::new(package_name, version, dist, VersionChoiceKind::Compatible)
400416
};
401417

@@ -415,8 +431,44 @@ impl CandidateSelector {
415431
continue;
416432
}
417433

434+
// If the candidate isn't compatible, we store it as incompatible and continue
435+
// searching. Typically, we want to return incompatible candidates so that PubGrub can
436+
// track them (then continue searching, with additional constraints). However, we may
437+
// see multiple entries for the same version (e.g., if the same version exists on
438+
// multiple indexes and `--index-strategy unsafe-best-match` is enabled), and it's
439+
// possible that one of them is compatible while the other is not.
440+
//
441+
// See, e.g., <https://github.com/astral-sh/uv/issues/8922>. At time of writing,
442+
// markupsafe==3.0.2 exists on the PyTorch index, but there's only a single wheel:
443+
//
444+
// MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
445+
//
446+
// Meanwhile, there are a large number of wheels on PyPI for the same version. If the
447+
// user is on Python 3.12, and we return the incompatible PyTorch wheel without
448+
// considering the PyPI wheels, PubGrub will mark 3.0.2 as an incompatible version,
449+
// even though there are compatible wheels on PyPI. Thus, we need to ensure that we
450+
// return the first _compatible_ candidate across all indexes, if such a candidate
451+
// exists.
452+
if matches!(candidate.dist(), CandidateDist::Incompatible(_)) {
453+
if incompatible.is_none() {
454+
incompatible = Some(candidate);
455+
}
456+
continue;
457+
}
458+
459+
trace!(
460+
"Returning candidate for package {package_name} with range {range} after {steps} steps",
461+
);
418462
return Some(candidate);
419463
}
464+
465+
if incompatible.is_some() {
466+
trace!(
467+
"Returning incompatible candidate for package {package_name} with range {range} after {steps} steps",
468+
);
469+
return incompatible;
470+
}
471+
420472
trace!("Exhausted all candidates for package {package_name} with range {range} after {steps} steps");
421473
None
422474
}

crates/uv/tests/it/pip_compile.rs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13190,3 +13190,40 @@ fn lowest_fork() -> Result<()> {
1319013190

1319113191
Ok(())
1319213192
}
13193+
13194+
/// See: <https://github.com/astral-sh/uv/issues/8922>
13195+
#[test]
13196+
fn same_version_multi_index_incompatibility() -> Result<()> {
13197+
let context = TestContext::new("3.10");
13198+
let requirements_in = context.temp_dir.child("requirements.in");
13199+
requirements_in.write_str("cffi==1.15.1")?;
13200+
13201+
// `cffi` is present on Test PyPI, but only as a single wheel: `cffi-1.15.1-cp311-cp311-win_arm64.whl`.
13202+
// If we don't check PyPI for the same version, we'll fail.
13203+
uv_snapshot!(context
13204+
.pip_compile()
13205+
.arg("requirements.in")
13206+
.arg("--extra-index-url")
13207+
.arg("https://test.pypi.org/simple")
13208+
.arg("--index-strategy")
13209+
.arg("unsafe-best-match")
13210+
.arg("--python-platform")
13211+
.arg("linux")
13212+
.arg("--python-version")
13213+
.arg("3.10"), @r###"
13214+
success: true
13215+
exit_code: 0
13216+
----- stdout -----
13217+
# This file was autogenerated by uv via the following command:
13218+
# uv pip compile --cache-dir [CACHE_DIR] requirements.in --index-strategy unsafe-best-match --python-platform linux --python-version 3.10
13219+
cffi==1.15.1
13220+
# via -r requirements.in
13221+
pycparser==2.21
13222+
# via cffi
13223+
13224+
----- stderr -----
13225+
Resolved 2 packages in [TIME]
13226+
"###);
13227+
13228+
Ok(())
13229+
}

0 commit comments

Comments
 (0)