@@ -374,17 +374,33 @@ impl CandidateSelector {
374
374
}
375
375
376
376
/// 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.
378
382
fn select_candidate < ' a > (
379
383
versions : impl Iterator < Item = ( & ' a Version , VersionMapDistHandle < ' a > ) > ,
380
384
package_name : & ' a PackageName ,
381
385
range : & Range < Version > ,
382
386
allow_prerelease : bool ,
383
387
) -> Option < Candidate < ' a > > {
384
388
let mut steps = 0usize ;
389
+ let mut incompatible: Option < Candidate > = None ;
385
390
for ( version, maybe_dist) in versions {
386
391
steps += 1 ;
387
392
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
+
388
404
let candidate = {
389
405
if version. any_prerelease ( ) && !allow_prerelease {
390
406
continue ;
@@ -395,7 +411,7 @@ impl CandidateSelector {
395
411
let Some ( dist) = maybe_dist. prioritized_dist ( ) else {
396
412
continue ;
397
413
} ;
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" ) ;
399
415
Candidate :: new ( package_name, version, dist, VersionChoiceKind :: Compatible )
400
416
} ;
401
417
@@ -415,8 +431,44 @@ impl CandidateSelector {
415
431
continue ;
416
432
}
417
433
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
+ ) ;
418
462
return Some ( candidate) ;
419
463
}
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
+
420
472
trace ! ( "Exhausted all candidates for package {package_name} with range {range} after {steps} steps" ) ;
421
473
None
422
474
}
0 commit comments