diff --git a/crates/uv-distribution-types/src/prioritized_distribution.rs b/crates/uv-distribution-types/src/prioritized_distribution.rs index 230ff3401a4e4..710f45ab93bac 100644 --- a/crates/uv-distribution-types/src/prioritized_distribution.rs +++ b/crates/uv-distribution-types/src/prioritized_distribution.rs @@ -12,7 +12,7 @@ use uv_pypi_types::{HashDigest, Yanked}; use crate::{ File, InstalledDist, KnownPlatform, RegistryBuiltDist, RegistryBuiltWheel, RegistrySourceDist, - ResolvedDistRef, + RequiresPython, ResolvedDistRef, }; /// A collection of distributions that have been filtered by relevance. @@ -425,7 +425,16 @@ impl PrioritizedDist { /// Return the highest-priority distribution for the package version, if any. pub fn get(&self) -> Option> { - let best_wheel = self.0.best_wheel_index.map(|i| &self.0.wheels[i]); + self.get_python_compatible(None) + } + + /// Return the highest-priority distribution for the package version, if any, while applying + /// the given Python requirement to wheel tags. + pub fn get_python_compatible( + &self, + requires_python: Option<&RequiresPython>, + ) -> Option> { + let best_wheel = self.best_wheel_for_python(requires_python); match (&best_wheel, &self.0.source) { // If both are compatible, break ties based on the hash outcome. For example, prefer a // source distribution with a matching hash over a wheel with a mismatched hash. When @@ -481,6 +490,23 @@ impl PrioritizedDist { } } + /// Return the highest-priority incompatibility for the package version while applying the + /// given Python requirement to wheel tags. + pub fn incompatible_dist_for_python( + &self, + requires_python: Option<&RequiresPython>, + ) -> IncompatibleDist { + if let Some(incompatibility) = self.incompatible_source() { + IncompatibleDist::Source(incompatibility.clone()) + } else if let Some((_, WheelCompatibility::Incompatible(incompatibility))) = + self.best_wheel_for_python(requires_python) + { + IncompatibleDist::Wheel(incompatibility) + } else { + IncompatibleDist::Unavailable + } + } + /// Return the incompatibility for the best source distribution, if any. pub fn incompatible_source(&self) -> Option<&IncompatibleSource> { self.0 @@ -517,21 +543,29 @@ impl PrioritizedDist { /// If this prioritized dist has at least one wheel, then this creates /// a built distribution with the best wheel in this prioritized dist. pub fn built_dist(&self) -> Option { - let best_wheel_index = self.0.best_wheel_index?; + let (wheel, _) = self.best_wheel()?; + self.built_dist_for(wheel) + } + + /// If this prioritized dist has the given wheel, then this creates a built distribution with + /// that wheel as the selected best wheel. + pub fn built_dist_for(&self, selected: &RegistryBuiltWheel) -> Option { + self.0.best_wheel_index?; // Remove any excluded wheels from the list of wheels, and adjust the wheel index to be // relative to the filtered list. let mut adjusted_wheels = Vec::with_capacity(self.0.wheels.len()); - let mut adjusted_best_index = 0; - for (i, (wheel, compatibility)) in self.0.wheels.iter().enumerate() { + let mut adjusted_best_index = None; + for (wheel, compatibility) in &self.0.wheels { if compatibility.is_excluded() { continue; } - if i == best_wheel_index { - adjusted_best_index = adjusted_wheels.len(); + if wheel.filename == selected.filename { + adjusted_best_index = Some(adjusted_wheels.len()); } adjusted_wheels.push(wheel.clone()); } + let adjusted_best_index = adjusted_best_index?; let sdist = self.0.source.as_ref().map(|(sdist, _)| sdist.clone()); Some(RegistryBuiltDist { @@ -569,6 +603,18 @@ impl PrioritizedDist { self.0.best_wheel_index.map(|i| &self.0.wheels[i]) } + /// Returns the minimum Python version supported by any compatible wheel in this distribution. + pub fn wheel_requires_python(&self) -> Option { + let lower = self + .0 + .wheels + .iter() + .filter(|(_, compatibility)| compatibility.is_compatible()) + .filter_map(|(wheel, _)| wheel_python_lower_bound(&wheel.filename)) + .min()?; + Some(RequiresPython::greater_than_equal_version(&lower)) + } + /// Returns an iterator of all wheels and the source distribution, if any. pub fn files(&self) -> impl Iterator { self.0 @@ -619,6 +665,25 @@ impl PrioritizedDist { } }) } + + fn best_wheel_for_python( + &self, + requires_python: Option<&RequiresPython>, + ) -> Option<(&RegistryBuiltWheel, WheelCompatibility)> { + let mut best = None; + + for (wheel, compatibility) in &self.0.wheels { + let compatibility = + effective_wheel_compatibility(wheel, compatibility, requires_python); + if best.as_ref().is_none_or(|(_, best_compatibility)| { + compatibility.is_more_compatible(best_compatibility) + }) { + best = Some((wheel, compatibility)); + } + } + + best + } } impl<'a> CompatibleDist<'a> { @@ -1031,6 +1096,79 @@ fn implied_python_markers(filename: &WheelFilename) -> MarkerTree { marker } +fn effective_wheel_compatibility( + wheel: &RegistryBuiltWheel, + compatibility: &WheelCompatibility, + requires_python: Option<&RequiresPython>, +) -> WheelCompatibility { + match compatibility { + WheelCompatibility::Compatible(_, _, _) + if requires_python.is_some_and(|requires_python| { + !requires_python.matches_wheel_tag(&wheel.filename) + }) => + { + WheelCompatibility::Incompatible(IncompatibleWheel::Tag( + IncompatibleTag::AbiPythonVersion, + )) + } + _ => compatibility.clone(), + } +} + +/// Return the lowest Python version implied by the wheel filename's Python tags. +fn wheel_python_lower_bound(filename: &WheelFilename) -> Option { + let mut lower: Option = None; + + for python_tag in filename.python_tags() { + match python_tag { + LanguageTag::Python { major: 3, minor } => { + let candidate = Version::new([3, u64::from(minor.unwrap_or(0))]); + lower = Some(lower.map_or(candidate.clone(), |lower| lower.min(candidate))); + } + LanguageTag::CPython { + python_version: (3, minor), + } + | LanguageTag::PyPy { + python_version: (3, minor), + } + | LanguageTag::GraalPy { + python_version: (3, minor), + } + | LanguageTag::Pyston { + python_version: (3, minor), + } => { + let candidate = Version::new([3, u64::from(*minor)]); + lower = Some(lower.map_or(candidate.clone(), |lower| lower.min(candidate))); + } + _ => {} + } + } + + for abi_tag in filename.abi_tags() { + let candidate = match abi_tag { + AbiTag::Abi3 => Some(Version::new([3, 2])), + AbiTag::CPython { + python_version: (3, minor), + .. + } + | AbiTag::PyPy { + python_version: Some((3, minor)), + .. + } + | AbiTag::GraalPy { + python_version: (3, minor), + .. + } => Some(Version::new([3, u64::from(*minor)])), + _ => None, + }; + if let Some(candidate) = candidate { + lower = Some(lower.map_or(candidate.clone(), |lower| lower.min(candidate))); + } + } + + lower +} + #[cfg(test)] mod tests { use std::str::FromStr; diff --git a/crates/uv-distribution-types/src/requires_python.rs b/crates/uv-distribution-types/src/requires_python.rs index 80f94b27a39fe..c5500afe68e69 100644 --- a/crates/uv-distribution-types/src/requires_python.rs +++ b/crates/uv-distribution-types/src/requires_python.rs @@ -502,6 +502,26 @@ impl RequiresPython { } }) } + + /// Returns `false` if the wheel's tags state it can't be used for the minimum Python version + /// permitted by this `Requires-Python`. + pub fn matches_wheel_minimum(&self, wheel: &WheelFilename) -> bool { + let version = match self.range.lower().as_ref() { + Bound::Included(version) | Bound::Excluded(version) => { + version.only_release().without_trailing_zeros() + } + Bound::Unbounded => return true, + }; + + Self { + specifiers: VersionSpecifiers::from(VersionSpecifier::equals_version(version.clone())), + range: RequiresPythonRange( + LowerBound::new(Bound::Included(version.clone())), + UpperBound::new(Bound::Included(version)), + ), + } + .matches_wheel_tag(wheel) + } } impl std::fmt::Display for RequiresPython { @@ -710,6 +730,29 @@ mod tests { } } + #[test] + fn requires_python_minimum() { + let requires_python = + RequiresPython::from_specifiers(&VersionSpecifiers::from_str(">=3.8").unwrap()); + + assert!(requires_python.matches_wheel_minimum( + &WheelFilename::from_str("pywin32-311-cp38-cp38-win_amd64.whl").unwrap() + )); + assert!(requires_python.matches_wheel_minimum( + &WheelFilename::from_str("bcrypt-4.1.3-cp37-abi3-macosx_10_12_universal2.whl").unwrap() + )); + assert!(requires_python.matches_wheel_minimum( + &WheelFilename::from_str("iniconfig-2.0.0-py3-none-any.whl").unwrap() + )); + + assert!(!requires_python.matches_wheel_minimum( + &WheelFilename::from_str("pywin32-311-cp39-cp39-win_amd64.whl").unwrap() + )); + assert!(!requires_python.matches_wheel_minimum( + &WheelFilename::from_str("pywin32-311-cp310-cp310-win_amd64.whl").unwrap() + )); + } + #[test] fn lower_bound_ordering() { let versions = &[ diff --git a/crates/uv-distribution-types/src/resolved.rs b/crates/uv-distribution-types/src/resolved.rs index cf2f3515070c4..12da74d161ec6 100644 --- a/crates/uv-distribution-types/src/resolved.rs +++ b/crates/uv-distribution-types/src/resolved.rs @@ -122,14 +122,11 @@ impl ResolvedDistRef<'_> { Self::InstallableRegistryBuiltDist { wheel, prioritized, .. } => { - assert_eq!( - Some(&wheel.filename), - prioritized.best_wheel().map(|(wheel, _)| &wheel.filename), - "expected chosen wheel to match best wheel" - ); // This is okay because we're only here if the prioritized dist // has at least one wheel, so this always succeeds. - let built = prioritized.built_dist().expect("at least one wheel"); + let built = prioritized + .built_dist_for(wheel) + .expect("selected wheel should be preserved"); ResolvedDist::Installable { dist: Arc::new(Dist::Built(BuiltDist::Registry(built))), version: Some(wheel.filename.version.clone()), diff --git a/crates/uv-resolver/src/candidate_selector.rs b/crates/uv-resolver/src/candidate_selector.rs index 385533c027a45..ac46a027aa422 100644 --- a/crates/uv-resolver/src/candidate_selector.rs +++ b/crates/uv-resolver/src/candidate_selector.rs @@ -7,7 +7,9 @@ use smallvec::SmallVec; use tracing::{debug, trace}; use uv_configuration::IndexStrategy; -use uv_distribution_types::{CompatibleDist, IncompatibleDist, IncompatibleSource, IndexUrl}; +use uv_distribution_types::{ + CompatibleDist, IncompatibleDist, IncompatibleSource, IndexUrl, RequiresPython, +}; use uv_distribution_types::{DistributionMetadata, IncompatibleWheel, Name, PrioritizedDist}; use uv_normalize::PackageName; use uv_pep440::Version; @@ -86,6 +88,7 @@ impl CandidateSelector { index: Option<&'a IndexUrl>, env: &ResolverEnvironment, tags: Option<&'a Tags>, + requires_python: Option<&'a RequiresPython>, ) -> Option> { let reinstall = exclusions.reinstall(package_name); let upgrade = exclusions.upgrade(package_name); @@ -109,6 +112,7 @@ impl CandidateSelector { index, env, tags, + requires_python, ) { trace!("Using preference {} {}", preferred.name, preferred.version); return Some(preferred); @@ -134,7 +138,8 @@ impl CandidateSelector { } // Otherwise, find the best candidate from the version maps. - let compatible = self.select_no_preference(package_name, range, version_maps, env); + let compatible = + self.select_no_preference(package_name, range, version_maps, env, requires_python); // Cross-reference against the already-installed distribution. // @@ -180,6 +185,7 @@ impl CandidateSelector { index: Option<&'a IndexUrl>, env: &ResolverEnvironment, tags: Option<&'a Tags>, + requires_python: Option<&'a RequiresPython>, ) -> Option> { let preferences = preferences.get(package_name); @@ -236,6 +242,7 @@ impl CandidateSelector { reinstall, env, tags, + requires_python, ) } @@ -250,6 +257,7 @@ impl CandidateSelector { reinstall: bool, env: &ResolverEnvironment, tags: Option<&Tags>, + requires_python: Option<&'a RequiresPython>, ) -> Option> { for (version, source) in preferences { // Respect the version range for this requirement. @@ -347,6 +355,7 @@ impl CandidateSelector { local, dist, VersionChoiceKind::Preference, + requires_python, )); } } @@ -357,6 +366,7 @@ impl CandidateSelector { version, file, VersionChoiceKind::Preference, + requires_python, )); } } @@ -418,6 +428,7 @@ impl CandidateSelector { range: &Range, version_maps: &'a [VersionMap], env: &ResolverEnvironment, + requires_python: Option<&'a RequiresPython>, ) -> Option> { trace!( "Selecting candidate for {package_name} with range {range} with {} remote versions", @@ -457,6 +468,7 @@ impl CandidateSelector { package_name, range, allow_prerelease, + requires_python, ) } else { Self::select_candidate( @@ -479,6 +491,7 @@ impl CandidateSelector { package_name, range, allow_prerelease, + requires_python, ) } } else { @@ -489,6 +502,7 @@ impl CandidateSelector { package_name, range, allow_prerelease, + requires_python, ) }) } else { @@ -498,6 +512,7 @@ impl CandidateSelector { package_name, range, allow_prerelease, + requires_python, ) }) } @@ -531,6 +546,7 @@ impl CandidateSelector { package_name: &'a PackageName, range: &Range, allow_prerelease: bool, + requires_python: Option<&'a RequiresPython>, ) -> Option> { let mut steps = 0usize; let mut incompatible: Option = None; @@ -561,7 +577,13 @@ impl CandidateSelector { trace!( "Found candidate for package {package_name} with range {range} after {steps} steps: {version} version" ); - Candidate::new(package_name, version, dist, VersionChoiceKind::Compatible) + Candidate::new( + package_name, + version, + dist, + VersionChoiceKind::Compatible, + requires_python, + ) }; // If candidate is not compatible due to exclude newer, continue searching. @@ -653,24 +675,16 @@ impl CandidateDist<'_> { } } -impl<'a> From<&'a PrioritizedDist> for CandidateDist<'a> { - fn from(value: &'a PrioritizedDist) -> Self { - if let Some(dist) = value.get() { +impl CandidateDist<'_> { + fn from_prioritized<'a>( + value: &'a PrioritizedDist, + requires_python: Option<&RequiresPython>, + ) -> CandidateDist<'a> { + if let Some(dist) = value.get_python_compatible(requires_python) { CandidateDist::Compatible(dist) } else { - // TODO(zanieb) - // We always return the source distribution (if one exists) instead of the wheel - // but in the future we may want to return both so the resolver can explain - // why neither distribution kind can be used. - let dist = if let Some(incompatibility) = value.incompatible_source() { - IncompatibleDist::Source(incompatibility.clone()) - } else if let Some(incompatibility) = value.incompatible_wheel() { - IncompatibleDist::Wheel(incompatibility.clone()) - } else { - IncompatibleDist::Unavailable - }; CandidateDist::Incompatible { - incompatible_dist: dist, + incompatible_dist: value.incompatible_dist_for_python(requires_python), prioritized_dist: value, } } @@ -717,11 +731,12 @@ impl<'a> Candidate<'a> { version: &'a Version, dist: &'a PrioritizedDist, choice_kind: VersionChoiceKind, + requires_python: Option<&RequiresPython>, ) -> Self { Self { name, version, - dist: CandidateDist::from(dist), + dist: CandidateDist::from_prioritized(dist, requires_python), choice_kind, } } diff --git a/crates/uv-resolver/src/flat_index.rs b/crates/uv-resolver/src/flat_index.rs index 46da6ddbf86f9..ed2c7c36d8238 100644 --- a/crates/uv-resolver/src/flat_index.rs +++ b/crates/uv-resolver/src/flat_index.rs @@ -9,12 +9,12 @@ use uv_configuration::BuildOptions; use uv_distribution_filename::{DistFilename, SourceDistFilename, WheelFilename}; use uv_distribution_types::{ File, HashComparison, HashPolicy, IncompatibleSource, IncompatibleWheel, IndexUrl, - PrioritizedDist, RegistryBuiltWheel, RegistrySourceDist, SourceDistCompatibility, - WheelCompatibility, + PrioritizedDist, RegistryBuiltWheel, RegistrySourceDist, RequiresPython, + SourceDistCompatibility, WheelCompatibility, }; use uv_normalize::PackageName; use uv_pep440::Version; -use uv_platform_tags::{TagCompatibility, Tags}; +use uv_platform_tags::{IncompatibleTag, TagCompatibility, Tags}; use uv_pypi_types::HashDigest; use uv_types::HashStrategy; @@ -37,6 +37,19 @@ impl FlatIndex { tags: Option<&Tags>, hasher: &HashStrategy, build_options: &BuildOptions, + ) -> Self { + Self::from_entries_with_requires_python(entries, tags, None, hasher, build_options) + } + + /// Collect all files from a `--find-links` target into a [`FlatIndex`], optionally filtering + /// wheels by the initial `requires-python`. + #[instrument(skip_all)] + pub fn from_entries_with_requires_python( + entries: FlatIndexEntries, + tags: Option<&Tags>, + requires_python: Option<&RequiresPython>, + hasher: &HashStrategy, + build_options: &BuildOptions, ) -> Self { // Collect compatible distributions. let mut index = FxHashMap::::default(); @@ -46,6 +59,7 @@ impl FlatIndex { entry.file, entry.filename, tags, + requires_python, hasher, build_options, entry.index, @@ -83,6 +97,19 @@ impl FlatDistributions { tags: Option<&Tags>, hasher: &HashStrategy, build_options: &BuildOptions, + ) -> Self { + Self::from_entries_with_requires_python(entries, tags, None, hasher, build_options) + } + + /// Collect all files from a `--find-links` target into a [`FlatIndex`], optionally filtering + /// wheels by the initial `requires-python`. + #[instrument(skip_all)] + pub fn from_entries_with_requires_python( + entries: Vec, + tags: Option<&Tags>, + requires_python: Option<&RequiresPython>, + hasher: &HashStrategy, + build_options: &BuildOptions, ) -> Self { let mut distributions = Self::default(); for entry in entries { @@ -90,6 +117,7 @@ impl FlatDistributions { entry.file, entry.filename, tags, + requires_python, hasher, build_options, entry.index, @@ -114,12 +142,11 @@ impl FlatDistributions { file: File, filename: DistFilename, tags: Option<&Tags>, + requires_python: Option<&RequiresPython>, hasher: &HashStrategy, build_options: &BuildOptions, index: IndexUrl, ) { - // No `requires-python` here: for source distributions, we don't have that information; - // for wheels, we read it lazily only when selected. match filename { DistFilename::WheelFilename(filename) => { let version = filename.version.clone(); @@ -128,6 +155,7 @@ impl FlatDistributions { &filename, file.hashes.as_slice(), tags, + requires_python, hasher, build_options, ); @@ -205,6 +233,7 @@ impl FlatDistributions { filename: &WheelFilename, hashes: &[HashDigest], tags: Option<&Tags>, + requires_python: Option<&RequiresPython>, hasher: &HashStrategy, build_options: &BuildOptions, ) -> WheelCompatibility { @@ -214,14 +243,24 @@ impl FlatDistributions { } // Determine a compatibility for the wheel based on tags. - let priority = match tags { - Some(tags) => match filename.compatibility(tags) { + let priority = if let Some(tags) = tags { + match filename.compatibility(tags) { TagCompatibility::Incompatible(tag) => { return WheelCompatibility::Incompatible(IncompatibleWheel::Tag(tag)); } TagCompatibility::Compatible(priority) => Some(priority), - }, - None => None, + } + } else { + // Check if the wheel is compatible with the `requires-python` (i.e., the + // Python ABI tag is not less than the `requires-python` minimum version). + if let Some(requires_python) = requires_python { + if !requires_python.matches_wheel_tag(filename) { + return WheelCompatibility::Incompatible(IncompatibleWheel::Tag( + IncompatibleTag::AbiPythonVersion, + )); + } + } + None }; // Check if hashes line up. diff --git a/crates/uv-resolver/src/pubgrub/report.rs b/crates/uv-resolver/src/pubgrub/report.rs index f8d61244abdda..fcd082dfc701e 100644 --- a/crates/uv-resolver/src/pubgrub/report.rs +++ b/crates/uv-resolver/src/pubgrub/report.rs @@ -771,7 +771,7 @@ impl PubGrubReportFormatter<'_> { return None; }; - let candidate = selector.select_no_preference(name, set, version_maps, env)?; + let candidate = selector.select_no_preference(name, set, version_maps, env, None)?; let prioritized = candidate.prioritized()?; diff --git a/crates/uv-resolver/src/resolver/batch_prefetch.rs b/crates/uv-resolver/src/resolver/batch_prefetch.rs index f8ae3b602b7e7..41b95c0f5754c 100644 --- a/crates/uv-resolver/src/resolver/batch_prefetch.rs +++ b/crates/uv-resolver/src/resolver/batch_prefetch.rs @@ -228,9 +228,13 @@ impl BatchPrefetcherRunner { compatible, previous, } => { - if let Some(candidate) = - selector.select_no_preference(name, &compatible, version_map, env) - { + if let Some(candidate) = selector.select_no_preference( + name, + &compatible, + version_map, + env, + Some(python_requirement.target()), + ) { let compatible = compatible.intersection( &Range::singleton(candidate.version().clone()).complement(), ); @@ -263,9 +267,13 @@ impl BatchPrefetcherRunner { } }; } - if let Some(candidate) = - selector.select_no_preference(name, &range, version_map, env) - { + if let Some(candidate) = selector.select_no_preference( + name, + &range, + version_map, + env, + Some(python_requirement.target()), + ) { phase = BatchPrefetchStrategy::InOrder { previous: candidate.version().clone(), }; diff --git a/crates/uv-resolver/src/resolver/mod.rs b/crates/uv-resolver/src/resolver/mod.rs index b0d26cafae176..32fd2491d4d31 100644 --- a/crates/uv-resolver/src/resolver/mod.rs +++ b/crates/uv-resolver/src/resolver/mod.rs @@ -25,8 +25,8 @@ use uv_distribution::{ArchiveMetadata, DistributionDatabase}; use uv_distribution_types::{ BuiltDist, CompatibleDist, DerivationChain, Dist, DistErrorKind, Identifier, IncompatibleDist, IncompatibleSource, IncompatibleWheel, IndexCapabilities, IndexLocations, IndexMetadata, - IndexUrl, InstalledDist, Name, PythonRequirementKind, RemoteSource, Requirement, ResolvedDist, - ResolvedDistRef, SourceDist, VersionOrUrlRef, implied_markers, + IndexUrl, InstalledDist, Name, PrioritizedDist, PythonRequirementKind, RemoteSource, + Requirement, ResolvedDist, ResolvedDistRef, SourceDist, VersionOrUrlRef, implied_markers, }; use uv_git::GitResolver; use uv_normalize::{ExtraName, GroupName, PackageName}; @@ -1220,7 +1220,7 @@ impl ResolverState ResolverState ResolverState ResolverState, preferences: &Preferences, env: &ResolverEnvironment, + python_requirement: &PythonRequirement, pubgrub: &State, pins: &mut FilePins, request_sink: &Sender, @@ -1505,6 +1511,46 @@ impl ResolverState>() + .join(", ") + ); + let forks = forks + .into_iter() + .map(|env| VersionFork { + env, + id, + version: None, + }) + .collect(); + return Ok(Some(ResolverVersion::Forked(forks))); + } + } + } + // For now, we only apply this to local versions. if !candidate.version().is_local() { return Ok(None); @@ -1531,6 +1577,10 @@ impl ResolverState ResolverState From> for Request { ResolvedDistRef::InstallableRegistryBuiltDist { wheel, prioritized, .. } => { - assert_eq!( - Some(&wheel.filename), - prioritized.best_wheel().map(|(wheel, _)| &wheel.filename), - "expected chosen wheel to match best wheel" - ); // This is okay because we're only here if the prioritized dist // has at least one wheel, so this always succeeds. - let built = prioritized.built_dist().expect("at least one wheel"); + let built = prioritized + .built_dist_for(wheel) + .expect("selected wheel should be preserved"); Self::Dist(Dist::Built(BuiltDist::Registry(built))) } ResolvedDistRef::Installed { dist } => Self::Installed(dist.clone()), diff --git a/crates/uv-resolver/src/resolver/provider.rs b/crates/uv-resolver/src/resolver/provider.rs index 5722749312a58..69e788c3f0324 100644 --- a/crates/uv-resolver/src/resolver/provider.rs +++ b/crates/uv-resolver/src/resolver/provider.rs @@ -193,6 +193,7 @@ impl ResolverProvider for DefaultResolverProvider<'_, Con MetadataFormat::Flat(metadata) => VersionMap::from_flat_metadata( metadata, self.tags.as_ref(), + &self.requires_python, &self.hasher, self.build_options, ), diff --git a/crates/uv-resolver/src/version_map.rs b/crates/uv-resolver/src/version_map.rs index 21dd7c3af5058..b6a5c307f1013 100644 --- a/crates/uv-resolver/src/version_map.rs +++ b/crates/uv-resolver/src/version_map.rs @@ -136,6 +136,7 @@ impl VersionMap { pub(crate) fn from_flat_metadata( flat_metadata: Vec, tags: Option<&Tags>, + requires_python: &RequiresPython, hasher: &HashStrategy, build_options: &BuildOptions, ) -> Self { @@ -143,9 +144,13 @@ impl VersionMap { let mut local = false; let mut map = BTreeMap::new(); - for (version, prioritized_dist) in - FlatDistributions::from_entries(flat_metadata, tags, hasher, build_options) - { + for (version, prioritized_dist) in FlatDistributions::from_entries_with_requires_python( + flat_metadata, + tags, + tags.is_none().then_some(requires_python), + hasher, + build_options, + ) { stable |= version.is_stable(); local |= version.is_local(); map.insert(version, prioritized_dist); diff --git a/crates/uv/src/commands/pip/compile.rs b/crates/uv/src/commands/pip/compile.rs index b735d0dfd093c..8ca0f8c212867 100644 --- a/crates/uv/src/commands/pip/compile.rs +++ b/crates/uv/src/commands/pip/compile.rs @@ -495,7 +495,13 @@ pub(crate) async fn pip_compile( let entries = client .fetch_all(index_locations.flat_indexes().map(Index::url)) .await?; - FlatIndex::from_entries(entries, tags.as_deref(), &hasher, &build_options) + FlatIndex::from_entries_with_requires_python( + entries, + tags.as_deref(), + tags.is_none().then(|| python_requirement.target()), + &hasher, + &build_options, + ) }; // Determine whether to enable build isolation. diff --git a/crates/uv/src/commands/project/lock.rs b/crates/uv/src/commands/project/lock.rs index 0067b7322f8cd..e4ccbf2724a82 100644 --- a/crates/uv/src/commands/project/lock.rs +++ b/crates/uv/src/commands/project/lock.rs @@ -750,7 +750,13 @@ async fn do_lock( let entries = client .fetch_all(index_locations.flat_indexes().map(Index::url)) .await?; - FlatIndex::from_entries(entries, None, &hasher, build_options) + FlatIndex::from_entries_with_requires_python( + entries, + None, + Some(python_requirement.target()), + &hasher, + build_options, + ) }; // Lower the extra build dependencies. diff --git a/crates/uv/tests/it/lock.rs b/crates/uv/tests/it/lock.rs index 3f9002609e6b5..5b10d99c3a176 100644 --- a/crates/uv/tests/it/lock.rs +++ b/crates/uv/tests/it/lock.rs @@ -31549,6 +31549,323 @@ fn windows_arm64_required() -> Result<()> { Ok(()) } +/// Ensure universal locking forks when a wheel-only version raises the minimum supported Python. +#[test] +fn windows_amd64_splits_on_wheel_python_tags() -> Result<()> { + let context = uv_test::test_context!("3.12").with_exclude_newer("2025-08-01T00:00:00Z"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "pywin32-prj" + version = "0.1.0" + requires-python = ">=3.7" + dependencies = ["pywin32; sys_platform == 'win32'"] + + [tool.uv] + environments = [ + "sys_platform == 'win32' and platform_machine == 'AMD64'", + ] + "#, + )?; + + uv_snapshot!(context.filters(), context.lock(), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 3 packages in [TIME] + "); + + let lock = context.read("uv.lock"); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r#" + version = 1 + revision = 3 + requires-python = ">=3.7" + resolution-markers = [ + "python_full_version >= '3.8' and platform_machine == 'AMD64' and sys_platform == 'win32'", + "python_full_version < '3.8' and platform_machine == 'AMD64' and sys_platform == 'win32'", + ] + supported-markers = [ + "platform_machine == 'AMD64' and sys_platform == 'win32'", + ] + + [options] + exclude-newer = "2025-08-01T00:00:00Z" + + [[package]] + name = "pywin32" + version = "308" + source = { registry = "https://pypi.org/simple" } + resolution-markers = [ + "python_full_version < '3.8' and platform_machine == 'AMD64' and sys_platform == 'win32'", + ] + wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/b4/84e2463422f869b4b718f79eb7530a4c1693e96b8a4e5e968de38be4d2ba/pywin32-308-cp310-cp310-win_amd64.whl", hash = "sha256:4fc888c59b3c0bef905ce7eb7e2106a07712015ea1c8234b703a088d46110e8e", size = 6558484, upload-time = "2024-10-12T20:42:01.271Z" }, + { url = "https://files.pythonhosted.org/packages/48/ef/f4fb45e2196bc7ffe09cad0542d9aff66b0e33f6c0954b43e49c33cad7bd/pywin32-308-cp311-cp311-win_amd64.whl", hash = "sha256:575621b90f0dc2695fec346b2d6302faebd4f0f45c05ea29404cefe35d89442b", size = 6559559, upload-time = "2024-10-12T20:42:07.644Z" }, + { url = "https://files.pythonhosted.org/packages/21/27/0c8811fbc3ca188f93b5354e7c286eb91f80a53afa4e11007ef661afa746/pywin32-308-cp312-cp312-win_amd64.whl", hash = "sha256:00b3e11ef09ede56c6a43c71f2d31857cf7c54b0ab6e78ac659497abd2834f47", size = 6543015, upload-time = "2024-10-12T20:42:14.044Z" }, + { url = "https://files.pythonhosted.org/packages/c7/50/b0efb8bb66210da67a53ab95fd7a98826a97ee21f1d22949863e6d588b22/pywin32-308-cp313-cp313-win_amd64.whl", hash = "sha256:fd380990e792eaf6827fcb7e187b2b4b1cede0585e3d0c9e84201ec27b9905e4", size = 6542056, upload-time = "2024-10-12T20:42:20.864Z" }, + { url = "https://files.pythonhosted.org/packages/69/52/ac7037ec0eec0aa0a78ec4aab3d34227ea714b7fe5b578c5dca0af3d312f/pywin32-308-cp37-cp37m-win_amd64.whl", hash = "sha256:13dcb914ed4347019fbec6697a01a0aec61019c1046c2b905410d197856326a6", size = 6617118, upload-time = "2024-10-12T20:41:48.182Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e8/729b049e3c5c5449049d6036edf7a24a6ba785a9a1d5f617b638a9b444eb/pywin32-308-cp38-cp38-win_amd64.whl", hash = "sha256:3b92622e29d651c6b783e368ba7d6722b1634b8e70bd376fd7610fe1992e19de", size = 6647446, upload-time = "2024-10-12T20:41:52.949Z" }, + { url = "https://files.pythonhosted.org/packages/e4/cd/0838c9a6063bff2e9bac2388ae36524c26c50288b5d7b6aebb6cdf8d375d/pywin32-308-cp39-cp39-win_amd64.whl", hash = "sha256:71b3322d949b4cc20776436a9c9ba0eeedcbc9c650daa536df63f0ff111bb920", size = 6640327, upload-time = "2024-10-12T20:41:57.239Z" }, + ] + + [[package]] + name = "pywin32" + version = "311" + source = { registry = "https://pypi.org/simple" } + resolution-markers = [ + "python_full_version >= '3.8' and platform_machine == 'AMD64' and sys_platform == 'win32'", + ] + wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/bf/360243b1e953bd254a82f12653974be395ba880e7ec23e3731d9f73921cc/pywin32-311-cp310-cp310-win_amd64.whl", hash = "sha256:797c2772017851984b97180b0bebe4b620bb86328e8a884bb626156295a63b3b", size = 9590103, upload-time = "2025-07-14T20:13:07.698Z" }, + { url = "https://files.pythonhosted.org/packages/51/8f/9bb81dd5bb77d22243d33c8397f09377056d5c687aa6d4042bea7fbf8364/pywin32-311-cp311-cp311-win_amd64.whl", hash = "sha256:3ce80b34b22b17ccbd937a6e78e7225d80c52f5ab9940fe0506a1a16f3dab503", size = 9508308, upload-time = "2025-07-14T20:13:15.147Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" }, + { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, + { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6c/94c10268bae5d0d0c6509bdfb5aa08882d11a9ccdf89ff1cde59a6161afb/pywin32-311-cp38-cp38-win_amd64.whl", hash = "sha256:c8015b09fb9a5e188f83b7b04de91ddca4658cee2ae6f3bc483f0b21a77ef6cd", size = 9594743, upload-time = "2025-07-14T20:12:57.59Z" }, + { url = "https://files.pythonhosted.org/packages/9f/8a/1403d0353f8c5a2f0829d2b1c4becbf9da2f0a4d040886404fc4a5431e4d/pywin32-311-cp39-cp39-win_amd64.whl", hash = "sha256:e0c4cfb0621281fe40387df582097fd796e80430597cb9944f0ae70447bacd91", size = 9590187, upload-time = "2025-07-14T20:13:01.419Z" }, + ] + + [[package]] + name = "pywin32-prj" + version = "0.1.0" + source = { virtual = "." } + dependencies = [ + { name = "pywin32", version = "308", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.8' and platform_machine == 'AMD64' and sys_platform == 'win32'" }, + { name = "pywin32", version = "311", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.8' and platform_machine == 'AMD64' and sys_platform == 'win32'" }, + ] + + [package.metadata] + requires-dist = [{ name = "pywin32", marker = "sys_platform == 'win32'" }] + "# + ); + }); + + Ok(()) +} + +/// Ensure a wheel-only package can use a newer version when it still covers the minimum Python version. +#[test] +fn windows_amd64_uses_newer_version_when_floor_is_covered() -> Result<()> { + let context = uv_test::test_context!("3.12").with_exclude_newer("2025-08-01T00:00:00Z"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "pywin32-prj" + version = "0.1.0" + requires-python = ">=3.8" + dependencies = ["pywin32; sys_platform == 'win32'"] + + [tool.uv] + environments = [ + "sys_platform == 'win32' and platform_machine == 'AMD64'", + ] + "#, + )?; + + uv_snapshot!(context.filters(), context.lock(), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 2 packages in [TIME] + "); + + let lock = context.read("uv.lock"); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r#" + version = 1 + revision = 3 + requires-python = ">=3.8" + resolution-markers = [ + "platform_machine == 'AMD64' and sys_platform == 'win32'", + ] + supported-markers = [ + "platform_machine == 'AMD64' and sys_platform == 'win32'", + ] + + [options] + exclude-newer = "2025-08-01T00:00:00Z" + + [[package]] + name = "pywin32" + version = "311" + source = { registry = "https://pypi.org/simple" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/bf/360243b1e953bd254a82f12653974be395ba880e7ec23e3731d9f73921cc/pywin32-311-cp310-cp310-win_amd64.whl", hash = "sha256:797c2772017851984b97180b0bebe4b620bb86328e8a884bb626156295a63b3b", size = 9590103, upload-time = "2025-07-14T20:13:07.698Z" }, + { url = "https://files.pythonhosted.org/packages/51/8f/9bb81dd5bb77d22243d33c8397f09377056d5c687aa6d4042bea7fbf8364/pywin32-311-cp311-cp311-win_amd64.whl", hash = "sha256:3ce80b34b22b17ccbd937a6e78e7225d80c52f5ab9940fe0506a1a16f3dab503", size = 9508308, upload-time = "2025-07-14T20:13:15.147Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" }, + { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, + { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6c/94c10268bae5d0d0c6509bdfb5aa08882d11a9ccdf89ff1cde59a6161afb/pywin32-311-cp38-cp38-win_amd64.whl", hash = "sha256:c8015b09fb9a5e188f83b7b04de91ddca4658cee2ae6f3bc483f0b21a77ef6cd", size = 9594743, upload-time = "2025-07-14T20:12:57.59Z" }, + { url = "https://files.pythonhosted.org/packages/9f/8a/1403d0353f8c5a2f0829d2b1c4becbf9da2f0a4d040886404fc4a5431e4d/pywin32-311-cp39-cp39-win_amd64.whl", hash = "sha256:e0c4cfb0621281fe40387df582097fd796e80430597cb9944f0ae70447bacd91", size = 9590187, upload-time = "2025-07-14T20:13:01.419Z" }, + ] + + [[package]] + name = "pywin32-prj" + version = "0.1.0" + source = { virtual = "." } + dependencies = [ + { name = "pywin32", marker = "platform_machine == 'AMD64' and sys_platform == 'win32'" }, + ] + + [package.metadata] + requires-dist = [{ name = "pywin32", marker = "sys_platform == 'win32'" }] + "# + ); + }); + + Ok(()) +} + +/// Ensure a compatible source distribution avoids unnecessary Python-tag forks for wheel-only coverage. +#[test] +fn windows_amd64_does_not_split_on_wheel_python_tags_with_compatible_sdist() -> Result<()> { + let context = uv_test::test_context!("3.12"); + + let root = context.temp_dir.child("simple-html"); + fs_err::create_dir_all(&root)?; + + let iniconfig = root.child("iniconfig"); + fs_err::create_dir_all(&iniconfig)?; + + let sdist = iniconfig.child("iniconfig-2.0.0.tar.gz"); + download_to_disk( + "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", + sdist.path(), + ); + + let downloaded_wheel = iniconfig.child("iniconfig-2.0.0-py3-none-any.whl"); + download_to_disk( + "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", + downloaded_wheel.path(), + ); + + let wheel = iniconfig.child("iniconfig-2.0.0-cp38-cp38-win_amd64.whl"); + fs_err::rename(downloaded_wheel.path(), wheel.path())?; + + let index = iniconfig.child("index.html"); + index.write_str(&formatdoc! {r#" + + + + + + +

Links for iniconfig

+ + iniconfig-2.0.0-cp38-cp38-win_amd64.whl + + + iniconfig-2.0.0.tar.gz + + + + "#, + Url::from_file_path(&wheel).unwrap().as_str(), + Url::from_file_path(&sdist).unwrap().as_str(), + })?; + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(&formatdoc! {r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.7" + dependencies = ["iniconfig==2.0.0"] + + [tool.uv] + environments = [ + "sys_platform == 'win32' and platform_machine == 'AMD64'", + ] + "# + })?; + + uv_snapshot!( + context.filters(), + context + .lock() + .arg("--index-url") + .arg(Url::from_file_path(&root).unwrap().as_str()), + @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 2 packages in [TIME] + " + ); + + let lock = context.read("uv.lock"); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r#" + version = 1 + revision = 3 + requires-python = ">=3.7" + resolution-markers = [ + "platform_machine == 'AMD64' and sys_platform == 'win32'", + ] + supported-markers = [ + "platform_machine == 'AMD64' and sys_platform == 'win32'", + ] + + [options] + exclude-newer = "2024-03-25T00:00:00Z" + + [[package]] + name = "iniconfig" + version = "2.0.0" + source = { registry = "[TEMP_DIR]/simple-html" } + sdist = { path = "[TEMP_DIR]/simple-html/iniconfig/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", upload-time = "2023-01-07T11:08:11.254Z" } + wheels = [ + { path = "[TEMP_DIR]/simple-html/iniconfig/iniconfig-2.0.0-cp38-cp38-win_amd64.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", upload-time = "2023-01-07T11:08:09.864Z" }, + ] + + [[package]] + name = "project" + version = "0.1.0" + source = { virtual = "." } + dependencies = [ + { name = "iniconfig", marker = "platform_machine == 'AMD64' and sys_platform == 'win32'" }, + ] + + [package.metadata] + requires-dist = [{ name = "iniconfig", specifier = "==2.0.0" }] + "# + ); + }); + + Ok(()) +} + #[test] fn lock_empty_extra() -> Result<()> { let context = uv_test::test_context!("3.12");