From dc5b3762f38a8e47b53bec9cc3cefb71e4aef55c Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Sun, 27 Apr 2025 12:56:50 -0400 Subject: [PATCH] Show tag hints when failing to find a compatible wheel in `pylock.toml` (#13136) ## Summary Closes #13135. --- crates/uv-requirements/src/upgrade.rs | 4 +- crates/uv-resolver/src/lib.rs | 5 +- crates/uv-resolver/src/lock/export/mod.rs | 2 +- .../src/lock/export/pylock_toml.rs | 223 +++++++++++----- crates/uv-resolver/src/lock/mod.rs | 242 ++++++++++-------- crates/uv/tests/it/pip_sync.rs | 2 + 6 files changed, 294 insertions(+), 184 deletions(-) diff --git a/crates/uv-requirements/src/upgrade.rs b/crates/uv-requirements/src/upgrade.rs index 01273ef59..c3fb6ff1e 100644 --- a/crates/uv-requirements/src/upgrade.rs +++ b/crates/uv-requirements/src/upgrade.rs @@ -7,7 +7,7 @@ use uv_configuration::Upgrade; use uv_fs::CWD; use uv_git::ResolvedRepositoryReference; use uv_requirements_txt::RequirementsTxt; -use uv_resolver::{Lock, LockError, Preference, PreferenceError, PylockToml, PylockTomlError}; +use uv_resolver::{Lock, LockError, Preference, PreferenceError, PylockToml, PylockTomlErrorKind}; #[derive(Debug, Default)] pub struct LockedRequirements { @@ -105,7 +105,7 @@ pub fn read_lock_requirements( pub async fn read_pylock_toml_requirements( output_file: &Path, upgrade: &Upgrade, -) -> Result { +) -> Result { // As an optimization, skip iterating over the lockfile is we're upgrading all packages anyway. if upgrade.is_all() { return Ok(LockedRequirements::default()); diff --git a/crates/uv-resolver/src/lib.rs b/crates/uv-resolver/src/lib.rs index ac1519545..3285f9a6a 100644 --- a/crates/uv-resolver/src/lib.rs +++ b/crates/uv-resolver/src/lib.rs @@ -5,8 +5,9 @@ pub use exclusions::Exclusions; pub use flat_index::{FlatDistributions, FlatIndex}; pub use fork_strategy::ForkStrategy; pub use lock::{ - Installable, Lock, LockError, LockVersion, Package, PackageMap, PylockToml, PylockTomlError, - RequirementsTxtExport, ResolverManifest, SatisfiesResult, TreeDisplay, VERSION, + Installable, Lock, LockError, LockVersion, Package, PackageMap, PylockToml, + PylockTomlErrorKind, RequirementsTxtExport, ResolverManifest, SatisfiesResult, TreeDisplay, + VERSION, }; pub use manifest::Manifest; pub use options::{Flexibility, Options, OptionsBuilder}; diff --git a/crates/uv-resolver/src/lock/export/mod.rs b/crates/uv-resolver/src/lock/export/mod.rs index e9af87736..bbf07cd7d 100644 --- a/crates/uv-resolver/src/lock/export/mod.rs +++ b/crates/uv-resolver/src/lock/export/mod.rs @@ -15,7 +15,7 @@ use uv_pypi_types::ConflictItem; use crate::graph_ops::{marker_reachability, Reachable}; pub(crate) use crate::lock::export::pylock_toml::PylockTomlPackage; -pub use crate::lock::export::pylock_toml::{PylockToml, PylockTomlError}; +pub use crate::lock::export::pylock_toml::{PylockToml, PylockTomlErrorKind}; pub use crate::lock::export::requirements_txt::RequirementsTxtExport; use crate::universal_marker::resolve_conflicts; use crate::{Installable, Package}; diff --git a/crates/uv-resolver/src/lock/export/pylock_toml.rs b/crates/uv-resolver/src/lock/export/pylock_toml.rs index 9a006585a..a9180e005 100644 --- a/crates/uv-resolver/src/lock/export/pylock_toml.rs +++ b/crates/uv-resolver/src/lock/export/pylock_toml.rs @@ -36,12 +36,12 @@ use uv_pypi_types::{HashDigests, Hashes, ParsedGitUrl, VcsKind}; use uv_small_str::SmallString; use crate::lock::export::ExportableRequirements; -use crate::lock::{each_element_on_its_line_array, Source}; +use crate::lock::{each_element_on_its_line_array, Source, WheelTagHint}; use crate::resolution::ResolutionGraphNode; use crate::{Installable, LockError, RequiresPython, ResolverOutput}; #[derive(Debug, thiserror::Error)] -pub enum PylockTomlError { +pub enum PylockTomlErrorKind { #[error("Package `{0}` includes both a registry (`packages.wheels`) and a directory source (`packages.directory`)")] WheelWithDirectory(PackageName), #[error("Package `{0}` includes both a registry (`packages.wheels`) and a VCS source (`packages.vcs`)")] @@ -114,6 +114,40 @@ pub enum PylockTomlError { Deserialize(#[from] toml::de::Error), } +#[derive(Debug)] +pub struct PylockTomlError { + kind: Box, + hint: Option, +} + +impl std::error::Error for PylockTomlError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + self.kind.source() + } +} + +impl std::fmt::Display for PylockTomlError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.kind)?; + if let Some(hint) = &self.hint { + write!(f, "\n\n{hint}")?; + } + Ok(()) + } +} + +impl From for PylockTomlError +where + PylockTomlErrorKind: From, +{ + fn from(err: E) -> Self { + PylockTomlError { + kind: Box::new(PylockTomlErrorKind::from(err)), + hint: None, + } + } +} + #[derive(Debug, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "kebab-case")] pub struct PylockToml { @@ -267,7 +301,7 @@ impl<'lock> PylockToml { resolution: &ResolverOutput, omit: &[PackageName], install_path: &Path, - ) -> Result { + ) -> Result { // The lock version is always `1.0` at time of writing. let lock_version = Version::new([1, 0]); @@ -354,8 +388,11 @@ impl<'lock> PylockToml { dist.wheels .iter() .map(|wheel| { - let url = - wheel.file.url.to_url().map_err(PylockTomlError::ToUrl)?; + let url = wheel + .file + .url + .to_url() + .map_err(PylockTomlErrorKind::ToUrl)?; Ok(PylockTomlWheel { // Optional "when the last component of path/ url would be the same value". name: if url @@ -372,18 +409,26 @@ impl<'lock> PylockToml { .map(Timestamp::from_millisecond) .transpose()?, url: Some( - wheel.file.url.to_url().map_err(PylockTomlError::ToUrl)?, + wheel + .file + .url + .to_url() + .map_err(PylockTomlErrorKind::ToUrl)?, ), path: None, size: wheel.file.size, hashes: Hashes::from(wheel.file.hashes.clone()), }) }) - .collect::, PylockTomlError>>()?, + .collect::, PylockTomlErrorKind>>()?, ); if let Some(sdist) = dist.sdist.as_ref() { - let url = sdist.file.url.to_url().map_err(PylockTomlError::ToUrl)?; + let url = sdist + .file + .url + .to_url() + .map_err(PylockTomlErrorKind::ToUrl)?; package.sdist = Some(PylockTomlSdist { // Optional "when the last component of path/ url would be the same value". name: if url @@ -456,8 +501,11 @@ impl<'lock> PylockToml { dist.wheels .iter() .map(|wheel| { - let url = - wheel.file.url.to_url().map_err(PylockTomlError::ToUrl)?; + let url = wheel + .file + .url + .to_url() + .map_err(PylockTomlErrorKind::ToUrl)?; Ok(PylockTomlWheel { // Optional "when the last component of path/ url would be the same value". name: if url @@ -474,17 +522,21 @@ impl<'lock> PylockToml { .map(Timestamp::from_millisecond) .transpose()?, url: Some( - wheel.file.url.to_url().map_err(PylockTomlError::ToUrl)?, + wheel + .file + .url + .to_url() + .map_err(PylockTomlErrorKind::ToUrl)?, ), path: None, size: wheel.file.size, hashes: Hashes::from(wheel.file.hashes.clone()), }) }) - .collect::, PylockTomlError>>()?, + .collect::, PylockTomlErrorKind>>()?, ); - let url = dist.file.url.to_url().map_err(PylockTomlError::ToUrl)?; + let url = dist.file.url.to_url().map_err(PylockTomlErrorKind::ToUrl)?; package.sdist = Some(PylockTomlSdist { // Optional "when the last component of path/ url would be the same value". name: if url @@ -536,7 +588,7 @@ impl<'lock> PylockToml { dev: &DependencyGroupsWithDefaults, annotate: bool, install_options: &'lock InstallOptions, - ) -> Result { + ) -> Result { // Extract the packages from the lock file. let ExportableRequirements(mut nodes) = ExportableRequirements::from_lock( target, @@ -591,8 +643,11 @@ impl<'lock> PylockToml { wheels .into_iter() .map(|wheel| { - let url = - wheel.file.url.to_url().map_err(PylockTomlError::ToUrl)?; + let url = wheel + .file + .url + .to_url() + .map_err(PylockTomlErrorKind::ToUrl)?; Ok(PylockTomlWheel { // Optional "when the last component of path/ url would be the same value". name: if url @@ -614,7 +669,7 @@ impl<'lock> PylockToml { hashes: Hashes::from(wheel.file.hashes), }) }) - .collect::, PylockTomlError>>()?, + .collect::, PylockTomlErrorKind>>()?, ) } Source::Path(..) => None, @@ -728,7 +783,11 @@ impl<'lock> PylockToml { // Extract the `packages.sdist` field. let sdist = match &sdist { Some(SourceDist::Registry(sdist)) => { - let url = sdist.file.url.to_url().map_err(PylockTomlError::ToUrl)?; + let url = sdist + .file + .url + .to_url() + .map_err(PylockTomlErrorKind::ToUrl)?; Some(PylockTomlSdist { // Optional "when the last component of path/ url would be the same value". name: if url @@ -894,37 +953,43 @@ impl<'lock> PylockToml { ) { // `packages.wheels` is mutually exclusive with `packages.directory`, `packages.vcs`, and `packages.archive`. (true, _, true, _, _) => { - return Err(PylockTomlError::WheelWithDirectory(package.name.clone())); + return Err( + PylockTomlErrorKind::WheelWithDirectory(package.name.clone()).into(), + ); } (true, _, _, true, _) => { - return Err(PylockTomlError::WheelWithVcs(package.name.clone())); + return Err(PylockTomlErrorKind::WheelWithVcs(package.name.clone()).into()); } (true, _, _, _, true) => { - return Err(PylockTomlError::WheelWithArchive(package.name.clone())); + return Err(PylockTomlErrorKind::WheelWithArchive(package.name.clone()).into()); } // `packages.sdist` is mutually exclusive with `packages.directory`, `packages.vcs`, and `packages.archive`. (_, true, true, _, _) => { - return Err(PylockTomlError::SdistWithDirectory(package.name.clone())); + return Err( + PylockTomlErrorKind::SdistWithDirectory(package.name.clone()).into(), + ); } (_, true, _, true, _) => { - return Err(PylockTomlError::SdistWithVcs(package.name.clone())); + return Err(PylockTomlErrorKind::SdistWithVcs(package.name.clone()).into()); } (_, true, _, _, true) => { - return Err(PylockTomlError::SdistWithArchive(package.name.clone())); + return Err(PylockTomlErrorKind::SdistWithArchive(package.name.clone()).into()); } // `packages.directory` is mutually exclusive with `packages.vcs`, and `packages.archive`. (_, _, true, true, _) => { - return Err(PylockTomlError::DirectoryWithVcs(package.name.clone())); + return Err(PylockTomlErrorKind::DirectoryWithVcs(package.name.clone()).into()); } (_, _, true, _, true) => { - return Err(PylockTomlError::DirectoryWithArchive(package.name.clone())); + return Err( + PylockTomlErrorKind::DirectoryWithArchive(package.name.clone()).into(), + ); } // `packages.vcs` is mutually exclusive with `packages.archive`. (_, _, _, true, true) => { - return Err(PylockTomlError::VcsWithArchive(package.name.clone())); + return Err(PylockTomlErrorKind::VcsWithArchive(package.name.clone()).into()); } (false, false, false, false, false) => { - return Err(PylockTomlError::MissingSource(package.name.clone())); + return Err(PylockTomlErrorKind::MissingSource(package.name.clone()).into()); } _ => {} } @@ -1025,18 +1090,28 @@ impl<'lock> PylockToml { } } else { return match (no_binary, no_build) { - (true, true) => Err(PylockTomlError::NoBinaryNoBuild(package.name.clone())), + (true, true) => { + Err(PylockTomlErrorKind::NoBinaryNoBuild(package.name.clone()).into()) + } (true, false) if is_wheel => { - Err(PylockTomlError::NoBinaryWheelOnly(package.name.clone())) + Err(PylockTomlErrorKind::NoBinaryWheelOnly(package.name.clone()).into()) } - (true, false) => Err(PylockTomlError::NoBinary(package.name.clone())), - (false, true) => Err(PylockTomlError::NoBuild(package.name.clone())), - (false, false) if is_wheel => { - Err(PylockTomlError::IncompatibleWheelOnly(package.name.clone())) + (true, false) => { + Err(PylockTomlErrorKind::NoBinary(package.name.clone()).into()) } - (false, false) => Err(PylockTomlError::NeitherSourceDistNorWheel( - package.name.clone(), - )), + (false, true) => Err(PylockTomlErrorKind::NoBuild(package.name.clone()).into()), + (false, false) if is_wheel => Err(PylockTomlError { + kind: Box::new(PylockTomlErrorKind::IncompatibleWheelOnly( + package.name.clone(), + )), + hint: package.tag_hint(tags), + }), + (false, false) => Err(PylockTomlError { + kind: Box::new(PylockTomlErrorKind::NeitherSourceDistNorWheel( + package.name.clone(), + )), + hint: package.tag_hint(tags), + }), }; }; @@ -1163,6 +1238,18 @@ impl PylockTomlPackage { best.map(|(_, i)| i) } + /// Generate a [`WheelTagHint`] based on wheel-tag incompatibilities. + fn tag_hint(&self, tags: &Tags) -> Option { + let filenames = self + .wheels + .iter() + .flatten() + .filter_map(|wheel| wheel.filename(&self.name).ok()) + .collect::>(); + let filenames = filenames.iter().map(Cow::as_ref).collect::>(); + WheelTagHint::from_wheels(&self.name, self.version.as_ref(), &filenames, tags) + } + /// Returns the [`ResolvedRepositoryReference`] for the package, if it is a Git source. pub fn as_git_ref(&self) -> Option { let vcs = self.vcs.as_ref()?; @@ -1180,12 +1267,12 @@ impl PylockTomlPackage { impl PylockTomlWheel { /// Return the [`WheelFilename`] for this wheel. - fn filename(&self, name: &PackageName) -> Result, PylockTomlError> { + fn filename(&self, name: &PackageName) -> Result, PylockTomlErrorKind> { if let Some(name) = self.name.as_ref() { Ok(Cow::Borrowed(name)) } else if let Some(path) = self.path.as_ref() { let Some(filename) = path.as_ref().file_name().and_then(OsStr::to_str) else { - return Err(PylockTomlError::PathMissingFilename(Box::::from( + return Err(PylockTomlErrorKind::PathMissingFilename(Box::::from( path.clone(), ))); }; @@ -1193,12 +1280,12 @@ impl PylockTomlWheel { Ok(filename) } else if let Some(url) = self.url.as_ref() { let Some(filename) = url.filename().ok() else { - return Err(PylockTomlError::UrlMissingFilename(url.clone())); + return Err(PylockTomlErrorKind::UrlMissingFilename(url.clone())); }; let filename = WheelFilename::from_str(&filename).map(Cow::Owned)?; Ok(filename) } else { - Err(PylockTomlError::WheelMissingPathUrl(name.clone())) + Err(PylockTomlErrorKind::WheelMissingPathUrl(name.clone())) } } @@ -1208,17 +1295,17 @@ impl PylockTomlWheel { install_path: &Path, name: &PackageName, index: Option<&Url>, - ) -> Result { + ) -> Result { let filename = self.filename(name)?.into_owned(); let file_url = if let Some(url) = self.url.as_ref() { UrlString::from(url) } else if let Some(path) = self.path.as_ref() { let path = install_path.join(path); - let url = Url::from_file_path(path).map_err(|()| PylockTomlError::PathToUrl)?; + let url = Url::from_file_path(path).map_err(|()| PylockTomlErrorKind::PathToUrl)?; UrlString::from(url) } else { - return Err(PylockTomlError::WheelMissingPathUrl(name.clone())); + return Err(PylockTomlErrorKind::WheelMissingPathUrl(name.clone())); }; let index = if let Some(index) = index { @@ -1228,7 +1315,7 @@ impl PylockTomlWheel { // URL (less the filename) as the index. This isn't correct, but it's the best we can // do. In practice, the only effect here should be that we cache the wheel under a hash // of this URL (since we cache under the hash of the index). - let mut index = file_url.to_url().map_err(PylockTomlError::ToUrl)?; + let mut index = file_url.to_url().map_err(PylockTomlErrorKind::ToUrl)?; index.path_segments_mut().unwrap().pop(); IndexUrl::from(VerbatimUrl::from_url(index)) }; @@ -1258,7 +1345,7 @@ impl PylockTomlDirectory { &self, install_path: &Path, name: &PackageName, - ) -> Result { + ) -> Result { let path = if let Some(subdirectory) = self.subdirectory.as_ref() { install_path.join(&self.path).join(subdirectory) } else { @@ -1266,7 +1353,7 @@ impl PylockTomlDirectory { }; let path = uv_fs::normalize_path_buf(path); let url = - VerbatimUrl::from_normalized_path(&path).map_err(|_| PylockTomlError::PathToUrl)?; + VerbatimUrl::from_normalized_path(&path).map_err(|_| PylockTomlErrorKind::PathToUrl)?; Ok(DirectorySourceDist { name: name.clone(), install_path: path.into_boxed_path(), @@ -1283,7 +1370,7 @@ impl PylockTomlVcs { &self, install_path: &Path, name: &PackageName, - ) -> Result { + ) -> Result { let subdirectory = self.subdirectory.clone().map(Box::::from); // Reconstruct the `GitUrl` from the individual fields. @@ -1292,9 +1379,9 @@ impl PylockTomlVcs { url.clone() } else if let Some(path) = self.path.as_ref() { Url::from_directory_path(install_path.join(path)) - .map_err(|()| PylockTomlError::PathToUrl)? + .map_err(|()| PylockTomlErrorKind::PathToUrl)? } else { - return Err(PylockTomlError::VcsMissingPathUrl(name.clone())); + return Err(PylockTomlErrorKind::VcsMissingPathUrl(name.clone())); }; url.set_fragment(None); url.set_query(None); @@ -1326,23 +1413,23 @@ impl PylockTomlVcs { impl PylockTomlSdist { /// Return the filename for this sdist. - fn filename(&self, name: &PackageName) -> Result, PylockTomlError> { + fn filename(&self, name: &PackageName) -> Result, PylockTomlErrorKind> { if let Some(name) = self.name.as_ref() { Ok(Cow::Borrowed(name)) } else if let Some(path) = self.path.as_ref() { let Some(filename) = path.as_ref().file_name().and_then(OsStr::to_str) else { - return Err(PylockTomlError::PathMissingFilename(Box::::from( + return Err(PylockTomlErrorKind::PathMissingFilename(Box::::from( path.clone(), ))); }; Ok(Cow::Owned(SmallString::from(filename))) } else if let Some(url) = self.url.as_ref() { let Some(filename) = url.filename().ok() else { - return Err(PylockTomlError::UrlMissingFilename(url.clone())); + return Err(PylockTomlErrorKind::UrlMissingFilename(url.clone())); }; Ok(Cow::Owned(SmallString::from(filename))) } else { - Err(PylockTomlError::SdistMissingPathUrl(name.clone())) + Err(PylockTomlErrorKind::SdistMissingPathUrl(name.clone())) } } @@ -1353,7 +1440,7 @@ impl PylockTomlSdist { name: &PackageName, version: Option<&Version>, index: Option<&Url>, - ) -> Result { + ) -> Result { let filename = self.filename(name)?.into_owned(); let ext = SourceDistExtension::from_path(filename.as_ref())?; @@ -1368,10 +1455,10 @@ impl PylockTomlSdist { UrlString::from(url) } else if let Some(path) = self.path.as_ref() { let path = install_path.join(path); - let url = Url::from_file_path(path).map_err(|()| PylockTomlError::PathToUrl)?; + let url = Url::from_file_path(path).map_err(|()| PylockTomlErrorKind::PathToUrl)?; UrlString::from(url) } else { - return Err(PylockTomlError::SdistMissingPathUrl(name.clone())); + return Err(PylockTomlErrorKind::SdistMissingPathUrl(name.clone())); }; let index = if let Some(index) = index { @@ -1381,7 +1468,7 @@ impl PylockTomlSdist { // URL (less the filename) as the index. This isn't correct, but it's the best we can // do. In practice, the only effect here should be that we cache the sdist under a hash // of this URL (since we cache under the hash of the index). - let mut index = file_url.to_url().map_err(PylockTomlError::ToUrl)?; + let mut index = file_url.to_url().map_err(PylockTomlErrorKind::ToUrl)?; index.path_segments_mut().unwrap().pop(); IndexUrl::from(VerbatimUrl::from_url(index)) }; @@ -1414,11 +1501,11 @@ impl PylockTomlArchive { install_path: &Path, name: &PackageName, version: Option<&Version>, - ) -> Result { + ) -> Result { if let Some(url) = self.url.as_ref() { let filename = url .filename() - .map_err(|_| PylockTomlError::UrlMissingFilename(url.clone()))?; + .map_err(|_| PylockTomlErrorKind::UrlMissingFilename(url.clone()))?; let ext = DistExtension::from_path(filename.as_ref())?; match ext { @@ -1446,7 +1533,7 @@ impl PylockTomlArchive { .file_name() .and_then(OsStr::to_str) .ok_or_else(|| { - PylockTomlError::PathMissingFilename(Box::::from(path.clone())) + PylockTomlErrorKind::PathMissingFilename(Box::::from(path.clone())) })?; let ext = DistExtension::from_path(filename)?; @@ -1455,7 +1542,7 @@ impl PylockTomlArchive { let filename = WheelFilename::from_str(filename)?; let install_path = install_path.join(path); let url = VerbatimUrl::from_absolute_path(&install_path) - .map_err(|_| PylockTomlError::PathToUrl)?; + .map_err(|_| PylockTomlErrorKind::PathToUrl)?; Ok(Dist::Built(BuiltDist::Path(PathBuiltDist { filename, install_path: install_path.into_boxed_path(), @@ -1465,7 +1552,7 @@ impl PylockTomlArchive { DistExtension::Source(ext) => { let install_path = install_path.join(path); let url = VerbatimUrl::from_absolute_path(&install_path) - .map_err(|_| PylockTomlError::PathToUrl)?; + .map_err(|_| PylockTomlErrorKind::PathToUrl)?; Ok(Dist::Source(SourceDist::Path(PathSourceDist { name: name.clone(), version: version.cloned(), @@ -1476,16 +1563,16 @@ impl PylockTomlArchive { } } } else { - return Err(PylockTomlError::ArchiveMissingPathUrl(name.clone())); + return Err(PylockTomlErrorKind::ArchiveMissingPathUrl(name.clone())); } } /// Returns `true` if the [`PylockTomlArchive`] is a wheel. - fn is_wheel(&self, name: &PackageName) -> Result { + fn is_wheel(&self, name: &PackageName) -> Result { if let Some(url) = self.url.as_ref() { let filename = url .filename() - .map_err(|_| PylockTomlError::UrlMissingFilename(url.clone()))?; + .map_err(|_| PylockTomlErrorKind::UrlMissingFilename(url.clone()))?; let ext = DistExtension::from_path(filename.as_ref())?; Ok(matches!(ext, DistExtension::Wheel)) @@ -1495,13 +1582,13 @@ impl PylockTomlArchive { .file_name() .and_then(OsStr::to_str) .ok_or_else(|| { - PylockTomlError::PathMissingFilename(Box::::from(path.clone())) + PylockTomlErrorKind::PathMissingFilename(Box::::from(path.clone())) })?; let ext = DistExtension::from_path(filename)?; Ok(matches!(ext, DistExtension::Wheel)) } else { - return Err(PylockTomlError::ArchiveMissingPathUrl(name.clone())); + return Err(PylockTomlErrorKind::ArchiveMissingPathUrl(name.clone())); } } } diff --git a/crates/uv-resolver/src/lock/mod.rs b/crates/uv-resolver/src/lock/mod.rs index 3b3b5f5d7..441497c91 100644 --- a/crates/uv-resolver/src/lock/mod.rs +++ b/crates/uv-resolver/src/lock/mod.rs @@ -52,7 +52,7 @@ use uv_workspace::WorkspaceMember; use crate::fork_strategy::ForkStrategy; pub(crate) use crate::lock::export::PylockTomlPackage; pub use crate::lock::export::RequirementsTxtExport; -pub use crate::lock::export::{PylockToml, PylockTomlError}; +pub use crate::lock::export::{PylockToml, PylockTomlErrorKind}; pub use crate::lock::installable::Installable; pub use crate::lock::map::PackageMap; pub use crate::lock::tree::TreeDisplay; @@ -2305,78 +2305,19 @@ impl Package { } } - /// Generate a [`LockErrorHint`] based on wheel-tag incompatibilities. - fn tag_hint(&self, tag_policy: TagPolicy<'_>) -> Option { - let incompatibility = self + /// Generate a [`WheelTagHint`] based on wheel-tag incompatibilities. + fn tag_hint(&self, tag_policy: TagPolicy<'_>) -> Option { + let filenames = self .wheels .iter() - .map(|wheel| { - tag_policy.tags().compatibility( - wheel.filename.python_tags(), - wheel.filename.abi_tags(), - wheel.filename.platform_tags(), - ) - }) - .max()?; - match incompatibility { - TagCompatibility::Incompatible(IncompatibleTag::Python) => { - let best = tag_policy.tags().python_tag(); - let tags = self.python_tags().collect::>(); - if tags.is_empty() { - None - } else { - Some(LockErrorHint::LanguageTags { - package: self.id.name.clone(), - version: self.id.version.clone(), - tags, - best, - }) - } - } - TagCompatibility::Incompatible(IncompatibleTag::Abi) => { - let best = tag_policy.tags().abi_tag(); - let tags = self - .abi_tags() - // 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(LockErrorHint::AbiTags { - package: self.id.name.clone(), - version: self.id.version.clone(), - tags, - best, - }) - } - } - TagCompatibility::Incompatible(IncompatibleTag::Platform) => { - let best = tag_policy.tags().platform_tag().cloned(); - let tags = self - .platform_tags(tag_policy.tags()) - .cloned() - .collect::>(); - if tags.is_empty() { - None - } else { - Some(LockErrorHint::PlatformTags { - package: self.id.name.clone(), - version: self.id.version.clone(), - tags, - best, - }) - } - } - _ => None, - } + .map(|wheel| &wheel.filename) + .collect::>(); + WheelTagHint::from_wheels( + &self.id.name, + self.id.version.as_ref(), + &filenames, + tag_policy.tags(), + ) } /// Convert the source of this [`Package`] to a [`SourceDist`] that can be used in installation. @@ -2729,43 +2670,6 @@ impl Package { Ok(table) } - /// Returns an iterator over the compatible Python tags of the available wheels. - fn python_tags(&self) -> impl Iterator + '_ { - self.wheels - .iter() - .flat_map(|wheel| wheel.filename.python_tags()) - .copied() - } - - /// Returns an iterator over the compatible Python tags of the available wheels. - fn abi_tags(&self) -> impl Iterator + '_ { - self.wheels - .iter() - .flat_map(|wheel| wheel.filename.abi_tags()) - .copied() - } - - /// 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, - ) -> impl Iterator + 'a { - self.wheels.iter().flat_map(move |wheel| { - if wheel.filename.python_tags().iter().any(|wheel_py| { - wheel - .filename - .abi_tags() - .iter() - .any(|wheel_abi| tags.is_compatible_abi(*wheel_py, *wheel_abi)) - }) { - wheel.filename.platform_tags().iter() - } else { - [].iter() - } - }) - } - fn find_best_wheel(&self, tag_policy: TagPolicy<'_>) -> Option { type WheelPriority<'lock> = (TagPriority, Option<&'lock BuildTag>); @@ -4782,7 +4686,7 @@ fn normalize_requirement( #[derive(Debug)] pub struct LockError { kind: Box, - hint: Option, + hint: Option, } impl std::error::Error for LockError { @@ -4822,7 +4726,7 @@ where #[derive(Debug, Clone, PartialEq, Eq)] #[allow(clippy::enum_variant_names)] -enum LockErrorHint { +enum WheelTagHint { /// None of the available wheels for a package have a compatible Python language tag (e.g., /// `cp310` in `cp310-abi3-manylinux_2_17_x86_64.whl`). LanguageTags { @@ -4849,7 +4753,123 @@ enum LockErrorHint { }, } -impl std::fmt::Display for LockErrorHint { +impl WheelTagHint { + /// Generate a [`WheelTagHint`] from the given (incompatible) wheels. + fn from_wheels( + name: &PackageName, + version: Option<&Version>, + filenames: &[&WheelFilename], + tags: &Tags, + ) -> Option { + let incompatibility = filenames + .iter() + .map(|filename| { + tags.compatibility( + filename.python_tags(), + filename.abi_tags(), + filename.platform_tags(), + ) + }) + .max()?; + match incompatibility { + TagCompatibility::Incompatible(IncompatibleTag::Python) => { + let best = tags.python_tag(); + let tags = Self::python_tags(filenames.iter().copied()).collect::>(); + if tags.is_empty() { + None + } else { + Some(WheelTagHint::LanguageTags { + package: name.clone(), + version: version.cloned(), + tags, + best, + }) + } + } + TagCompatibility::Incompatible(IncompatibleTag::Abi) => { + let best = tags.abi_tag(); + let tags = Self::abi_tags(filenames.iter().copied()) + // 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(WheelTagHint::AbiTags { + package: name.clone(), + version: version.cloned(), + tags, + best, + }) + } + } + TagCompatibility::Incompatible(IncompatibleTag::Platform) => { + let best = tags.platform_tag().cloned(); + let tags = Self::platform_tags(filenames.iter().copied(), tags) + .cloned() + .collect::>(); + if tags.is_empty() { + None + } else { + Some(WheelTagHint::PlatformTags { + package: name.clone(), + version: version.cloned(), + tags, + best, + }) + } + } + _ => None, + } + } + + /// Returns an iterator over the compatible Python tags of the available wheels. + fn python_tags<'a>( + filenames: impl Iterator + 'a, + ) -> impl Iterator + 'a { + filenames + .flat_map(uv_distribution_filename::WheelFilename::python_tags) + .copied() + } + + /// Returns an iterator over the compatible Python tags of the available wheels. + fn abi_tags<'a>( + filenames: impl Iterator + 'a, + ) -> impl Iterator + 'a { + filenames + .flat_map(uv_distribution_filename::WheelFilename::abi_tags) + .copied() + } + + /// Returns the set of platform tags for the distribution that are ABI-compatible with the given + /// tags. + fn platform_tags<'a>( + filenames: impl Iterator + 'a, + tags: &'a Tags, + ) -> impl Iterator + 'a { + filenames.flat_map(move |filename| { + if filename.python_tags().iter().any(|wheel_py| { + filename + .abi_tags() + .iter() + .any(|wheel_abi| tags.is_compatible_abi(*wheel_py, *wheel_abi)) + }) { + filename.platform_tags().iter() + } else { + [].iter() + } + }) + } +} + +impl std::fmt::Display for WheelTagHint { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::LanguageTags { diff --git a/crates/uv/tests/it/pip_sync.rs b/crates/uv/tests/it/pip_sync.rs index f577612c3..bb8a0aa34 100644 --- a/crates/uv/tests/it/pip_sync.rs +++ b/crates/uv/tests/it/pip_sync.rs @@ -5892,6 +5892,8 @@ fn pep_751_wheel_only() -> Result<()> { ----- stderr ----- error: Package `torch` can't be installed because it doesn't have a source distribution or wheel for the current platform + + hint: You're using CPython 3.8 (`cp38`), but `torch` (v2.2.1) only has wheels with the following Python implementation tag: `cp312` " );