From 10c4effbd30d4359ebe9d9fd3660d4fadb9ac253 Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Fri, 8 Mar 2024 11:02:31 -0600 Subject: [PATCH] Refactor incompatiblity tracking for distributions (#1298) Extends the "compatibility" types introduced in #1293 to apply to source distributions as well as wheels. - We now track the most-relevant incompatible source distribution - Exclude newer, Python requirements, and yanked versions are all tracked as incompatibilities in the new model (this lets us remove `DistMetadata`!) --- Cargo.lock | 1 + crates/distribution-types/Cargo.toml | 1 + .../src/prioritized_distribution.rs | 305 +++++++++++------- crates/pypi-types/src/simple_json.rs | 10 +- crates/uv-client/src/flat_index.rs | 27 +- crates/uv-dev/src/install_many.rs | 1 + crates/uv-resolver/src/candidate_selector.rs | 50 +-- crates/uv-resolver/src/finder.rs | 18 +- crates/uv-resolver/src/pins.rs | 8 +- crates/uv-resolver/src/python_requirement.rs | 41 +-- crates/uv-resolver/src/resolver/mod.rs | 139 ++++---- crates/uv-resolver/src/resolver/provider.rs | 11 +- crates/uv-resolver/src/version_map.rs | 193 ++++++++--- crates/uv-resolver/src/yanks.rs | 17 +- crates/uv/src/commands/pip_sync.rs | 12 +- crates/uv/tests/pip_install_scenarios.rs | 7 +- 16 files changed, 498 insertions(+), 343 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 93fde529a..932b292d5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -915,6 +915,7 @@ version = "0.0.1" dependencies = [ "anyhow", "cache-key", + "chrono", "data-encoding", "distribution-filename", "fs-err", diff --git a/crates/distribution-types/Cargo.toml b/crates/distribution-types/Cargo.toml index 488410c04..4d4157bab 100644 --- a/crates/distribution-types/Cargo.toml +++ b/crates/distribution-types/Cargo.toml @@ -25,6 +25,7 @@ uv-normalize = { path = "../uv-normalize" } pypi-types = { path = "../pypi-types" } anyhow = { workspace = true } +chrono = { workspace = true } data-encoding = { workspace = true } fs-err = { workspace = true } itertools = { workspace = true } diff --git a/crates/distribution-types/src/prioritized_distribution.rs b/crates/distribution-types/src/prioritized_distribution.rs index dfa560b00..d6e702d98 100644 --- a/crates/distribution-types/src/prioritized_distribution.rs +++ b/crates/distribution-types/src/prioritized_distribution.rs @@ -11,95 +11,88 @@ pub struct PrioritizedDist(Box); /// [`PrioritizedDist`] is boxed because [`Dist`] is large. #[derive(Debug, Default, Clone)] struct PrioritizedDistInner { - /// An arbitrary source distribution for the package version. - source: Option, - /// The most-compatible wheel distribution for the package version. - wheel: Option<(DistMetadata, WheelCompatibility)>, + /// The highest-priority source distribution. Between compatible source distributions this priority is arbitrary. + source: Option<(Dist, SourceDistCompatibility)>, + /// The highest-priority wheel. + wheel: Option<(Dist, WheelCompatibility)>, /// The hashes for each distribution. hashes: Vec, - /// If exclude newer filtered files from this distribution - exclude_newer: bool, } /// A distribution that can be used for both resolution and installation. #[derive(Debug, Clone)] pub enum CompatibleDist<'a> { /// The distribution should be resolved and installed using a source distribution. - SourceDist(&'a DistMetadata), + SourceDist(&'a Dist), /// The distribution should be resolved and installed using a wheel distribution. - CompatibleWheel(&'a DistMetadata, TagPriority), + CompatibleWheel(&'a Dist, TagPriority), /// The distribution should be resolved using an incompatible wheel distribution, but /// installed using a source distribution. IncompatibleWheel { - source_dist: &'a DistMetadata, - wheel: &'a DistMetadata, + source_dist: &'a Dist, + wheel: &'a Dist, }, } #[derive(Debug, PartialEq, Eq, Clone)] +pub enum IncompatibleDist { + /// An incompatible wheel is available. + Wheel(IncompatibleWheel), + /// An incompatible source distribution is available. + Source(IncompatibleSource), + /// No distributions are available + Unavailable, +} + +#[derive(Debug, Clone, PartialEq, Eq)] pub enum WheelCompatibility { Incompatible(IncompatibleWheel), Compatible(TagPriority), } -#[derive(Debug, PartialEq, Eq, Ord, PartialOrd, Clone)] +#[derive(Debug, PartialEq, Eq, Clone)] pub enum IncompatibleWheel { + ExcludeNewer(Option), Tag(IncompatibleTag), - RequiresPython, + RequiresPython(VersionSpecifiers), + Yanked(Yanked), NoBinary, } -/// A [`Dist`] and metadata about it required for downstream filtering. -#[derive(Debug, Clone)] -pub struct DistMetadata { - /// The distribution. - pub dist: Dist, - /// The version of Python required by the distribution. - pub requires_python: Option, - /// If the distribution file is yanked. - pub yanked: Yanked, +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SourceDistCompatibility { + Incompatible(IncompatibleSource), + Compatible, +} + +#[derive(Debug, PartialEq, Eq, Clone)] +pub enum IncompatibleSource { + ExcludeNewer(Option), + RequiresPython(VersionSpecifiers), + Yanked(Yanked), + NoBuild, } impl PrioritizedDist { /// Create a new [`PrioritizedDist`] from the given wheel distribution. - pub fn from_built( - dist: Dist, - requires_python: Option, - yanked: Yanked, - hash: Option, - compatibility: WheelCompatibility, - ) -> Self { + pub fn from_built(dist: Dist, hash: Option, compatibility: WheelCompatibility) -> Self { Self(Box::new(PrioritizedDistInner { + wheel: Some((dist, compatibility)), source: None, - wheel: Some(( - DistMetadata { - dist, - requires_python, - yanked, - }, - compatibility, - )), hashes: hash.map(|hash| vec![hash]).unwrap_or_default(), - exclude_newer: false, })) } /// Create a new [`PrioritizedDist`] from the given source distribution. pub fn from_source( dist: Dist, - requires_python: Option, - yanked: Yanked, hash: Option, + compatibility: SourceDistCompatibility, ) -> Self { Self(Box::new(PrioritizedDistInner { - source: Some(DistMetadata { - dist, - requires_python, - yanked, - }), wheel: None, + source: Some((dist, compatibility)), hashes: hash.map(|hash| vec![hash]).unwrap_or_default(), - exclude_newer: false, })) } @@ -107,32 +100,16 @@ impl PrioritizedDist { pub fn insert_built( &mut self, dist: Dist, - requires_python: Option, - yanked: Yanked, hash: Option, compatibility: WheelCompatibility, ) { // Track the highest-priority wheel. if let Some((.., existing_compatibility)) = &self.0.wheel { - if compatibility > *existing_compatibility { - self.0.wheel = Some(( - DistMetadata { - dist, - requires_python, - yanked, - }, - compatibility, - )); + if compatibility.is_more_compatible(existing_compatibility) { + self.0.wheel = Some((dist, compatibility)); } } else { - self.0.wheel = Some(( - DistMetadata { - dist, - requires_python, - yanked, - }, - compatibility, - )); + self.0.wheel = Some((dist, compatibility)); } if let Some(hash) = hash { @@ -144,16 +121,16 @@ impl PrioritizedDist { pub fn insert_source( &mut self, dist: Dist, - requires_python: Option, - yanked: Yanked, hash: Option, + compatibility: SourceDistCompatibility, ) { - if self.0.source.is_none() { - self.0.source = Some(DistMetadata { - dist, - requires_python, - yanked, - }); + // Track the highest-priority source. + if let Some((.., existing_compatibility)) = &self.0.source { + if compatibility.is_more_compatible(existing_compatibility) { + self.0.source = Some((dist, compatibility)); + } + } else { + self.0.source = Some((dist, compatibility)); } if let Some(hash) = hash { @@ -172,22 +149,44 @@ impl PrioritizedDist { // wheel. We assume that all distributions have the same metadata for a given package // version. If a compatible source distribution exists, we assume we can build it, but // using the wheel is faster. - (Some((wheel, _)), Some(source_dist)) => { - Some(CompatibleDist::IncompatibleWheel { source_dist, wheel }) - } + ( + Some((wheel, WheelCompatibility::Incompatible(_))), + Some((source_dist, SourceDistCompatibility::Compatible)), + ) => Some(CompatibleDist::IncompatibleWheel { source_dist, wheel }), // Otherwise, if we have a source distribution, return it. - (_, Some(source_dist)) => Some(CompatibleDist::SourceDist(source_dist)), + (None, Some((source_dist, SourceDistCompatibility::Compatible))) => { + Some(CompatibleDist::SourceDist(source_dist)) + } _ => None, } } - /// Return the source distribution, if any. - pub fn source(&self) -> Option<&DistMetadata> { - self.0.source.as_ref() + /// Return the compatible source distribution, if any. + pub fn compatible_source(&self) -> Option<&Dist> { + self.0 + .source + .as_ref() + .and_then(|(dist, compatibility)| match compatibility { + SourceDistCompatibility::Compatible => Some(dist), + SourceDistCompatibility::Incompatible(_) => None, + }) + } + + /// Return the incompatible source distribution, if any. + pub fn incompatible_source(&self) -> Option<(&Dist, &IncompatibleSource)> { + self.0 + .source + .as_ref() + .and_then(|(dist, compatibility)| match compatibility { + SourceDistCompatibility::Compatible => None, + SourceDistCompatibility::Incompatible(incompatibility) => { + Some((dist, incompatibility)) + } + }) } /// Return the compatible built distribution, if any. - pub fn compatible_wheel(&self) -> Option<(&DistMetadata, TagPriority)> { + pub fn compatible_wheel(&self) -> Option<(&Dist, TagPriority)> { self.0 .wheel .as_ref() @@ -198,7 +197,7 @@ impl PrioritizedDist { } /// Return the incompatible built distribution, if any. - pub fn incompatible_wheel(&self) -> Option<(&DistMetadata, &IncompatibleWheel)> { + pub fn incompatible_wheel(&self) -> Option<(&Dist, &IncompatibleWheel)> { self.0 .wheel .as_ref() @@ -208,16 +207,6 @@ impl PrioritizedDist { }) } - /// Set the `exclude_newer` flag - pub fn set_exclude_newer(&mut self) { - self.0.exclude_newer = true; - } - - /// Check if any distributions were excluded by the `exclude_newer` option - pub fn exclude_newer(&self) -> bool { - self.0.exclude_newer - } - /// Return the hashes for each distribution. pub fn hashes(&self) -> &[Hashes] { &self.0.hashes @@ -231,8 +220,8 @@ impl PrioritizedDist { } impl<'a> CompatibleDist<'a> { - /// Return the [`DistMetadata`] to use during resolution. - pub fn for_resolution(&self) -> &DistMetadata { + /// Return the [`Dist`] to use during resolution. + pub fn for_resolution(&self) -> &Dist { match *self { CompatibleDist::SourceDist(sdist) => sdist, CompatibleDist::CompatibleWheel(wheel, _) => wheel, @@ -243,8 +232,8 @@ impl<'a> CompatibleDist<'a> { } } - /// Return the [`DistMetadata`] to use during installation. - pub fn for_installation(&self) -> &DistMetadata { + /// Return the [`Dist`] to use during installation. + pub fn for_installation(&self) -> &Dist { match *self { CompatibleDist::SourceDist(sdist) => sdist, CompatibleDist::CompatibleWheel(wheel, _) => wheel, @@ -254,42 +243,47 @@ impl<'a> CompatibleDist<'a> { } => source_dist, } } - - /// Return the [`Yanked`] status of the distribution. - /// - /// It is possible for files to have a different yank status per PEP 592 but in the official - /// PyPI warehouse this cannot happen. - /// - /// Here, we will treat the distribution is yanked if the file we will install with - /// is yanked. - /// - /// PEP 592: - pub fn yanked(&self) -> &Yanked { - &self.for_installation().yanked - } -} - -impl Ord for WheelCompatibility { - fn cmp(&self, other: &Self) -> std::cmp::Ordering { - match (self, other) { - (Self::Compatible(p_self), Self::Compatible(p_other)) => p_self.cmp(p_other), - (Self::Incompatible(_), Self::Compatible(_)) => std::cmp::Ordering::Less, - (Self::Compatible(_), Self::Incompatible(_)) => std::cmp::Ordering::Greater, - (Self::Incompatible(t_self), Self::Incompatible(t_other)) => t_self.cmp(t_other), - } - } -} - -impl PartialOrd for WheelCompatibility { - fn partial_cmp(&self, other: &Self) -> Option { - Some(Self::cmp(self, other)) - } } impl WheelCompatibility { pub fn is_compatible(&self) -> bool { matches!(self, Self::Compatible(_)) } + + /// Return `true` if the current compatibility is more compatible than another. + /// + /// Compatible wheels are always higher more compatible than incompatible wheels. + /// Compatible wheel ordering is determined by tag priority. + pub fn is_more_compatible(&self, other: &Self) -> bool { + match (self, other) { + (Self::Compatible(_), Self::Incompatible(_)) => true, + (Self::Compatible(tag_priority), Self::Compatible(other_tag_priority)) => { + tag_priority > other_tag_priority + } + (Self::Incompatible(_), Self::Compatible(_)) => false, + (Self::Incompatible(incompatibility), Self::Incompatible(other_incompatibility)) => { + incompatibility.is_more_compatible(other_incompatibility) + } + } + } +} + +impl SourceDistCompatibility { + /// Return the higher priority compatibility. + /// + /// Compatible source distributions are always higher priority than incompatible source distributions. + /// Compatible source distribution priority is arbitrary. + /// Incompatible source distribution priority selects a source distribution that was "closest" to being usable. + pub fn is_more_compatible(&self, other: &Self) -> bool { + match (self, other) { + (Self::Compatible, Self::Incompatible(_)) => true, + (Self::Compatible, Self::Compatible) => false, // Arbitary + (Self::Incompatible(_), Self::Compatible) => false, + (Self::Incompatible(incompatibility), Self::Incompatible(other_incompatibility)) => { + incompatibility.is_more_compatible(other_incompatibility) + } + } + } } impl From for WheelCompatibility { @@ -300,3 +294,64 @@ impl From for WheelCompatibility { } } } + +impl IncompatibleSource { + fn is_more_compatible(&self, other: &Self) -> bool { + match self { + Self::ExcludeNewer(timestamp_self) => match other { + // Smaller timestamps are closer to the cut-off time + Self::ExcludeNewer(timestamp_other) => timestamp_other < timestamp_self, + Self::NoBuild | Self::RequiresPython(_) | Self::Yanked(_) => true, + }, + Self::RequiresPython(_) => match other { + Self::ExcludeNewer(_) => false, + // Version specifiers cannot be reasonably compared + Self::RequiresPython(_) => false, + Self::NoBuild | Self::Yanked(_) => true, + }, + Self::Yanked(_) => match other { + Self::ExcludeNewer(_) | Self::RequiresPython(_) => false, + // Yanks with a reason are more helpful for errors + Self::Yanked(yanked_other) => matches!(yanked_other, Yanked::Reason(_)), + Self::NoBuild => true, + }, + Self::NoBuild => false, + } + } +} + +impl IncompatibleWheel { + fn is_more_compatible(&self, other: &Self) -> bool { + match self { + Self::ExcludeNewer(timestamp_self) => match other { + // Smaller timestamps are closer to the cut-off time + Self::ExcludeNewer(timestamp_other) => match (timestamp_self, timestamp_other) { + (None, _) => true, + (_, None) => false, + (Some(timestamp_self), Some(timestamp_other)) => { + timestamp_other < timestamp_self + } + }, + Self::NoBinary | Self::RequiresPython(_) | Self::Tag(_) | Self::Yanked(_) => true, + }, + Self::Tag(tag_self) => match other { + Self::ExcludeNewer(_) => false, + Self::Tag(tag_other) => tag_other > tag_self, + Self::NoBinary | Self::RequiresPython(_) | Self::Yanked(_) => true, + }, + Self::RequiresPython(_) => match other { + Self::ExcludeNewer(_) | Self::Tag(_) => false, + // Version specifiers cannot be reasonably compared + Self::RequiresPython(_) => false, + Self::NoBinary | Self::Yanked(_) => true, + }, + Self::Yanked(_) => match other { + Self::ExcludeNewer(_) | Self::Tag(_) | Self::RequiresPython(_) => false, + // Yanks with a reason are more helpful for errors + Self::Yanked(yanked_other) => matches!(yanked_other, Yanked::Reason(_)), + Self::NoBinary => true, + }, + Self::NoBinary => false, + } + } +} diff --git a/crates/pypi-types/src/simple_json.rs b/crates/pypi-types/src/simple_json.rs index a0de88571..2257c109f 100644 --- a/crates/pypi-types/src/simple_json.rs +++ b/crates/pypi-types/src/simple_json.rs @@ -89,7 +89,15 @@ impl DistInfoMetadata { } #[derive( - Debug, Clone, Serialize, Deserialize, rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, + Debug, + Clone, + PartialEq, + Eq, + Serialize, + Deserialize, + rkyv::Archive, + rkyv::Deserialize, + rkyv::Serialize, )] #[archive(check_bytes)] #[archive_attr(derive(Debug))] diff --git a/crates/uv-client/src/flat_index.rs b/crates/uv-client/src/flat_index.rs index ad6cc6b0b..d66c3a7e6 100644 --- a/crates/uv-client/src/flat_index.rs +++ b/crates/uv-client/src/flat_index.rs @@ -11,12 +11,12 @@ use url::Url; use distribution_filename::DistFilename; use distribution_types::{ BuiltDist, Dist, File, FileLocation, FlatIndexLocation, IndexUrl, PrioritizedDist, - RegistryBuiltDist, RegistrySourceDist, SourceDist, + RegistryBuiltDist, RegistrySourceDist, SourceDist, SourceDistCompatibility, }; use pep440_rs::Version; use pep508_rs::VerbatimUrl; use platform_tags::Tags; -use pypi_types::{Hashes, Yanked}; +use pypi_types::Hashes; use uv_auth::safe_copy_url_auth; use uv_cache::{Cache, CacheBucket}; use uv_normalize::PackageName; @@ -307,20 +307,14 @@ impl FlatIndex { })); match distributions.0.entry(version) { Entry::Occupied(mut entry) => { - entry.get_mut().insert_built( - dist, - None, - Yanked::default(), - None, - compatibility.into(), - ); + entry + .get_mut() + .insert_built(dist, None, compatibility.into()); } Entry::Vacant(entry) => { entry.insert(PrioritizedDist::from_built( dist, None, - Yanked::default(), - None, compatibility.into(), )); } @@ -334,16 +328,17 @@ impl FlatIndex { })); match distributions.0.entry(filename.version) { Entry::Occupied(mut entry) => { - entry - .get_mut() - .insert_source(dist, None, Yanked::default(), None); + entry.get_mut().insert_source( + dist, + None, + SourceDistCompatibility::Compatible, + ); } Entry::Vacant(entry) => { entry.insert(PrioritizedDist::from_source( dist, None, - Yanked::default(), - None, + SourceDistCompatibility::Compatible, )); } } diff --git a/crates/uv-dev/src/install_many.rs b/crates/uv-dev/src/install_many.rs index adcd3a381..983d36c9b 100644 --- a/crates/uv-dev/src/install_many.rs +++ b/crates/uv-dev/src/install_many.rs @@ -122,6 +122,7 @@ async fn install_chunk( venv.interpreter(), &FlatIndex::default(), &NoBinary::None, + &NoBuild::None, ) .resolve_stream(requirements) .collect() diff --git a/crates/uv-resolver/src/candidate_selector.rs b/crates/uv-resolver/src/candidate_selector.rs index c83b8565e..de7892645 100644 --- a/crates/uv-resolver/src/candidate_selector.rs +++ b/crates/uv-resolver/src/candidate_selector.rs @@ -1,7 +1,7 @@ use pubgrub::range::Range; use rustc_hash::FxHashMap; -use distribution_types::CompatibleDist; +use distribution_types::{CompatibleDist, IncompatibleDist, IncompatibleSource}; use distribution_types::{DistributionMetadata, IncompatibleWheel, Name, PrioritizedDist}; use pep440_rs::Version; use pep508_rs::{MarkerEnvironment, Requirement, VersionOrUrl}; @@ -192,7 +192,7 @@ impl CandidateSelector { for (version, maybe_dist) in versions { steps += 1; - let dist = if version.any_prerelease() { + let candidate = if version.any_prerelease() { if range.contains(version) { match allow_prerelease { AllowPreRelease::Yes => { @@ -209,7 +209,7 @@ impl CandidateSelector { ); // If pre-releases are allowed, treat them equivalently // to stable distributions. - dist + Candidate::new(package_name, version, dist) } AllowPreRelease::IfNecessary => { let Some(dist) = maybe_dist.prioritized_dist() else { @@ -235,7 +235,7 @@ impl CandidateSelector { // current range. prerelease = Some(PreReleaseCandidate::NotNecessary); - // Always return the first-matching stable distribution. + // Return the first-matching stable distribution. if range.contains(version) { let Some(dist) = maybe_dist.prioritized_dist() else { continue; @@ -248,18 +248,29 @@ impl CandidateSelector { steps, version, ); - dist + Candidate::new(package_name, version, dist) } else { continue; } }; - // Skip empty candidates due to exclude newer - if dist.exclude_newer() && dist.incompatible_wheel().is_none() && dist.get().is_none() { + // If candidate is not compatible due to exclude newer, continue searching. + // This is a special case — we pretend versions with exclude newer incompatibilities + // do not exist so that they are not present in error messages in our test suite. + // TODO(zanieb): Now that `--exclude-newer` is user facing we may want to consider + // flagging this behavior such that we _will_ report filtered distributions due to + // exclude-newer in our error messages. + if matches!( + candidate.dist(), + CandidateDist::Incompatible( + IncompatibleDist::Source(IncompatibleSource::ExcludeNewer(_)) + | IncompatibleDist::Wheel(IncompatibleWheel::ExcludeNewer(_)) + ) + ) { continue; } - return Some(Candidate::new(package_name, version, dist)); + return Some(candidate); } tracing::trace!( "exhausted all candidates for package {:?} with range {:?} \ @@ -281,23 +292,26 @@ impl CandidateSelector { #[derive(Debug, Clone)] pub(crate) enum CandidateDist<'a> { Compatible(CompatibleDist<'a>), - Incompatible(Option<&'a IncompatibleWheel>), - ExcludeNewer, + Incompatible(IncompatibleDist), } impl<'a> From<&'a PrioritizedDist> for CandidateDist<'a> { fn from(value: &'a PrioritizedDist) -> Self { if let Some(dist) = value.get() { CandidateDist::Compatible(dist) - } else if value.exclude_newer() && value.incompatible_wheel().is_none() { - // If empty because of exclude-newer, mark as a special case - CandidateDist::ExcludeNewer } else { - CandidateDist::Incompatible( - value - .incompatible_wheel() - .map(|(_, incompatibility)| incompatibility), - ) + // 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(dist) } } } diff --git a/crates/uv-resolver/src/finder.rs b/crates/uv-resolver/src/finder.rs index b49e36446..c6f6770a3 100644 --- a/crates/uv-resolver/src/finder.rs +++ b/crates/uv-resolver/src/finder.rs @@ -5,7 +5,7 @@ use anyhow::Result; use futures::{stream, Stream, StreamExt, TryStreamExt}; use rustc_hash::FxHashMap; -use uv_traits::NoBinary; +use uv_traits::{NoBinary, NoBuild}; use distribution_filename::DistFilename; use distribution_types::{Dist, IndexUrl, Resolution}; @@ -26,6 +26,7 @@ pub struct DistFinder<'a> { interpreter: &'a Interpreter, flat_index: &'a FlatIndex, no_binary: &'a NoBinary, + no_build: &'a NoBuild, } impl<'a> DistFinder<'a> { @@ -36,6 +37,7 @@ impl<'a> DistFinder<'a> { interpreter: &'a Interpreter, flat_index: &'a FlatIndex, no_binary: &'a NoBinary, + no_build: &'a NoBuild, ) -> Self { Self { tags, @@ -44,6 +46,7 @@ impl<'a> DistFinder<'a> { interpreter, flat_index, no_binary, + no_build, } } @@ -135,6 +138,11 @@ impl<'a> DistFinder<'a> { NoBinary::All => true, NoBinary::Packages(packages) => packages.contains(&requirement.name), }; + let no_build = match self.no_build { + NoBuild::None => false, + NoBuild::All => true, + NoBuild::Packages(packages) => packages.contains(&requirement.name), + }; // Prioritize the flat index by initializing the "best" matches with its entries. let matching_override = if let Some(flat_index) = flat_index { @@ -154,8 +162,10 @@ impl<'a> DistFinder<'a> { Some(version.clone()), resolvable_dist .compatible_wheel() - .map(|(dist, tag_priority)| (dist.dist.clone(), tag_priority)), - resolvable_dist.source().map(|dist| dist.dist.clone()), + .map(|(dist, tag_priority)| (dist.clone(), tag_priority)), + resolvable_dist + .compatible_source() + .map(std::clone::Clone::clone), ) } else { (None, None, None) @@ -213,7 +223,7 @@ impl<'a> DistFinder<'a> { } // Find the most-compatible sdist, if no wheel was found. - if best_wheel.is_none() { + if !no_build && best_wheel.is_none() { for version_sdist in files.source_dists { // Only add dists compatible with the python version. // This is relevant for source dists which give no other indication of their diff --git a/crates/uv-resolver/src/pins.rs b/crates/uv-resolver/src/pins.rs index 6c656c500..70e055ae8 100644 --- a/crates/uv-resolver/src/pins.rs +++ b/crates/uv-resolver/src/pins.rs @@ -15,10 +15,10 @@ pub(crate) struct FilePins(FxHashMap &Version { &self.target } - - /// If the dist doesn't match the given Python requirement, return the version specifiers. - pub(crate) fn validate_dist<'a>( - &self, - dist: &'a CompatibleDist, - ) -> Option<&'a VersionSpecifiers> { - // Validate the _installed_ file. - let requires_python = dist.for_installation().requires_python.as_ref()?; - - // If the dist doesn't support the target Python version, return the failing version - // specifiers. - if !requires_python.contains(self.target()) { - return Some(requires_python); - } - - // If the dist is a source distribution, and doesn't support the installed Python - // version, return the failing version specifiers, since we won't be able to build it. - if matches!(dist.for_installation().dist, Dist::Source(_)) - && !requires_python.contains(self.installed()) - { - return Some(requires_python); - } - - // Validate the resolved file. - let requires_python = dist.for_resolution().requires_python.as_ref()?; - - // If the dist is a source distribution, and doesn't support the installed Python - // version, return the failing version specifiers, since we won't be able to build it. - // This isn't strictly necessary, since if `dist.resolve_metadata()` is a source distribution, it - // should be the same file as `dist.install_metadata()` (validated above). - if matches!(dist.for_resolution().dist, Dist::Source(_)) - && !requires_python.contains(self.installed()) - { - return Some(requires_python); - } - - None - } } diff --git a/crates/uv-resolver/src/resolver/mod.rs b/crates/uv-resolver/src/resolver/mod.rs index a35a0e58d..8decdf540 100644 --- a/crates/uv-resolver/src/resolver/mod.rs +++ b/crates/uv-resolver/src/resolver/mod.rs @@ -14,15 +14,15 @@ use pubgrub::solver::{Incompatibility, State}; use rustc_hash::{FxHashMap, FxHashSet}; use tokio::select; use tokio_stream::wrappers::ReceiverStream; -use tracing::{debug, info_span, instrument, trace, warn, Instrument}; +use tracing::{debug, info_span, instrument, trace, Instrument}; use url::Url; use distribution_filename::WheelFilename; use distribution_types::{ - BuiltDist, Dist, DistributionMetadata, IncompatibleWheel, Name, RemoteSource, SourceDist, - VersionOrUrl, + BuiltDist, Dist, DistributionMetadata, IncompatibleDist, IncompatibleSource, IncompatibleWheel, + Name, RemoteSource, SourceDist, VersionOrUrl, }; -use pep440_rs::{Version, VersionSpecifiers, MIN_VERSION}; +use pep440_rs::{Version, MIN_VERSION}; use pep508_rs::{MarkerEnvironment, Requirement}; use platform_tags::{IncompatibleTag, Tags}; use pypi_types::{Metadata23, Yanked}; @@ -53,6 +53,7 @@ pub use crate::resolver::provider::{ }; use crate::resolver::reporter::Facade; pub use crate::resolver::reporter::{BuildId, Reporter}; + use crate::yanks::AllowedYanks; use crate::{DependencyMode, Options}; @@ -65,12 +66,8 @@ mod urls; /// Unlike [`PackageUnavailable`] this applies to a single version of the package #[derive(Debug, Clone)] pub(crate) enum UnavailableVersion { - /// Version is incompatible due to the `Requires-Python` version specifiers for that package. - RequiresPython(VersionSpecifiers), - /// Version is incompatible because it is yanked - Yanked(Yanked), /// Version is incompatible because it has no usable distributions - NoDistributions(Option), + IncompatibleDist(IncompatibleDist), } /// The package is unavailable and cannot be used @@ -97,7 +94,6 @@ pub struct Resolver<'a, Provider: ResolverProvider> { constraints: Constraints, overrides: Overrides, editables: Editables, - allowed_yanks: AllowedYanks, urls: Urls, dependency_mode: DependencyMode, markers: &'a MarkerEnvironment, @@ -134,8 +130,10 @@ impl<'a, Context: BuildContext + Send + Sync> Resolver<'a, DefaultResolverProvid flat_index, tags, PythonRequirement::new(interpreter, markers), + AllowedYanks::from_manifest(&manifest, markers), options.exclude_newer, build_context.no_binary(), + build_context.no_build(), ); Self::new_custom_io( manifest, @@ -163,7 +161,6 @@ impl<'a, Provider: ResolverProvider> Resolver<'a, Provider> { unavailable_packages: DashMap::default(), visited: DashSet::default(), selector: CandidateSelector::for_resolution(options, &manifest, markers), - allowed_yanks: AllowedYanks::from_manifest(&manifest, markers), dependency_mode: options.dependency_mode, urls: Urls::from_manifest(&manifest, markers)?, project: manifest.project, @@ -341,10 +338,13 @@ impl<'a, Provider: ResolverProvider> Resolver<'a, Provider> { ResolverVersion::Available(version) => version, ResolverVersion::Unavailable(version, unavailable) => { let reason = match unavailable { - UnavailableVersion::RequiresPython(requires_python) => { - // Incompatible requires-python versions are special in that we track - // them as incompatible dependencies instead of marking the package version - // as unavailable directly + // Incompatible requires-python versions are special in that we track + // them as incompatible dependencies instead of marking the package version + // as unavailable directly + UnavailableVersion::IncompatibleDist( + IncompatibleDist::Source(IncompatibleSource::RequiresPython(requires_python)) + | IncompatibleDist::Wheel(IncompatibleWheel::RequiresPython(requires_python)) + ) => { let python_version = requires_python .iter() .map(PubGrubSpecifier::try_from) @@ -363,30 +363,51 @@ impl<'a, Provider: ResolverProvider> Resolver<'a, Provider> { state.partial_solution.add_decision(next.clone(), version); continue; } - UnavailableVersion::Yanked(yanked) => match yanked { - Yanked::Bool(_) => "it was yanked".to_string(), - Yanked::Reason(reason) => format!( - "it was yanked (reason: {})", - reason.trim().trim_end_matches('.') - ), - }, - UnavailableVersion::NoDistributions(best_incompatible) => { - if let Some(best_incompatible) = best_incompatible { - match best_incompatible { - IncompatibleWheel::NoBinary => "no source distribution is available and using wheels is disabled".to_string(), - IncompatibleWheel::RequiresPython => "no wheels are available that meet your required Python version".to_string(), - IncompatibleWheel::Tag(tag) => { - match tag { - IncompatibleTag::Invalid => "no wheels are available with valid tags".to_string(), - IncompatibleTag::Python => "no wheels are available with a matching Python implementation".to_string(), - IncompatibleTag::Abi => "no wheels are available with a matching Python ABI".to_string(), - IncompatibleTag::Platform => "no wheels are available with a matching platform".to_string(), + UnavailableVersion::IncompatibleDist(incompatibility) => { + match incompatibility { + IncompatibleDist::Wheel(incompatibility) => { + match incompatibility { + IncompatibleWheel::NoBinary => "no source distribution is available and using wheels is disabled".to_string(), + IncompatibleWheel::Tag(tag) => { + match tag { + IncompatibleTag::Invalid => "no wheels are available with valid tags".to_string(), + IncompatibleTag::Python => "no wheels are available with a matching Python implementation".to_string(), + IncompatibleTag::Abi => "no wheels are available with a matching Python ABI".to_string(), + IncompatibleTag::Platform => "no wheels are available with a matching platform".to_string(), + } } + IncompatibleWheel::Yanked(yanked) => match yanked { + Yanked::Bool(_) => "it was yanked".to_string(), + Yanked::Reason(reason) => format!( + "it was yanked (reason: {})", + reason.trim().trim_end_matches('.') + ), + }, + IncompatibleWheel::ExcludeNewer(ts) => match ts { + Some(_) => "it was published after the exclude newer time".to_string(), + None => "it has no publish time".to_string() + } + IncompatibleWheel::RequiresPython(_) => unreachable!(), } } - } else { - // TODO(zanieb): It's unclear why we would encounter this case still - "no wheels are available for your system".to_string() + IncompatibleDist::Source(incompatibility) => { + match incompatibility { + IncompatibleSource::NoBuild => "no wheels are usable and building from source is disabled".to_string(), + IncompatibleSource::Yanked(yanked) => match yanked { + Yanked::Bool(_) => "it was yanked".to_string(), + Yanked::Reason(reason) => format!( + "it was yanked (reason: {})", + reason.trim().trim_end_matches('.') + ), + }, + IncompatibleSource::ExcludeNewer(ts) => match ts { + Some(_) => "it was published after the exclude newer time".to_string(), + None => "it has no publish time".to_string() + } + IncompatibleSource::RequiresPython(_) => unreachable!(), + } + } + IncompatibleDist::Unavailable => "no distributions are available".to_string() } } }; @@ -521,7 +542,7 @@ impl<'a, Provider: ResolverProvider> Resolver<'a, Provider> { #[instrument(skip_all, fields(%package))] async fn choose_version( &self, - package: &PubGrubPackage, + package: &'a PubGrubPackage, range: &Range, pins: &mut FilePins, request_sink: &tokio::sync::mpsc::Sender, @@ -644,42 +665,15 @@ impl<'a, Provider: ResolverProvider> Resolver<'a, Provider> { let dist = match candidate.dist() { CandidateDist::Compatible(dist) => dist, - CandidateDist::ExcludeNewer => { - // If the version is incompatible because of `exclude_newer`, pretend the versions do not exist - return Ok(None); - } CandidateDist::Incompatible(incompatibility) => { - // If the version is incompatible because no distributions match, exit early. + // If the version is incompatible because no distributions are compatible, exit early. return Ok(Some(ResolverVersion::Unavailable( candidate.version().clone(), - UnavailableVersion::NoDistributions(incompatibility.cloned()), + UnavailableVersion::IncompatibleDist(incompatibility.clone()), ))); } }; - // If the version is incompatible because it was yanked, exit early. - if dist.yanked().is_yanked() { - if self - .allowed_yanks - .allowed(package_name, candidate.version()) - { - warn!("Allowing yanked version: {}", candidate.package_id()); - } else { - return Ok(Some(ResolverVersion::Unavailable( - candidate.version().clone(), - UnavailableVersion::Yanked(dist.yanked().clone()), - ))); - } - } - - // If the version is incompatible because of its Python requirement - if let Some(requires_python) = self.python_requirement.validate_dist(dist) { - return Ok(Some(ResolverVersion::Unavailable( - candidate.version().clone(), - UnavailableVersion::RequiresPython(requires_python.clone()), - ))); - } - if let Some(extra) = extra { debug!( "Selecting: {}[{}]=={} ({})", @@ -687,7 +681,6 @@ impl<'a, Provider: ResolverProvider> Resolver<'a, Provider> { extra, candidate.version(), dist.for_resolution() - .dist .filename() .unwrap_or(Cow::Borrowed("unknown filename")) ); @@ -697,7 +690,6 @@ impl<'a, Provider: ResolverProvider> Resolver<'a, Provider> { candidate.name(), candidate.version(), dist.for_resolution() - .dist .filename() .unwrap_or(Cow::Borrowed("unknown filename")) ); @@ -711,7 +703,7 @@ impl<'a, Provider: ResolverProvider> Resolver<'a, Provider> { // Emit a request to fetch the metadata for this version. if self.index.distributions.register(candidate.package_id()) { - let dist = dist.for_resolution().dist.clone(); + let dist = dist.for_resolution().clone(); request_sink.send(Request::Dist(dist)).await?; } @@ -1023,7 +1015,7 @@ impl<'a, Provider: ResolverProvider> Resolver<'a, Provider> { }; // Try to find a compatible version. If there aren't any compatible versions, - // short-circuit and return `None`. + // short-circuit. let Some(candidate) = self.selector.select(&package_name, &range, version_map) else { return Ok(None); @@ -1034,14 +1026,9 @@ impl<'a, Provider: ResolverProvider> Resolver<'a, Provider> { return Ok(None); }; - // If the Python version is incompatible, short-circuit. - if self.python_requirement.validate_dist(dist).is_some() { - return Ok(None); - } - // Emit a request to fetch the metadata for this version. if self.index.distributions.register(candidate.package_id()) { - let dist = dist.for_resolution().dist.clone(); + let dist = dist.for_resolution().clone(); let (metadata, precise) = self .provider diff --git a/crates/uv-resolver/src/resolver/provider.rs b/crates/uv-resolver/src/resolver/provider.rs index fae19ee6d..2a114fae7 100644 --- a/crates/uv-resolver/src/resolver/provider.rs +++ b/crates/uv-resolver/src/resolver/provider.rs @@ -10,10 +10,11 @@ use pypi_types::Metadata23; use uv_client::{FlatIndex, RegistryClient}; use uv_distribution::DistributionDatabase; use uv_normalize::PackageName; -use uv_traits::{BuildContext, NoBinary}; +use uv_traits::{BuildContext, NoBinary, NoBuild}; use crate::python_requirement::PythonRequirement; use crate::version_map::VersionMap; +use crate::yanks::AllowedYanks; pub type PackageVersionsResult = Result; pub type WheelMetadataResult = Result<(Metadata23, Option), uv_distribution::Error>; @@ -66,8 +67,10 @@ pub struct DefaultResolverProvider<'a, Context: BuildContext + Send + Sync> { flat_index: FlatIndex, tags: Tags, python_requirement: PythonRequirement, + allowed_yanks: AllowedYanks, exclude_newer: Option>, no_binary: NoBinary, + no_build: NoBuild, } impl<'a, Context: BuildContext + Send + Sync> DefaultResolverProvider<'a, Context> { @@ -79,8 +82,10 @@ impl<'a, Context: BuildContext + Send + Sync> DefaultResolverProvider<'a, Contex flat_index: &'a FlatIndex, tags: &'a Tags, python_requirement: PythonRequirement, + allowed_yanks: AllowedYanks, exclude_newer: Option>, no_binary: &'a NoBinary, + no_build: &'a NoBuild, ) -> Self { Self { fetcher, @@ -88,8 +93,10 @@ impl<'a, Context: BuildContext + Send + Sync> DefaultResolverProvider<'a, Contex flat_index: flat_index.clone(), tags: tags.clone(), python_requirement, + allowed_yanks, exclude_newer, no_binary: no_binary.clone(), + no_build: no_build.clone(), } } } @@ -113,9 +120,11 @@ impl<'a, Context: BuildContext + Send + Sync> ResolverProvider &index, &self.tags, &self.python_requirement, + &self.allowed_yanks, self.exclude_newer.as_ref(), self.flat_index.get(package_name).cloned(), &self.no_binary, + &self.no_build, ))), Err(err) => match err.into_kind() { uv_client::ErrorKind::PackageNotFound(_) => { diff --git a/crates/uv-resolver/src/version_map.rs b/crates/uv-resolver/src/version_map.rs index 7210cfb37..0b269dbc1 100644 --- a/crates/uv-resolver/src/version_map.rs +++ b/crates/uv-resolver/src/version_map.rs @@ -2,20 +2,24 @@ use std::collections::btree_map::{BTreeMap, Entry}; use std::sync::OnceLock; use chrono::{DateTime, Utc}; +use rustc_hash::FxHashSet; use tracing::{instrument, warn}; -use distribution_filename::DistFilename; -use distribution_types::{Dist, IncompatibleWheel, IndexUrl, PrioritizedDist, WheelCompatibility}; -use pep440_rs::Version; +use distribution_filename::{DistFilename, WheelFilename}; +use distribution_types::{ + Dist, IncompatibleSource, IncompatibleWheel, IndexUrl, PrioritizedDist, + SourceDistCompatibility, WheelCompatibility, +}; +use pep440_rs::{Version, VersionSpecifiers}; use platform_tags::Tags; -use pypi_types::Hashes; +use pypi_types::{Hashes, Yanked}; use rkyv::{de::deserializers::SharedDeserializeMap, Deserialize}; use uv_client::{FlatDistributions, OwnedArchive, SimpleMetadata, VersionFiles}; use uv_normalize::PackageName; -use uv_traits::NoBinary; +use uv_traits::{NoBinary, NoBuild}; use uv_warnings::warn_user_once; -use crate::python_requirement::PythonRequirement; +use crate::{python_requirement::PythonRequirement, yanks::AllowedYanks}; /// A map from versions to distributions. #[derive(Debug)] @@ -25,6 +29,14 @@ pub struct VersionMap { impl VersionMap { /// Initialize a [`VersionMap`] from the given metadata. + /// + /// Note it is possible for files to have a different yank status per PEP 592 but in the official + /// PyPI warehouse this cannot happen. + /// + /// Here, we track if each file is yanked separately. If a release is partially yanked, the + /// unyanked distributions _can_ be used. + /// + /// PEP 592: #[instrument(skip_all, fields(package_name))] #[allow(clippy::too_many_arguments)] pub(crate) fn from_metadata( @@ -33,9 +45,11 @@ impl VersionMap { index: &IndexUrl, tags: &Tags, python_requirement: &PythonRequirement, + allowed_yanks: &AllowedYanks, exclude_newer: Option<&DateTime>, flat_index: Option, no_binary: &NoBinary, + no_build: &NoBuild, ) -> Self { let mut map = BTreeMap::new(); // Create stubs for each entry in simple metadata. The full conversion @@ -85,15 +99,27 @@ impl VersionMap { NoBinary::All => true, NoBinary::Packages(packages) => packages.contains(package_name), }; + // Check if source distributions are allowed for this package. + let no_build = match no_build { + NoBuild::None => false, + NoBuild::All => true, + NoBuild::Packages(packages) => packages.contains(package_name), + }; + let allowed_yanks = allowed_yanks + .allowed_versions(package_name) + .cloned() + .unwrap_or_default(); Self { inner: VersionMapInner::Lazy(VersionMapLazy { map, simple_metadata, no_binary, + no_build, index: index.clone(), tags: tags.clone(), python_requirement: python_requirement.clone(), exclude_newer: exclude_newer.copied(), + allowed_yanks, }), } } @@ -256,6 +282,8 @@ struct VersionMapLazy { simple_metadata: OwnedArchive, /// When true, wheels aren't allowed. no_binary: bool, + /// When true, source dists aren't allowed. + no_build: bool, /// The URL of the index where this package came from. index: IndexUrl, /// The set of compatibility tags that determines whether a wheel is usable @@ -267,6 +295,8 @@ struct VersionMapLazy { python_requirement: PythonRequirement, /// Whether files newer than this timestamp should be excluded or not. exclude_newer: Option>, + /// Which yanked versions are allowed + allowed_yanks: FxHashSet, } impl VersionMapLazy { @@ -319,69 +349,62 @@ impl VersionMapLazy { .expect("archived version files should deserialize"); let mut priority_dist = init.cloned().unwrap_or_default(); for (filename, file) in files.all() { - if let Some(exclude_newer) = self.exclude_newer { + // Support resolving as if it were an earlier timestamp, at least as long files have + // upload time information. + let (excluded, upload_time) = if let Some(exclude_newer) = self.exclude_newer { match file.upload_time_utc_ms.as_ref() { Some(&upload_time) if upload_time >= exclude_newer.timestamp_millis() => { - priority_dist.set_exclude_newer(); - continue; + (true, Some(upload_time)) } None => { warn_user_once!( "{} is missing an upload date, but user provided: {exclude_newer}", file.filename, ); - priority_dist.set_exclude_newer(); - continue; + (true, None) } - _ => {} + _ => (false, None), } - } - let yanked = file.yanked.clone().unwrap_or_default(); + } else { + (false, None) + }; + + // Prioritize amongst all available files. + let version = filename.version().clone(); let requires_python = file.requires_python.clone(); + let yanked = file.yanked.clone(); let hash = file.hashes.clone(); match filename { DistFilename::WheelFilename(filename) => { - // Determine a compatibility for the wheel based on tags - let mut compatibility = - WheelCompatibility::from(filename.compatibility(&self.tags)); - - if compatibility.is_compatible() { - // Check for Python version incompatibility - if let Some(ref requires_python) = file.requires_python { - if !requires_python.contains(self.python_requirement.target()) { - compatibility = WheelCompatibility::Incompatible( - IncompatibleWheel::RequiresPython, - ); - } - } - - // Mark all wheels as incompatibility when binaries are disabled - if self.no_binary { - compatibility = - WheelCompatibility::Incompatible(IncompatibleWheel::NoBinary); - } - }; - + let compatibility = self.wheel_compatibility( + &filename, + &version, + requires_python, + yanked, + excluded, + upload_time, + ); let dist = Dist::from_registry( DistFilename::WheelFilename(filename), file, self.index.clone(), ); - priority_dist.insert_built( - dist, - requires_python, - yanked, - Some(hash), - compatibility, - ); + priority_dist.insert_built(dist, Some(hash), compatibility); } DistFilename::SourceDistFilename(filename) => { + let compatibility = self.source_dist_compatibility( + &version, + requires_python, + yanked, + excluded, + upload_time, + ); let dist = Dist::from_registry( DistFilename::SourceDistFilename(filename), file, self.index.clone(), ); - priority_dist.insert_source(dist, requires_python, yanked, Some(hash)); + priority_dist.insert_source(dist, Some(hash), compatibility); } } } @@ -393,6 +416,88 @@ impl VersionMapLazy { }; simple.dist.get_or_init(get_or_init).as_ref() } + + fn source_dist_compatibility( + &self, + version: &Version, + requires_python: Option, + yanked: Option, + excluded: bool, + upload_time: Option, + ) -> SourceDistCompatibility { + // Check if builds are disabled + if self.no_build { + return SourceDistCompatibility::Incompatible(IncompatibleSource::NoBuild); + } + + // Check if after upload time cutoff + if excluded { + return SourceDistCompatibility::Incompatible(IncompatibleSource::ExcludeNewer( + upload_time, + )); + } + + // Check if yanked + if let Some(yanked) = yanked { + if yanked.is_yanked() && !self.allowed_yanks.contains(version) { + return SourceDistCompatibility::Incompatible(IncompatibleSource::Yanked(yanked)); + } + } + + // Check if Python version is supported + // Source distributions must meet both the _target_ Python version and the + // _installed_ Python version (to build successfully) + if let Some(requires_python) = requires_python { + if !requires_python.contains(self.python_requirement.target()) + || !requires_python.contains(self.python_requirement.installed()) + { + return SourceDistCompatibility::Incompatible(IncompatibleSource::RequiresPython( + requires_python, + )); + } + } + + SourceDistCompatibility::Compatible + } + + fn wheel_compatibility( + &self, + filename: &WheelFilename, + version: &Version, + requires_python: Option, + yanked: Option, + excluded: bool, + upload_time: Option, + ) -> WheelCompatibility { + // Check if binaries are disabled + if self.no_binary { + return WheelCompatibility::Incompatible(IncompatibleWheel::NoBinary); + } + + // Check if after upload time cutoff + if excluded { + return WheelCompatibility::Incompatible(IncompatibleWheel::ExcludeNewer(upload_time)); + } + + // Check if yanked + if let Some(yanked) = yanked { + if yanked.is_yanked() && !self.allowed_yanks.contains(version) { + return WheelCompatibility::Incompatible(IncompatibleWheel::Yanked(yanked)); + } + } + + // Check for a Python version incompatibility` + if let Some(requires_python) = requires_python { + if !requires_python.contains(self.python_requirement.target()) { + return WheelCompatibility::Incompatible(IncompatibleWheel::RequiresPython( + requires_python, + )); + } + } + + // Determine a compatibility for the wheel based on tags + WheelCompatibility::from(filename.compatibility(&self.tags)) + } } /// Represents a possibly initialized [`PrioritizedDist`] for @@ -402,7 +507,7 @@ enum LazyPrioritizedDist { /// Represents a eagerly constructed distribution from a /// `FlatDistributions`. OnlyFlat(PrioritizedDist), - /// Represents a lazyily constructed distribution from an index into a + /// Represents a lazily constructed distribution from an index into a /// `VersionFiles` from `SimpleMetadata`. OnlySimple(SimplePrioritizedDist), /// Combines the above. This occurs when we have data from both a flat diff --git a/crates/uv-resolver/src/yanks.rs b/crates/uv-resolver/src/yanks.rs index 8c092bb3c..786d82a83 100644 --- a/crates/uv-resolver/src/yanks.rs +++ b/crates/uv-resolver/src/yanks.rs @@ -8,8 +8,8 @@ use crate::Manifest; /// A set of package versions that are permitted, even if they're marked as yanked by the /// relevant index. -#[derive(Debug, Default)] -pub(crate) struct AllowedYanks(FxHashMap>); +#[derive(Debug, Default, Clone)] +pub struct AllowedYanks(FxHashMap>); impl AllowedYanks { pub(crate) fn from_manifest(manifest: &Manifest, markers: &MarkerEnvironment) -> Self { @@ -49,11 +49,12 @@ impl AllowedYanks { Self(allowed_yanks) } - /// Returns `true` if the given package version is allowed, even if it's marked as yanked by - /// the relevant index. - pub(crate) fn allowed(&self, package_name: &PackageName, version: &Version) -> bool { - self.0 - .get(package_name) - .is_some_and(|allowed_yanks| allowed_yanks.contains(version)) + /// Returns versions for the given package which are allowed even if marked as yanked by the + /// relevant index. + pub(crate) fn allowed_versions( + &self, + package_name: &PackageName, + ) -> Option<&FxHashSet> { + self.0.get(package_name) } } diff --git a/crates/uv/src/commands/pip_sync.rs b/crates/uv/src/commands/pip_sync.rs index 3daacbf02..a4d5dae3e 100644 --- a/crates/uv/src/commands/pip_sync.rs +++ b/crates/uv/src/commands/pip_sync.rs @@ -220,9 +220,15 @@ pub(crate) async fn pip_sync( } else { let start = std::time::Instant::now(); - let wheel_finder = - uv_resolver::DistFinder::new(tags, &client, venv.interpreter(), &flat_index, no_binary) - .with_reporter(FinderReporter::from(printer).with_length(remote.len() as u64)); + let wheel_finder = uv_resolver::DistFinder::new( + tags, + &client, + venv.interpreter(), + &flat_index, + no_binary, + no_build, + ) + .with_reporter(FinderReporter::from(printer).with_length(remote.len() as u64)); let resolution = wheel_finder.resolve(&remote).await?; let s = if resolution.len() == 1 { "" } else { "s" }; diff --git a/crates/uv/tests/pip_install_scenarios.rs b/crates/uv/tests/pip_install_scenarios.rs index 4ff10eb07..85075a38a 100644 --- a/crates/uv/tests/pip_install_scenarios.rs +++ b/crates/uv/tests/pip_install_scenarios.rs @@ -3445,12 +3445,13 @@ fn no_wheels_no_build() { .arg("no-wheels-no-build-a") , @r###" success: false - exit_code: 2 + exit_code: 1 ----- stdout ----- ----- stderr ----- - error: Failed to download and build: albatross==1.0.0 - Caused by: Building source distributions is disabled + × No solution found when resolving dependencies: + ╰─▶ Because only albatross==1.0.0 is available and albatross==1.0.0 is unusable because no wheels are usable and building from source is disabled, we can conclude that all versions of albatross cannot be used. + And because you require albatross, we can conclude that the requirements are unsatisfiable. "###); assert_not_installed(&context.venv, "no_wheels_no_build_a", &context.temp_dir);