@@ -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,42 @@ 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.
450+ if matches ! ( candidate. dist( ) , CandidateDist :: Incompatible ( _) ) {
451+ if incompatible. is_none ( ) {
452+ incompatible = Some ( candidate) ;
453+ }
454+ continue ;
455+ }
456+
457+ trace ! (
458+ "Returning candidate for package {package_name} with range {range} after {steps} steps" ,
459+ ) ;
418460 return Some ( candidate) ;
419461 }
462+
463+ if incompatible. is_some ( ) {
464+ trace ! (
465+ "Returning incompatible candidate for package {package_name} with range {range} after {steps} steps" ,
466+ ) ;
467+ return incompatible;
468+ }
469+
420470 trace ! ( "Exhausted all candidates for package {package_name} with range {range} after {steps} steps" ) ;
421471 None
422472 }
0 commit comments