diff --git a/Cargo.lock b/Cargo.lock index e69493b1b9d9..7e3d61132179 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5019,6 +5019,7 @@ dependencies = [ "fs-err 3.0.0", "itertools 0.14.0", "jiff", + "owo-colors", "petgraph", "rkyv", "rustc-hash", diff --git a/crates/uv-distribution-types/Cargo.toml b/crates/uv-distribution-types/Cargo.toml index d4c5309f5e05..b65ac8cfcc4d 100644 --- a/crates/uv-distribution-types/Cargo.toml +++ b/crates/uv-distribution-types/Cargo.toml @@ -34,6 +34,7 @@ bitflags = { workspace = true } fs-err = { workspace = true } itertools = { workspace = true } jiff = { workspace = true } +owo-colors = { workspace = true } petgraph = { workspace = true } rkyv = { workspace = true } rustc-hash = { workspace = true } diff --git a/crates/uv-distribution-types/src/prioritized_distribution.rs b/crates/uv-distribution-types/src/prioritized_distribution.rs index d5bfc4d5ecd2..d3235b0b49df 100644 --- a/crates/uv-distribution-types/src/prioritized_distribution.rs +++ b/crates/uv-distribution-types/src/prioritized_distribution.rs @@ -1,12 +1,14 @@ +use std::collections::BTreeSet; use std::fmt::{Display, Formatter}; use arcstr::ArcStr; +use owo_colors::OwoColorize; use tracing::debug; use uv_distribution_filename::{BuildTag, WheelFilename}; use uv_pep440::VersionSpecifiers; use uv_pep508::{MarkerExpression, MarkerOperator, MarkerTree, MarkerValueString}; -use uv_platform_tags::{IncompatibleTag, TagPriority}; +use uv_platform_tags::{AbiTag, IncompatibleTag, TagPriority, Tags}; use uv_pypi_types::{HashDigest, Yanked}; use crate::{ @@ -164,6 +166,40 @@ impl IncompatibleDist { Self::Unavailable => format!("have {self}"), } } + + pub fn context_message( + &self, + tags: Option<&Tags>, + requires_python: Option, + ) -> Option { + match self { + Self::Wheel(incompatibility) => match incompatibility { + IncompatibleWheel::Tag(IncompatibleTag::Python) => { + let tag = tags?.python_tag().map(ToString::to_string)?; + Some(format!("(e.g., `{tag}`)", tag = tag.cyan())) + } + IncompatibleWheel::Tag(IncompatibleTag::Abi) => { + let tag = tags?.abi_tag().map(ToString::to_string)?; + Some(format!("(e.g., `{tag}`)", tag = tag.cyan())) + } + IncompatibleWheel::Tag(IncompatibleTag::AbiPythonVersion) => { + let tag = requires_python?; + Some(format!("(e.g., `{tag}`)", tag = tag.cyan())) + } + IncompatibleWheel::Tag(IncompatibleTag::Platform) => { + let tag = tags?.platform_tag().map(ToString::to_string)?; + Some(format!("(e.g., `{tag}`)", tag = tag.cyan())) + } + IncompatibleWheel::Tag(IncompatibleTag::Invalid) => None, + IncompatibleWheel::NoBinary => None, + IncompatibleWheel::Yanked(..) => None, + IncompatibleWheel::ExcludeNewer(..) => None, + IncompatibleWheel::RequiresPython(..) => None, + }, + Self::Source(..) => None, + Self::Unavailable => None, + } + } } impl Display for IncompatibleDist { @@ -246,6 +282,8 @@ pub enum IncompatibleWheel { /// The wheel tags do not match those of the target Python platform. Tag(IncompatibleTag), /// The required Python version is not a superset of the target Python version range. + /// + /// TODO(charlie): Consider making this two variants to reduce enum size. RequiresPython(VersionSpecifiers, PythonRequirementKind), /// The wheel was yanked. Yanked(Yanked), @@ -483,6 +521,40 @@ impl PrioritizedDist { pub fn best_wheel(&self) -> Option<&(RegistryBuiltWheel, WheelCompatibility)> { self.0.best_wheel_index.map(|i| &self.0.wheels[i]) } + + /// Returns the set of all Python tags for the distribution. + pub fn python_tags(&self) -> BTreeSet<&str> { + self.0 + .wheels + .iter() + .flat_map(|(wheel, _)| wheel.filename.python_tag.iter().map(String::as_str)) + .collect() + } + + /// Returns the set of all ABI tags for the distribution. + pub fn abi_tags(&self) -> BTreeSet<&str> { + self.0 + .wheels + .iter() + .flat_map(|(wheel, _)| wheel.filename.abi_tag.iter().map(String::as_str)) + .collect() + } + + /// Returns the set of platform tags for the distribution that are ABI-compatible with the given + /// tags. + pub fn platform_tags<'a>(&'a self, tags: &'a Tags) -> BTreeSet<&'a str> { + let mut candidates = BTreeSet::new(); + for (wheel, _) in &self.0.wheels { + for wheel_py in &wheel.filename.python_tag { + for wheel_abi in &wheel.filename.abi_tag { + if tags.is_compatible_abi(wheel_py.as_str(), wheel_abi.as_str()) { + candidates.extend(wheel.filename.platform_tag.iter().map(String::as_str)); + } + } + } + } + candidates + } } impl<'a> CompatibleDist<'a> { diff --git a/crates/uv-platform-tags/src/tags.rs b/crates/uv-platform-tags/src/tags.rs index 1fc887b68332..3f41222c4939 100644 --- a/crates/uv-platform-tags/src/tags.rs +++ b/crates/uv-platform-tags/src/tags.rs @@ -5,8 +5,7 @@ use std::{cmp, num::NonZeroU32}; use rustc_hash::FxHashMap; -use crate::abi_tag::AbiTag; -use crate::{Arch, LanguageTag, Os, Platform, PlatformError}; +use crate::{AbiTag, Arch, LanguageTag, Os, Platform, PlatformError}; #[derive(Debug, thiserror::Error)] pub enum TagsError { @@ -75,6 +74,8 @@ pub struct Tags { /// `python_tag` |--> `abi_tag` |--> `platform_tag` |--> priority #[allow(clippy::type_complexity)] map: Arc>>>, + /// The highest-priority tag for the Python version and platform. + best: Option<(String, String, String)>, } impl Tags { @@ -83,6 +84,9 @@ impl Tags { /// Tags are prioritized based on their position in the given vector. Specifically, tags that /// appear earlier in the vector are given higher priority than tags that appear later. pub fn new(tags: Vec<(String, String, String)>) -> Self { + // Store the highest-priority tag for each component. + let best = tags.first().cloned(); + // Index the tags by Python version, ABI, and platform. let mut map = FxHashMap::default(); for (index, (py, abi, platform)) in tags.into_iter().rev().enumerate() { @@ -93,7 +97,11 @@ impl Tags { .entry(platform) .or_insert(TagPriority::try_from(index).expect("valid tag priority")); } - Self { map: Arc::new(map) } + + Self { + map: Arc::new(map), + best, + } } /// Returns the compatible tags for the given Python implementation (e.g., `cpython`), version, @@ -291,6 +299,30 @@ impl Tags { } max_compatibility } + + /// Return the highest-priority Python tag for the [`Tags`]. + pub fn python_tag(&self) -> Option<&str> { + self.best.as_ref().map(|(py, _, _)| py.as_str()) + } + + /// Return the highest-priority ABI tag for the [`Tags`]. + pub fn abi_tag(&self) -> Option<&str> { + self.best.as_ref().map(|(_, abi, _)| abi.as_str()) + } + + /// Return the highest-priority platform tag for the [`Tags`]. + pub fn platform_tag(&self) -> Option<&str> { + self.best.as_ref().map(|(_, _, platform)| platform.as_str()) + } + + /// Returns `true` if the given language and ABI tags are compatible with the current + /// environment. + pub fn is_compatible_abi<'a>(&'a self, python_tag: &'a str, abi_tag: &'a str) -> bool { + self.map + .get(python_tag) + .map(|abis| abis.contains_key(abi_tag)) + .unwrap_or(false) + } } /// The priority of a platform tag. diff --git a/crates/uv-resolver/src/candidate_selector.rs b/crates/uv-resolver/src/candidate_selector.rs index 3104b38a73f9..4f443becbd6c 100644 --- a/crates/uv-resolver/src/candidate_selector.rs +++ b/crates/uv-resolver/src/candidate_selector.rs @@ -236,6 +236,7 @@ impl CandidateSelector { return Some(Candidate { name: package_name, version, + prioritized: None, dist: CandidateDist::Compatible(CompatibleDist::InstalledDist( dist, )), @@ -302,6 +303,7 @@ impl CandidateSelector { return Some(Candidate { name: package_name, version, + prioritized: None, dist: CandidateDist::Compatible(CompatibleDist::InstalledDist(dist)), choice_kind: VersionChoiceKind::Installed, }); @@ -583,6 +585,8 @@ pub(crate) struct Candidate<'a> { name: &'a PackageName, /// The version of the package. version: &'a Version, + /// The prioritized distribution for the package. + prioritized: Option<&'a PrioritizedDist>, /// The distributions to use for resolving and installing the package. dist: CandidateDist<'a>, /// Whether this candidate was selected from a preference. @@ -599,6 +603,7 @@ impl<'a> Candidate<'a> { Self { name, version, + prioritized: Some(dist), dist: CandidateDist::from(dist), choice_kind, } @@ -632,6 +637,11 @@ impl<'a> Candidate<'a> { pub(crate) fn dist(&self) -> &CandidateDist<'a> { &self.dist } + + /// Return the prioritized distribution for the candidate. + pub(crate) fn prioritized(&self) -> Option<&PrioritizedDist> { + self.prioritized + } } impl Name for Candidate<'_> { diff --git a/crates/uv-resolver/src/error.rs b/crates/uv-resolver/src/error.rs index be35a4fc9ef5..188a02696c17 100644 --- a/crates/uv-resolver/src/error.rs +++ b/crates/uv-resolver/src/error.rs @@ -14,10 +14,12 @@ use uv_distribution_types::{ }; use uv_normalize::{ExtraName, PackageName}; use uv_pep440::{LocalVersionSlice, Version}; +use uv_platform_tags::Tags; use uv_static::EnvVars; use crate::candidate_selector::CandidateSelector; use crate::dependency_provider::UvDependencyProvider; +use crate::fork_indexes::ForkIndexes; use crate::fork_urls::ForkUrls; use crate::prerelease::AllowPrerelease; use crate::pubgrub::{PubGrubPackage, PubGrubPackageInner, PubGrubReportFormatter}; @@ -27,7 +29,7 @@ use crate::resolution::ConflictingDistributionError; use crate::resolver::{ MetadataUnavailable, ResolverEnvironment, UnavailablePackage, UnavailableReason, }; -use crate::Options; +use crate::{InMemoryIndex, Options}; #[derive(Debug, thiserror::Error)] pub enum ResolveError { @@ -130,9 +132,9 @@ impl From> for ResolveError { pub(crate) type ErrorTree = DerivationTree, UnavailableReason>; /// A wrapper around [`pubgrub::error::NoSolutionError`] that displays a resolution failure report. -#[derive(Debug)] pub struct NoSolutionError { error: pubgrub::NoSolutionError, + index: InMemoryIndex, available_versions: FxHashMap>, available_indexes: FxHashMap>, selector: CandidateSelector, @@ -142,7 +144,9 @@ pub struct NoSolutionError { unavailable_packages: FxHashMap, incomplete_packages: FxHashMap>, fork_urls: ForkUrls, + fork_indexes: ForkIndexes, env: ResolverEnvironment, + tags: Option, workspace_members: BTreeSet, options: Options, } @@ -151,6 +155,7 @@ impl NoSolutionError { /// Create a new [`NoSolutionError`] from a [`pubgrub::NoSolutionError`]. pub(crate) fn new( error: pubgrub::NoSolutionError, + index: InMemoryIndex, available_versions: FxHashMap>, available_indexes: FxHashMap>, selector: CandidateSelector, @@ -160,12 +165,15 @@ impl NoSolutionError { unavailable_packages: FxHashMap, incomplete_packages: FxHashMap>, fork_urls: ForkUrls, + fork_indexes: ForkIndexes, env: ResolverEnvironment, + tags: Option, workspace_members: BTreeSet, options: Options, ) -> Self { Self { error, + index, available_versions, available_indexes, selector, @@ -175,7 +183,9 @@ impl NoSolutionError { unavailable_packages, incomplete_packages, fork_urls, + fork_indexes, env, + tags, workspace_members, options, } @@ -328,6 +338,47 @@ impl NoSolutionError { } } +impl std::fmt::Debug for NoSolutionError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + // Include every field except `index`, which doesn't implement `Debug`. + let Self { + error, + index: _, + available_versions, + available_indexes, + selector, + python_requirement, + index_locations, + index_capabilities, + unavailable_packages, + incomplete_packages, + fork_urls, + fork_indexes, + env, + tags, + workspace_members, + options, + } = self; + f.debug_struct("NoSolutionError") + .field("error", error) + .field("available_versions", available_versions) + .field("available_indexes", available_indexes) + .field("selector", selector) + .field("python_requirement", python_requirement) + .field("index_locations", index_locations) + .field("index_capabilities", index_capabilities) + .field("unavailable_packages", unavailable_packages) + .field("incomplete_packages", incomplete_packages) + .field("fork_urls", fork_urls) + .field("fork_indexes", fork_indexes) + .field("env", env) + .field("tags", tags) + .field("workspace_members", workspace_members) + .field("options", options) + .finish() + } +} + impl std::error::Error for NoSolutionError {} impl std::fmt::Display for NoSolutionError { @@ -337,6 +388,7 @@ impl std::fmt::Display for NoSolutionError { available_versions: &self.available_versions, python_requirement: &self.python_requirement, workspace_members: &self.workspace_members, + tags: self.tags.as_ref(), }; // Transform the error tree for reporting @@ -385,6 +437,7 @@ impl std::fmt::Display for NoSolutionError { let mut additional_hints = IndexSet::default(); formatter.generate_hints( &tree, + &self.index, &self.selector, &self.index_locations, &self.index_capabilities, @@ -392,6 +445,7 @@ impl std::fmt::Display for NoSolutionError { &self.unavailable_packages, &self.incomplete_packages, &self.fork_urls, + &self.fork_indexes, &self.env, &self.workspace_members, &self.options, diff --git a/crates/uv-resolver/src/pubgrub/report.rs b/crates/uv-resolver/src/pubgrub/report.rs index 9595b1358699..04406a0915c3 100644 --- a/crates/uv-resolver/src/pubgrub/report.rs +++ b/crates/uv-resolver/src/pubgrub/report.rs @@ -1,41 +1,48 @@ -use std::cmp::Ordering; -use std::collections::{BTreeMap, BTreeSet}; -use std::ops::Bound; - use indexmap::IndexSet; +use itertools::Itertools; use owo_colors::OwoColorize; use pubgrub::{DerivationTree, Derived, External, Map, Range, ReportFormatter, Term}; use rustc_hash::FxHashMap; +use std::cmp::Ordering; +use std::collections::{BTreeMap, BTreeSet}; +use std::ops::Bound; +use std::str::FromStr; -use uv_configuration::{IndexStrategy, NoBinary, NoBuild}; -use uv_distribution_types::{ - IncompatibleDist, IncompatibleSource, IncompatibleWheel, Index, IndexCapabilities, - IndexLocations, IndexUrl, -}; -use uv_normalize::PackageName; -use uv_pep440::{Version, VersionSpecifiers}; - +use super::{PubGrubPackage, PubGrubPackageInner, PubGrubPython}; use crate::candidate_selector::CandidateSelector; use crate::error::ErrorTree; +use crate::fork_indexes::ForkIndexes; use crate::fork_urls::ForkUrls; use crate::prerelease::AllowPrerelease; use crate::python_requirement::{PythonRequirement, PythonRequirementSource}; use crate::resolver::{ MetadataUnavailable, UnavailablePackage, UnavailableReason, UnavailableVersion, }; -use crate::{Flexibility, Options, RequiresPython, ResolverEnvironment}; - -use super::{PubGrubPackage, PubGrubPackageInner, PubGrubPython}; +use crate::{ + Flexibility, InMemoryIndex, Options, RequiresPython, ResolverEnvironment, VersionsResponse, +}; +use uv_configuration::{IndexStrategy, NoBinary, NoBuild}; +use uv_distribution_types::{ + IncompatibleDist, IncompatibleSource, IncompatibleWheel, Index, IndexCapabilities, + IndexLocations, IndexUrl, +}; +use uv_normalize::PackageName; +use uv_pep440::{Version, VersionSpecifiers}; +use uv_platform_tags::{AbiTag, IncompatibleTag, LanguageTag, Tags}; #[derive(Debug)] pub(crate) struct PubGrubReportFormatter<'a> { - /// The versions that were available for each package + /// The versions that were available for each package. pub(crate) available_versions: &'a FxHashMap>, - /// The versions that were available for each package + /// The versions that were available for each package. pub(crate) python_requirement: &'a PythonRequirement, + /// The members of the workspace. pub(crate) workspace_members: &'a BTreeSet, + + /// The compatible tags for the resolution. + pub(crate) tags: Option<&'a Tags>, } impl ReportFormatter, UnavailableReason> @@ -111,20 +118,25 @@ impl ReportFormatter, UnavailableReason> } else { match reason { UnavailableReason::Package(reason) => { - format!( - "{}{}", - Padded::new("", &package, " "), - reason.singular_message() - ) + let message = reason.singular_message(); + format!("{}{}", package, Padded::new(" ", &message, ""),) } UnavailableReason::Version(reason) => { let range = self.compatible_range(package, set); - let reason = if range.plural() { + let message = if range.plural() { reason.plural_message() } else { reason.singular_message() }; - format!("{}{reason}", Padded::new("", &range, " ")) + let context = reason.context_message( + self.tags, + self.python_requirement.target().abi_tag(), + ); + if let Some(context) = context { + format!("{}{}{}", range, Padded::new(" ", &message, " "), context) + } else { + format!("{}{}", range, Padded::new(" ", &message, "")) + } } } } @@ -513,6 +525,7 @@ impl PubGrubReportFormatter<'_> { pub(crate) fn generate_hints( &self, derivation_tree: &ErrorTree, + index: &InMemoryIndex, selector: &CandidateSelector, index_locations: &IndexLocations, index_capabilities: &IndexCapabilities, @@ -520,6 +533,7 @@ impl PubGrubReportFormatter<'_> { unavailable_packages: &FxHashMap, incomplete_packages: &FxHashMap>, fork_urls: &ForkUrls, + fork_indexes: &ForkIndexes, env: &ResolverEnvironment, workspace_members: &BTreeSet, options: &Options, @@ -555,27 +569,42 @@ impl PubGrubReportFormatter<'_> { incomplete_packages, output_hints, ); - } - // Check for unavailable versions due to `--no-build` or `--no-binary`. - if let UnavailableReason::Version(UnavailableVersion::IncompatibleDist( - incompatibility, - )) = reason - { - match incompatibility { - IncompatibleDist::Wheel(IncompatibleWheel::NoBinary) => { - output_hints.insert(PubGrubHint::NoBinary { - package: package.clone(), - option: options.build_options.no_binary().clone(), - }); - } - IncompatibleDist::Source(IncompatibleSource::NoBuild) => { - output_hints.insert(PubGrubHint::NoBuild { - package: package.clone(), - option: options.build_options.no_build().clone(), - }); + if let UnavailableReason::Version(UnavailableVersion::IncompatibleDist( + incompatibility, + )) = reason + { + match incompatibility { + // Check for unavailable versions due to `--no-build` or `--no-binary`. + IncompatibleDist::Wheel(IncompatibleWheel::NoBinary) => { + output_hints.insert(PubGrubHint::NoBinary { + package: package.clone(), + option: options.build_options.no_binary().clone(), + }); + } + IncompatibleDist::Source(IncompatibleSource::NoBuild) => { + output_hints.insert(PubGrubHint::NoBuild { + package: package.clone(), + option: options.build_options.no_build().clone(), + }); + } + // Check for unavailable versions due to incompatible tags. + IncompatibleDist::Wheel(IncompatibleWheel::Tag(tag)) => { + if let Some(hint) = self.tag_hint( + package, + name, + set, + *tag, + index, + selector, + fork_indexes, + env, + ) { + output_hints.insert(hint); + } + } + _ => {} } - _ => {} } } } @@ -661,6 +690,7 @@ impl PubGrubReportFormatter<'_> { DerivationTree::Derived(derived) => { self.generate_hints( &derived.cause1, + index, selector, index_locations, index_capabilities, @@ -668,6 +698,7 @@ impl PubGrubReportFormatter<'_> { unavailable_packages, incomplete_packages, fork_urls, + fork_indexes, env, workspace_members, options, @@ -675,6 +706,7 @@ impl PubGrubReportFormatter<'_> { ); self.generate_hints( &derived.cause2, + index, selector, index_locations, index_capabilities, @@ -682,6 +714,7 @@ impl PubGrubReportFormatter<'_> { unavailable_packages, incomplete_packages, fork_urls, + fork_indexes, env, workspace_members, options, @@ -691,6 +724,106 @@ impl PubGrubReportFormatter<'_> { }; } + /// Generate a [`PubGrubHint`] for a package that doesn't have any wheels matching the current + /// Python version, ABI, or platform. + fn tag_hint( + &self, + package: &PubGrubPackage, + name: &PackageName, + set: &Range, + tag: IncompatibleTag, + index: &InMemoryIndex, + selector: &CandidateSelector, + fork_indexes: &ForkIndexes, + env: &ResolverEnvironment, + ) -> Option { + let response = if let Some(url) = fork_indexes.get(name) { + index.explicit().get(&(name.clone(), url.clone())) + } else { + index.implicit().get(name) + }?; + + let VersionsResponse::Found(version_maps) = &*response else { + return None; + }; + + let candidate = selector.select_no_preference(name, set, version_maps, env)?; + + let prioritized = candidate.prioritized()?; + + match tag { + IncompatibleTag::Invalid => None, + IncompatibleTag::Python => { + // Return all available language tags. + let tags = prioritized + .python_tags() + .into_iter() + .filter_map(|tag| LanguageTag::from_str(tag).ok()) + .collect::>(); + if tags.is_empty() { + None + } else { + Some(PubGrubHint::LanguageTags { + package: package.clone(), + version: candidate.version().clone(), + tags, + }) + } + } + IncompatibleTag::Abi | IncompatibleTag::AbiPythonVersion => { + let tags = prioritized + .abi_tags() + .into_iter() + .filter_map(|tag| AbiTag::from_str(tag).ok()) + // Ignore `none`, which is universally compatible. + // + // As an example, `none` can appear here if we're solving for Python 3.13, and + // the distribution includes a wheel for `cp312-none-macosx_11_0_arm64`. + // + // In that case, the wheel isn't compatible, but when solving for Python 3.13, + // the `cp312` Python tag _can_ be compatible (e.g., for `cp312-abi3-macosx_11_0_arm64.whl`), + // so this is considered an ABI incompatibility rather than Python incompatibility. + .filter(|tag| *tag != AbiTag::None) + .collect::>(); + if tags.is_empty() { + None + } else { + Some(PubGrubHint::AbiTags { + package: package.clone(), + version: candidate.version().clone(), + tags, + }) + } + } + IncompatibleTag::Platform => { + // We don't want to report all available platforms, since it's plausible that there + // are wheels for the current platform, but at a different ABI. For example, when + // solving for Python 3.13 on macOS, `cp312-cp312-macosx_11_0_arm64` could be + // available along with `cp313-cp313-manylinux2014`. In this case, we'd consider + // the distribution to be platform-incompatible, since `cp313-cp313` matches the + // compatible wheel tags. But showing `macosx_11_0_arm64` here would be misleading. + // + // So, instead, we only show the platforms that are linked to otherwise-compatible + // wheels (e.g., `manylinux2014` in `cp313-cp313-manylinux2014`). In other words, + // we only show platforms for ABI-compatible wheels. + let tags = prioritized + .platform_tags(self.tags?) + .into_iter() + .map(ToString::to_string) + .collect::>(); + if tags.is_empty() { + None + } else { + Some(PubGrubHint::PlatformTags { + package: package.clone(), + version: candidate.version().clone(), + tags, + }) + } + } + } + } + fn index_hints( package: &PubGrubPackage, name: &PackageName, @@ -991,6 +1124,30 @@ pub(crate) enum PubGrubHint { UnauthorizedIndex { index: IndexUrl }, /// An index returned a Forbidden (403) response. ForbiddenIndex { index: IndexUrl }, + /// No wheels are available for a package, and using source distributions was disabled. + LanguageTags { + package: PubGrubPackage, + // excluded from `PartialEq` and `Hash` + version: Version, + // excluded from `PartialEq` and `Hash` + tags: BTreeSet, + }, + /// No wheels are available for a package, and using source distributions was disabled. + AbiTags { + package: PubGrubPackage, + // excluded from `PartialEq` and `Hash` + version: Version, + // excluded from `PartialEq` and `Hash` + tags: BTreeSet, + }, + /// No wheels are available for a package, and using source distributions was disabled. + PlatformTags { + package: PubGrubPackage, + // excluded from `PartialEq` and `Hash` + version: Version, + // excluded from `PartialEq` and `Hash` + tags: Vec, + }, } /// This private enum mirrors [`PubGrubHint`] but only includes fields that should be @@ -1052,6 +1209,15 @@ enum PubGrubHintCore { NoBinary { package: PubGrubPackage, }, + LanguageTags { + package: PubGrubPackage, + }, + AbiTags { + package: PubGrubPackage, + }, + PlatformTags { + package: PubGrubPackage, + }, } impl From for PubGrubHintCore { @@ -1109,6 +1275,9 @@ impl From for PubGrubHintCore { PubGrubHint::ForbiddenIndex { index } => Self::ForbiddenIndex { index }, PubGrubHint::NoBuild { package, .. } => Self::NoBuild { package }, PubGrubHint::NoBinary { package, .. } => Self::NoBinary { package }, + PubGrubHint::LanguageTags { package, .. } => Self::LanguageTags { package }, + PubGrubHint::AbiTags { package, .. } => Self::AbiTags { package }, + PubGrubHint::PlatformTags { package, .. } => Self::PlatformTags { package }, } } } @@ -1415,6 +1584,60 @@ impl std::fmt::Display for PubGrubHint { package.cyan(), ) } + Self::LanguageTags { + package, + version, + tags, + } => { + let s = if tags.len() == 1 { "" } else { "s" }; + write!( + f, + "{}{} Wheels are available for `{}` ({}) with the following Python tag{s}: {}", + "hint".bold().cyan(), + ":".bold(), + package.cyan(), + format!("v{version}").cyan(), + tags.iter() + .map(|tag| format!("`{}`", tag.cyan())) + .join(", "), + ) + } + Self::AbiTags { + package, + version, + tags, + } => { + let s = if tags.len() == 1 { "" } else { "s" }; + write!( + f, + "{}{} Wheels are available for `{}` ({}) with the following ABI tag{s}: {}", + "hint".bold().cyan(), + ":".bold(), + package.cyan(), + format!("v{version}").cyan(), + tags.iter() + .map(|tag| format!("`{}`", tag.cyan())) + .join(", "), + ) + } + Self::PlatformTags { + package, + version, + tags, + } => { + let s = if tags.len() == 1 { "" } else { "s" }; + write!( + f, + "{}{} Wheels are available for `{}` ({}) on the following platform{s}: {}", + "hint".bold().cyan(), + ":".bold(), + package.cyan(), + format!("v{version}").cyan(), + tags.iter() + .map(|tag| format!("`{}`", tag.cyan())) + .join(", "), + ) + } } } } diff --git a/crates/uv-resolver/src/requires_python.rs b/crates/uv-resolver/src/requires_python.rs index 9fbaa36af390..fb9710d0cc9b 100644 --- a/crates/uv-resolver/src/requires_python.rs +++ b/crates/uv-resolver/src/requires_python.rs @@ -7,6 +7,7 @@ use pubgrub::Range; use uv_distribution_filename::WheelFilename; use uv_pep440::{release_specifiers_to_ranges, Version, VersionSpecifier, VersionSpecifiers}; use uv_pep508::{MarkerExpression, MarkerTree, MarkerValueVersion}; +use uv_platform_tags::AbiTag; /// The `Requires-Python` requirement specifier. /// @@ -303,6 +304,23 @@ impl RequiresPython { &self.range } + /// Returns a wheel tag that's compatible with the `Requires-Python` specifier. + pub fn abi_tag(&self) -> Option { + match self.range.lower().as_ref() { + Bound::Included(version) | Bound::Excluded(version) => { + let major = version.release().first().copied()?; + let major = u8::try_from(major).ok()?; + let minor = version.release().get(1).copied()?; + let minor = u8::try_from(minor).ok()?; + Some(AbiTag::CPython { + gil_disabled: false, + python_version: (major, minor), + }) + } + Bound::Unbounded => None, + } + } + /// Simplifies the given markers in such a way as to assume that /// the Python version is constrained by this Python version bound. /// diff --git a/crates/uv-resolver/src/resolver/availability.rs b/crates/uv-resolver/src/resolver/availability.rs index cf8a42b60a52..d2e9296b95d6 100644 --- a/crates/uv-resolver/src/resolver/availability.rs +++ b/crates/uv-resolver/src/resolver/availability.rs @@ -1,9 +1,9 @@ use std::fmt::{Display, Formatter}; +use crate::resolver::{MetadataUnavailable, VersionFork}; use uv_distribution_types::IncompatibleDist; use uv_pep440::{Version, VersionSpecifiers}; - -use crate::resolver::{MetadataUnavailable, VersionFork}; +use uv_platform_tags::{AbiTag, Tags}; /// The reason why a package or a version cannot be used. #[derive(Debug, Clone, Eq, PartialEq)] @@ -80,6 +80,23 @@ impl UnavailableVersion { UnavailableVersion::RequiresPython(..) => format!("require {self}"), } } + + pub(crate) fn context_message( + &self, + tags: Option<&Tags>, + requires_python: Option, + ) -> Option { + match self { + UnavailableVersion::IncompatibleDist(invalid_dist) => { + invalid_dist.context_message(tags, requires_python) + } + UnavailableVersion::InvalidMetadata => None, + UnavailableVersion::InconsistentMetadata => None, + UnavailableVersion::InvalidStructure => None, + UnavailableVersion::Offline => None, + UnavailableVersion::RequiresPython(..) => None, + } + } } impl Display for UnavailableVersion { diff --git a/crates/uv-resolver/src/resolver/mod.rs b/crates/uv-resolver/src/resolver/mod.rs index ced06cf26c9e..6fe985ea8f00 100644 --- a/crates/uv-resolver/src/resolver/mod.rs +++ b/crates/uv-resolver/src/resolver/mod.rs @@ -113,6 +113,7 @@ struct ResolverState { dependency_mode: DependencyMode, hasher: HashStrategy, env: ResolverEnvironment, + tags: Option, python_requirement: PythonRequirement, conflicts: Conflicts, workspace_members: BTreeSet, @@ -181,6 +182,7 @@ impl<'a, Context: BuildContext, InstalledPackages: InstalledPackagesProvider> options, hasher, env, + tags.cloned(), python_requirement, conflicts, index, @@ -202,6 +204,7 @@ impl options: Options, hasher: &HashStrategy, env: ResolverEnvironment, + tags: Option, python_requirement: &PythonRequirement, conflicts: Conflicts, index: &InMemoryIndex, @@ -229,6 +232,7 @@ impl hasher: hasher.clone(), locations: locations.clone(), env, + tags, python_requirement: python_requirement.clone(), conflicts, installed_packages, @@ -346,11 +350,9 @@ impl ResolverState { @@ -1219,6 +1221,8 @@ impl ResolverState ResolverState, fork_urls: ForkUrls, - fork_indexes: &ForkIndexes, + fork_indexes: ForkIndexes, env: ResolverEnvironment, visited: &FxHashSet, - index_locations: &IndexLocations, - index_capabilities: &IndexCapabilities, ) -> ResolveError { err = NoSolutionError::collapse_local_version_segments(NoSolutionError::collapse_proxies( err, @@ -2413,16 +2415,19 @@ impl ResolverState Result<()> { ----- stderr ----- × No solution found when resolving dependencies: - ╰─▶ Because dearpygui==1.9.1 has no wheels with a matching Python version tag and your project depends on dearpygui==1.9.1, we can conclude that your project's requirements are unsatisfiable. + ╰─▶ Because dearpygui==1.9.1 has no wheels with a matching Python version tag (e.g., `cp312`) and your project depends on dearpygui==1.9.1, we can conclude that your project's requirements are unsatisfiable. + + hint: Wheels are available for `dearpygui` (v1.9.1) with the following ABI tags: `cp37m`, `cp38`, `cp39`, `cp310`, `cp311` "###); Ok(()) diff --git a/crates/uv/tests/it/pip_compile.rs b/crates/uv/tests/it/pip_compile.rs index 7adf4a635f0e..1bb1c58b8487 100644 --- a/crates/uv/tests/it/pip_compile.rs +++ b/crates/uv/tests/it/pip_compile.rs @@ -13944,8 +13944,12 @@ fn invalid_platform() -> Result<()> { ----- stderr ----- × No solution found when resolving dependencies: - ╰─▶ Because only open3d<=0.18.0 is available and open3d<=0.15.2 has no wheels with a matching Python ABI tag, we can conclude that open3d<=0.15.2 cannot be used. - And because open3d>=0.16.0,<=0.18.0 has no wheels with a matching platform tag and you require open3d, we can conclude that your requirements are unsatisfiable. + ╰─▶ Because only open3d<=0.18.0 is available and open3d<=0.15.2 has no wheels with a matching Python ABI tag (e.g., `cp310`), we can conclude that open3d<=0.15.2 cannot be used. + And because open3d>=0.16.0,<=0.18.0 has no wheels with a matching platform tag (e.g., `manylinux_2_17_x86_64`) and you require open3d, we can conclude that your requirements are unsatisfiable. + + hint: Wheels are available for `open3d` (v0.15.2) with the following ABI tags: `cp36m`, `cp37m`, `cp38`, `cp39` + + hint: Wheels are available for `open3d` (v0.18.0) on the following platforms: `macosx_11_0_x86_64`, `macosx_13_0_arm64`, `manylinux_2_27_aarch64`, `manylinux_2_27_x86_64`, `win_amd64` "###); Ok(()) diff --git a/crates/uv/tests/it/pip_install_scenarios.rs b/crates/uv/tests/it/pip_install_scenarios.rs index aa16c7941d85..f1f13ce8245e 100644 --- a/crates/uv/tests/it/pip_install_scenarios.rs +++ b/crates/uv/tests/it/pip_install_scenarios.rs @@ -4088,7 +4088,7 @@ fn no_sdist_no_wheels_with_matching_abi() { ----- stderr ----- × No solution found when resolving dependencies: - ╰─▶ Because only package-a==1.0.0 is available and package-a==1.0.0 has no wheels with a matching Python ABI tag, we can conclude that all versions of package-a cannot be used. + ╰─▶ Because only package-a==1.0.0 is available and package-a==1.0.0 has no wheels with a matching Python ABI tag (e.g., `cp38`), we can conclude that all versions of package-a cannot be used. And because you require package-a, we can conclude that your requirements are unsatisfiable. "###); @@ -4129,8 +4129,10 @@ fn no_sdist_no_wheels_with_matching_platform() { ----- stderr ----- × No solution found when resolving dependencies: - ╰─▶ Because only package-a==1.0.0 is available and package-a==1.0.0 has no wheels with a matching platform tag, we can conclude that all versions of package-a cannot be used. + ╰─▶ Because only package-a==1.0.0 is available and package-a==1.0.0 has no wheels with a matching platform tag (e.g., `manylinux_2_17_x86_64`), we can conclude that all versions of package-a cannot be used. And because you require package-a, we can conclude that your requirements are unsatisfiable. + + hint: Wheels are available for `package-a` (v1.0.0) on the following platform: `macosx_10_0_ppc64` "###); assert_not_installed( @@ -4170,8 +4172,10 @@ fn no_sdist_no_wheels_with_matching_python() { ----- stderr ----- × No solution found when resolving dependencies: - ╰─▶ Because only package-a==1.0.0 is available and package-a==1.0.0 has no wheels with a matching Python implementation tag, we can conclude that all versions of package-a cannot be used. + ╰─▶ Because only package-a==1.0.0 is available and package-a==1.0.0 has no wheels with a matching Python implementation tag (e.g., `cp38`), we can conclude that all versions of package-a cannot be used. And because you require package-a, we can conclude that your requirements are unsatisfiable. + + hint: Wheels are available for `package-a` (v1.0.0) with the following Python tag: `graalpy310` "###); assert_not_installed(