Show tag hints when failing to find a compatible wheel in `pylock.toml` (#13136)

## Summary

Closes #13135.
This commit is contained in:
Charlie Marsh 2025-04-27 12:56:50 -04:00 committed by GitHub
parent 78756de027
commit dc5b3762f3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 294 additions and 184 deletions

View File

@ -7,7 +7,7 @@ use uv_configuration::Upgrade;
use uv_fs::CWD; use uv_fs::CWD;
use uv_git::ResolvedRepositoryReference; use uv_git::ResolvedRepositoryReference;
use uv_requirements_txt::RequirementsTxt; 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)] #[derive(Debug, Default)]
pub struct LockedRequirements { pub struct LockedRequirements {
@ -105,7 +105,7 @@ pub fn read_lock_requirements(
pub async fn read_pylock_toml_requirements( pub async fn read_pylock_toml_requirements(
output_file: &Path, output_file: &Path,
upgrade: &Upgrade, upgrade: &Upgrade,
) -> Result<LockedRequirements, PylockTomlError> { ) -> Result<LockedRequirements, PylockTomlErrorKind> {
// As an optimization, skip iterating over the lockfile is we're upgrading all packages anyway. // As an optimization, skip iterating over the lockfile is we're upgrading all packages anyway.
if upgrade.is_all() { if upgrade.is_all() {
return Ok(LockedRequirements::default()); return Ok(LockedRequirements::default());

View File

@ -5,8 +5,9 @@ pub use exclusions::Exclusions;
pub use flat_index::{FlatDistributions, FlatIndex}; pub use flat_index::{FlatDistributions, FlatIndex};
pub use fork_strategy::ForkStrategy; pub use fork_strategy::ForkStrategy;
pub use lock::{ pub use lock::{
Installable, Lock, LockError, LockVersion, Package, PackageMap, PylockToml, PylockTomlError, Installable, Lock, LockError, LockVersion, Package, PackageMap, PylockToml,
RequirementsTxtExport, ResolverManifest, SatisfiesResult, TreeDisplay, VERSION, PylockTomlErrorKind, RequirementsTxtExport, ResolverManifest, SatisfiesResult, TreeDisplay,
VERSION,
}; };
pub use manifest::Manifest; pub use manifest::Manifest;
pub use options::{Flexibility, Options, OptionsBuilder}; pub use options::{Flexibility, Options, OptionsBuilder};

View File

@ -15,7 +15,7 @@ use uv_pypi_types::ConflictItem;
use crate::graph_ops::{marker_reachability, Reachable}; use crate::graph_ops::{marker_reachability, Reachable};
pub(crate) use crate::lock::export::pylock_toml::PylockTomlPackage; 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; pub use crate::lock::export::requirements_txt::RequirementsTxtExport;
use crate::universal_marker::resolve_conflicts; use crate::universal_marker::resolve_conflicts;
use crate::{Installable, Package}; use crate::{Installable, Package};

View File

@ -36,12 +36,12 @@ use uv_pypi_types::{HashDigests, Hashes, ParsedGitUrl, VcsKind};
use uv_small_str::SmallString; use uv_small_str::SmallString;
use crate::lock::export::ExportableRequirements; 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::resolution::ResolutionGraphNode;
use crate::{Installable, LockError, RequiresPython, ResolverOutput}; use crate::{Installable, LockError, RequiresPython, ResolverOutput};
#[derive(Debug, thiserror::Error)] #[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`)")] #[error("Package `{0}` includes both a registry (`packages.wheels`) and a directory source (`packages.directory`)")]
WheelWithDirectory(PackageName), WheelWithDirectory(PackageName),
#[error("Package `{0}` includes both a registry (`packages.wheels`) and a VCS source (`packages.vcs`)")] #[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), Deserialize(#[from] toml::de::Error),
} }
#[derive(Debug)]
pub struct PylockTomlError {
kind: Box<PylockTomlErrorKind>,
hint: Option<WheelTagHint>,
}
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<E> From<E> for PylockTomlError
where
PylockTomlErrorKind: From<E>,
{
fn from(err: E) -> Self {
PylockTomlError {
kind: Box::new(PylockTomlErrorKind::from(err)),
hint: None,
}
}
}
#[derive(Debug, serde::Serialize, serde::Deserialize)] #[derive(Debug, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "kebab-case")] #[serde(rename_all = "kebab-case")]
pub struct PylockToml { pub struct PylockToml {
@ -267,7 +301,7 @@ impl<'lock> PylockToml {
resolution: &ResolverOutput, resolution: &ResolverOutput,
omit: &[PackageName], omit: &[PackageName],
install_path: &Path, install_path: &Path,
) -> Result<Self, PylockTomlError> { ) -> Result<Self, PylockTomlErrorKind> {
// The lock version is always `1.0` at time of writing. // The lock version is always `1.0` at time of writing.
let lock_version = Version::new([1, 0]); let lock_version = Version::new([1, 0]);
@ -354,8 +388,11 @@ impl<'lock> PylockToml {
dist.wheels dist.wheels
.iter() .iter()
.map(|wheel| { .map(|wheel| {
let url = let url = wheel
wheel.file.url.to_url().map_err(PylockTomlError::ToUrl)?; .file
.url
.to_url()
.map_err(PylockTomlErrorKind::ToUrl)?;
Ok(PylockTomlWheel { Ok(PylockTomlWheel {
// Optional "when the last component of path/ url would be the same value". // Optional "when the last component of path/ url would be the same value".
name: if url name: if url
@ -372,18 +409,26 @@ impl<'lock> PylockToml {
.map(Timestamp::from_millisecond) .map(Timestamp::from_millisecond)
.transpose()?, .transpose()?,
url: Some( url: Some(
wheel.file.url.to_url().map_err(PylockTomlError::ToUrl)?, wheel
.file
.url
.to_url()
.map_err(PylockTomlErrorKind::ToUrl)?,
), ),
path: None, path: None,
size: wheel.file.size, size: wheel.file.size,
hashes: Hashes::from(wheel.file.hashes.clone()), hashes: Hashes::from(wheel.file.hashes.clone()),
}) })
}) })
.collect::<Result<Vec<_>, PylockTomlError>>()?, .collect::<Result<Vec<_>, PylockTomlErrorKind>>()?,
); );
if let Some(sdist) = dist.sdist.as_ref() { 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 { package.sdist = Some(PylockTomlSdist {
// Optional "when the last component of path/ url would be the same value". // Optional "when the last component of path/ url would be the same value".
name: if url name: if url
@ -456,8 +501,11 @@ impl<'lock> PylockToml {
dist.wheels dist.wheels
.iter() .iter()
.map(|wheel| { .map(|wheel| {
let url = let url = wheel
wheel.file.url.to_url().map_err(PylockTomlError::ToUrl)?; .file
.url
.to_url()
.map_err(PylockTomlErrorKind::ToUrl)?;
Ok(PylockTomlWheel { Ok(PylockTomlWheel {
// Optional "when the last component of path/ url would be the same value". // Optional "when the last component of path/ url would be the same value".
name: if url name: if url
@ -474,17 +522,21 @@ impl<'lock> PylockToml {
.map(Timestamp::from_millisecond) .map(Timestamp::from_millisecond)
.transpose()?, .transpose()?,
url: Some( url: Some(
wheel.file.url.to_url().map_err(PylockTomlError::ToUrl)?, wheel
.file
.url
.to_url()
.map_err(PylockTomlErrorKind::ToUrl)?,
), ),
path: None, path: None,
size: wheel.file.size, size: wheel.file.size,
hashes: Hashes::from(wheel.file.hashes.clone()), hashes: Hashes::from(wheel.file.hashes.clone()),
}) })
}) })
.collect::<Result<Vec<_>, PylockTomlError>>()?, .collect::<Result<Vec<_>, 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 { package.sdist = Some(PylockTomlSdist {
// Optional "when the last component of path/ url would be the same value". // Optional "when the last component of path/ url would be the same value".
name: if url name: if url
@ -536,7 +588,7 @@ impl<'lock> PylockToml {
dev: &DependencyGroupsWithDefaults, dev: &DependencyGroupsWithDefaults,
annotate: bool, annotate: bool,
install_options: &'lock InstallOptions, install_options: &'lock InstallOptions,
) -> Result<Self, PylockTomlError> { ) -> Result<Self, PylockTomlErrorKind> {
// Extract the packages from the lock file. // Extract the packages from the lock file.
let ExportableRequirements(mut nodes) = ExportableRequirements::from_lock( let ExportableRequirements(mut nodes) = ExportableRequirements::from_lock(
target, target,
@ -591,8 +643,11 @@ impl<'lock> PylockToml {
wheels wheels
.into_iter() .into_iter()
.map(|wheel| { .map(|wheel| {
let url = let url = wheel
wheel.file.url.to_url().map_err(PylockTomlError::ToUrl)?; .file
.url
.to_url()
.map_err(PylockTomlErrorKind::ToUrl)?;
Ok(PylockTomlWheel { Ok(PylockTomlWheel {
// Optional "when the last component of path/ url would be the same value". // Optional "when the last component of path/ url would be the same value".
name: if url name: if url
@ -614,7 +669,7 @@ impl<'lock> PylockToml {
hashes: Hashes::from(wheel.file.hashes), hashes: Hashes::from(wheel.file.hashes),
}) })
}) })
.collect::<Result<Vec<_>, PylockTomlError>>()?, .collect::<Result<Vec<_>, PylockTomlErrorKind>>()?,
) )
} }
Source::Path(..) => None, Source::Path(..) => None,
@ -728,7 +783,11 @@ impl<'lock> PylockToml {
// Extract the `packages.sdist` field. // Extract the `packages.sdist` field.
let sdist = match &sdist { let sdist = match &sdist {
Some(SourceDist::Registry(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 { Some(PylockTomlSdist {
// Optional "when the last component of path/ url would be the same value". // Optional "when the last component of path/ url would be the same value".
name: if url name: if url
@ -894,37 +953,43 @@ impl<'lock> PylockToml {
) { ) {
// `packages.wheels` is mutually exclusive with `packages.directory`, `packages.vcs`, and `packages.archive`. // `packages.wheels` is mutually exclusive with `packages.directory`, `packages.vcs`, and `packages.archive`.
(true, _, true, _, _) => { (true, _, true, _, _) => {
return Err(PylockTomlError::WheelWithDirectory(package.name.clone())); return Err(
PylockTomlErrorKind::WheelWithDirectory(package.name.clone()).into(),
);
} }
(true, _, _, true, _) => { (true, _, _, true, _) => {
return Err(PylockTomlError::WheelWithVcs(package.name.clone())); return Err(PylockTomlErrorKind::WheelWithVcs(package.name.clone()).into());
} }
(true, _, _, _, true) => { (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`. // `packages.sdist` is mutually exclusive with `packages.directory`, `packages.vcs`, and `packages.archive`.
(_, true, true, _, _) => { (_, true, true, _, _) => {
return Err(PylockTomlError::SdistWithDirectory(package.name.clone())); return Err(
PylockTomlErrorKind::SdistWithDirectory(package.name.clone()).into(),
);
} }
(_, true, _, true, _) => { (_, true, _, true, _) => {
return Err(PylockTomlError::SdistWithVcs(package.name.clone())); return Err(PylockTomlErrorKind::SdistWithVcs(package.name.clone()).into());
} }
(_, true, _, _, true) => { (_, 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`. // `packages.directory` is mutually exclusive with `packages.vcs`, and `packages.archive`.
(_, _, true, true, _) => { (_, _, true, true, _) => {
return Err(PylockTomlError::DirectoryWithVcs(package.name.clone())); return Err(PylockTomlErrorKind::DirectoryWithVcs(package.name.clone()).into());
} }
(_, _, true, _, true) => { (_, _, 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`. // `packages.vcs` is mutually exclusive with `packages.archive`.
(_, _, _, true, true) => { (_, _, _, true, true) => {
return Err(PylockTomlError::VcsWithArchive(package.name.clone())); return Err(PylockTomlErrorKind::VcsWithArchive(package.name.clone()).into());
} }
(false, false, false, false, false) => { (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 { } else {
return match (no_binary, no_build) { 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 => { (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())), (true, false) => {
(false, true) => Err(PylockTomlError::NoBuild(package.name.clone())), Err(PylockTomlErrorKind::NoBinary(package.name.clone()).into())
(false, false) if is_wheel => {
Err(PylockTomlError::IncompatibleWheelOnly(package.name.clone()))
} }
(false, false) => Err(PylockTomlError::NeitherSourceDistNorWheel( (false, true) => Err(PylockTomlErrorKind::NoBuild(package.name.clone()).into()),
(false, false) if is_wheel => Err(PylockTomlError {
kind: Box::new(PylockTomlErrorKind::IncompatibleWheelOnly(
package.name.clone(), 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) best.map(|(_, i)| i)
} }
/// Generate a [`WheelTagHint`] based on wheel-tag incompatibilities.
fn tag_hint(&self, tags: &Tags) -> Option<WheelTagHint> {
let filenames = self
.wheels
.iter()
.flatten()
.filter_map(|wheel| wheel.filename(&self.name).ok())
.collect::<Vec<_>>();
let filenames = filenames.iter().map(Cow::as_ref).collect::<Vec<_>>();
WheelTagHint::from_wheels(&self.name, self.version.as_ref(), &filenames, tags)
}
/// Returns the [`ResolvedRepositoryReference`] for the package, if it is a Git source. /// Returns the [`ResolvedRepositoryReference`] for the package, if it is a Git source.
pub fn as_git_ref(&self) -> Option<ResolvedRepositoryReference> { pub fn as_git_ref(&self) -> Option<ResolvedRepositoryReference> {
let vcs = self.vcs.as_ref()?; let vcs = self.vcs.as_ref()?;
@ -1180,12 +1267,12 @@ impl PylockTomlPackage {
impl PylockTomlWheel { impl PylockTomlWheel {
/// Return the [`WheelFilename`] for this wheel. /// Return the [`WheelFilename`] for this wheel.
fn filename(&self, name: &PackageName) -> Result<Cow<'_, WheelFilename>, PylockTomlError> { fn filename(&self, name: &PackageName) -> Result<Cow<WheelFilename>, PylockTomlErrorKind> {
if let Some(name) = self.name.as_ref() { if let Some(name) = self.name.as_ref() {
Ok(Cow::Borrowed(name)) Ok(Cow::Borrowed(name))
} else if let Some(path) = self.path.as_ref() { } else if let Some(path) = self.path.as_ref() {
let Some(filename) = path.as_ref().file_name().and_then(OsStr::to_str) else { let Some(filename) = path.as_ref().file_name().and_then(OsStr::to_str) else {
return Err(PylockTomlError::PathMissingFilename(Box::<Path>::from( return Err(PylockTomlErrorKind::PathMissingFilename(Box::<Path>::from(
path.clone(), path.clone(),
))); )));
}; };
@ -1193,12 +1280,12 @@ impl PylockTomlWheel {
Ok(filename) Ok(filename)
} else if let Some(url) = self.url.as_ref() { } else if let Some(url) = self.url.as_ref() {
let Some(filename) = url.filename().ok() else { 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)?; let filename = WheelFilename::from_str(&filename).map(Cow::Owned)?;
Ok(filename) Ok(filename)
} else { } else {
Err(PylockTomlError::WheelMissingPathUrl(name.clone())) Err(PylockTomlErrorKind::WheelMissingPathUrl(name.clone()))
} }
} }
@ -1208,17 +1295,17 @@ impl PylockTomlWheel {
install_path: &Path, install_path: &Path,
name: &PackageName, name: &PackageName,
index: Option<&Url>, index: Option<&Url>,
) -> Result<RegistryBuiltWheel, PylockTomlError> { ) -> Result<RegistryBuiltWheel, PylockTomlErrorKind> {
let filename = self.filename(name)?.into_owned(); let filename = self.filename(name)?.into_owned();
let file_url = if let Some(url) = self.url.as_ref() { let file_url = if let Some(url) = self.url.as_ref() {
UrlString::from(url) UrlString::from(url)
} else if let Some(path) = self.path.as_ref() { } else if let Some(path) = self.path.as_ref() {
let path = install_path.join(path); 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) UrlString::from(url)
} else { } else {
return Err(PylockTomlError::WheelMissingPathUrl(name.clone())); return Err(PylockTomlErrorKind::WheelMissingPathUrl(name.clone()));
}; };
let index = if let Some(index) = index { 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 // 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 // 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). // 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(); index.path_segments_mut().unwrap().pop();
IndexUrl::from(VerbatimUrl::from_url(index)) IndexUrl::from(VerbatimUrl::from_url(index))
}; };
@ -1258,7 +1345,7 @@ impl PylockTomlDirectory {
&self, &self,
install_path: &Path, install_path: &Path,
name: &PackageName, name: &PackageName,
) -> Result<DirectorySourceDist, PylockTomlError> { ) -> Result<DirectorySourceDist, PylockTomlErrorKind> {
let path = if let Some(subdirectory) = self.subdirectory.as_ref() { let path = if let Some(subdirectory) = self.subdirectory.as_ref() {
install_path.join(&self.path).join(subdirectory) install_path.join(&self.path).join(subdirectory)
} else { } else {
@ -1266,7 +1353,7 @@ impl PylockTomlDirectory {
}; };
let path = uv_fs::normalize_path_buf(path); let path = uv_fs::normalize_path_buf(path);
let url = let url =
VerbatimUrl::from_normalized_path(&path).map_err(|_| PylockTomlError::PathToUrl)?; VerbatimUrl::from_normalized_path(&path).map_err(|_| PylockTomlErrorKind::PathToUrl)?;
Ok(DirectorySourceDist { Ok(DirectorySourceDist {
name: name.clone(), name: name.clone(),
install_path: path.into_boxed_path(), install_path: path.into_boxed_path(),
@ -1283,7 +1370,7 @@ impl PylockTomlVcs {
&self, &self,
install_path: &Path, install_path: &Path,
name: &PackageName, name: &PackageName,
) -> Result<GitSourceDist, PylockTomlError> { ) -> Result<GitSourceDist, PylockTomlErrorKind> {
let subdirectory = self.subdirectory.clone().map(Box::<Path>::from); let subdirectory = self.subdirectory.clone().map(Box::<Path>::from);
// Reconstruct the `GitUrl` from the individual fields. // Reconstruct the `GitUrl` from the individual fields.
@ -1292,9 +1379,9 @@ impl PylockTomlVcs {
url.clone() url.clone()
} else if let Some(path) = self.path.as_ref() { } else if let Some(path) = self.path.as_ref() {
Url::from_directory_path(install_path.join(path)) Url::from_directory_path(install_path.join(path))
.map_err(|()| PylockTomlError::PathToUrl)? .map_err(|()| PylockTomlErrorKind::PathToUrl)?
} else { } else {
return Err(PylockTomlError::VcsMissingPathUrl(name.clone())); return Err(PylockTomlErrorKind::VcsMissingPathUrl(name.clone()));
}; };
url.set_fragment(None); url.set_fragment(None);
url.set_query(None); url.set_query(None);
@ -1326,23 +1413,23 @@ impl PylockTomlVcs {
impl PylockTomlSdist { impl PylockTomlSdist {
/// Return the filename for this sdist. /// Return the filename for this sdist.
fn filename(&self, name: &PackageName) -> Result<Cow<'_, SmallString>, PylockTomlError> { fn filename(&self, name: &PackageName) -> Result<Cow<'_, SmallString>, PylockTomlErrorKind> {
if let Some(name) = self.name.as_ref() { if let Some(name) = self.name.as_ref() {
Ok(Cow::Borrowed(name)) Ok(Cow::Borrowed(name))
} else if let Some(path) = self.path.as_ref() { } else if let Some(path) = self.path.as_ref() {
let Some(filename) = path.as_ref().file_name().and_then(OsStr::to_str) else { let Some(filename) = path.as_ref().file_name().and_then(OsStr::to_str) else {
return Err(PylockTomlError::PathMissingFilename(Box::<Path>::from( return Err(PylockTomlErrorKind::PathMissingFilename(Box::<Path>::from(
path.clone(), path.clone(),
))); )));
}; };
Ok(Cow::Owned(SmallString::from(filename))) Ok(Cow::Owned(SmallString::from(filename)))
} else if let Some(url) = self.url.as_ref() { } else if let Some(url) = self.url.as_ref() {
let Some(filename) = url.filename().ok() else { 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))) Ok(Cow::Owned(SmallString::from(filename)))
} else { } else {
Err(PylockTomlError::SdistMissingPathUrl(name.clone())) Err(PylockTomlErrorKind::SdistMissingPathUrl(name.clone()))
} }
} }
@ -1353,7 +1440,7 @@ impl PylockTomlSdist {
name: &PackageName, name: &PackageName,
version: Option<&Version>, version: Option<&Version>,
index: Option<&Url>, index: Option<&Url>,
) -> Result<RegistrySourceDist, PylockTomlError> { ) -> Result<RegistrySourceDist, PylockTomlErrorKind> {
let filename = self.filename(name)?.into_owned(); let filename = self.filename(name)?.into_owned();
let ext = SourceDistExtension::from_path(filename.as_ref())?; let ext = SourceDistExtension::from_path(filename.as_ref())?;
@ -1368,10 +1455,10 @@ impl PylockTomlSdist {
UrlString::from(url) UrlString::from(url)
} else if let Some(path) = self.path.as_ref() { } else if let Some(path) = self.path.as_ref() {
let path = install_path.join(path); 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) UrlString::from(url)
} else { } else {
return Err(PylockTomlError::SdistMissingPathUrl(name.clone())); return Err(PylockTomlErrorKind::SdistMissingPathUrl(name.clone()));
}; };
let index = if let Some(index) = index { 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 // 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 // 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). // 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(); index.path_segments_mut().unwrap().pop();
IndexUrl::from(VerbatimUrl::from_url(index)) IndexUrl::from(VerbatimUrl::from_url(index))
}; };
@ -1414,11 +1501,11 @@ impl PylockTomlArchive {
install_path: &Path, install_path: &Path,
name: &PackageName, name: &PackageName,
version: Option<&Version>, version: Option<&Version>,
) -> Result<Dist, PylockTomlError> { ) -> Result<Dist, PylockTomlErrorKind> {
if let Some(url) = self.url.as_ref() { if let Some(url) = self.url.as_ref() {
let filename = url let filename = url
.filename() .filename()
.map_err(|_| PylockTomlError::UrlMissingFilename(url.clone()))?; .map_err(|_| PylockTomlErrorKind::UrlMissingFilename(url.clone()))?;
let ext = DistExtension::from_path(filename.as_ref())?; let ext = DistExtension::from_path(filename.as_ref())?;
match ext { match ext {
@ -1446,7 +1533,7 @@ impl PylockTomlArchive {
.file_name() .file_name()
.and_then(OsStr::to_str) .and_then(OsStr::to_str)
.ok_or_else(|| { .ok_or_else(|| {
PylockTomlError::PathMissingFilename(Box::<Path>::from(path.clone())) PylockTomlErrorKind::PathMissingFilename(Box::<Path>::from(path.clone()))
})?; })?;
let ext = DistExtension::from_path(filename)?; let ext = DistExtension::from_path(filename)?;
@ -1455,7 +1542,7 @@ impl PylockTomlArchive {
let filename = WheelFilename::from_str(filename)?; let filename = WheelFilename::from_str(filename)?;
let install_path = install_path.join(path); let install_path = install_path.join(path);
let url = VerbatimUrl::from_absolute_path(&install_path) let url = VerbatimUrl::from_absolute_path(&install_path)
.map_err(|_| PylockTomlError::PathToUrl)?; .map_err(|_| PylockTomlErrorKind::PathToUrl)?;
Ok(Dist::Built(BuiltDist::Path(PathBuiltDist { Ok(Dist::Built(BuiltDist::Path(PathBuiltDist {
filename, filename,
install_path: install_path.into_boxed_path(), install_path: install_path.into_boxed_path(),
@ -1465,7 +1552,7 @@ impl PylockTomlArchive {
DistExtension::Source(ext) => { DistExtension::Source(ext) => {
let install_path = install_path.join(path); let install_path = install_path.join(path);
let url = VerbatimUrl::from_absolute_path(&install_path) let url = VerbatimUrl::from_absolute_path(&install_path)
.map_err(|_| PylockTomlError::PathToUrl)?; .map_err(|_| PylockTomlErrorKind::PathToUrl)?;
Ok(Dist::Source(SourceDist::Path(PathSourceDist { Ok(Dist::Source(SourceDist::Path(PathSourceDist {
name: name.clone(), name: name.clone(),
version: version.cloned(), version: version.cloned(),
@ -1476,16 +1563,16 @@ impl PylockTomlArchive {
} }
} }
} else { } else {
return Err(PylockTomlError::ArchiveMissingPathUrl(name.clone())); return Err(PylockTomlErrorKind::ArchiveMissingPathUrl(name.clone()));
} }
} }
/// Returns `true` if the [`PylockTomlArchive`] is a wheel. /// Returns `true` if the [`PylockTomlArchive`] is a wheel.
fn is_wheel(&self, name: &PackageName) -> Result<bool, PylockTomlError> { fn is_wheel(&self, name: &PackageName) -> Result<bool, PylockTomlErrorKind> {
if let Some(url) = self.url.as_ref() { if let Some(url) = self.url.as_ref() {
let filename = url let filename = url
.filename() .filename()
.map_err(|_| PylockTomlError::UrlMissingFilename(url.clone()))?; .map_err(|_| PylockTomlErrorKind::UrlMissingFilename(url.clone()))?;
let ext = DistExtension::from_path(filename.as_ref())?; let ext = DistExtension::from_path(filename.as_ref())?;
Ok(matches!(ext, DistExtension::Wheel)) Ok(matches!(ext, DistExtension::Wheel))
@ -1495,13 +1582,13 @@ impl PylockTomlArchive {
.file_name() .file_name()
.and_then(OsStr::to_str) .and_then(OsStr::to_str)
.ok_or_else(|| { .ok_or_else(|| {
PylockTomlError::PathMissingFilename(Box::<Path>::from(path.clone())) PylockTomlErrorKind::PathMissingFilename(Box::<Path>::from(path.clone()))
})?; })?;
let ext = DistExtension::from_path(filename)?; let ext = DistExtension::from_path(filename)?;
Ok(matches!(ext, DistExtension::Wheel)) Ok(matches!(ext, DistExtension::Wheel))
} else { } else {
return Err(PylockTomlError::ArchiveMissingPathUrl(name.clone())); return Err(PylockTomlErrorKind::ArchiveMissingPathUrl(name.clone()));
} }
} }
} }

View File

@ -52,7 +52,7 @@ use uv_workspace::WorkspaceMember;
use crate::fork_strategy::ForkStrategy; use crate::fork_strategy::ForkStrategy;
pub(crate) use crate::lock::export::PylockTomlPackage; pub(crate) use crate::lock::export::PylockTomlPackage;
pub use crate::lock::export::RequirementsTxtExport; 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::installable::Installable;
pub use crate::lock::map::PackageMap; pub use crate::lock::map::PackageMap;
pub use crate::lock::tree::TreeDisplay; pub use crate::lock::tree::TreeDisplay;
@ -2305,78 +2305,19 @@ impl Package {
} }
} }
/// Generate a [`LockErrorHint`] based on wheel-tag incompatibilities. /// Generate a [`WheelTagHint`] based on wheel-tag incompatibilities.
fn tag_hint(&self, tag_policy: TagPolicy<'_>) -> Option<LockErrorHint> { fn tag_hint(&self, tag_policy: TagPolicy<'_>) -> Option<WheelTagHint> {
let incompatibility = self let filenames = self
.wheels .wheels
.iter() .iter()
.map(|wheel| { .map(|wheel| &wheel.filename)
tag_policy.tags().compatibility( .collect::<Vec<_>>();
wheel.filename.python_tags(), WheelTagHint::from_wheels(
wheel.filename.abi_tags(), &self.id.name,
wheel.filename.platform_tags(), self.id.version.as_ref(),
&filenames,
tag_policy.tags(),
) )
})
.max()?;
match incompatibility {
TagCompatibility::Incompatible(IncompatibleTag::Python) => {
let best = tag_policy.tags().python_tag();
let tags = self.python_tags().collect::<BTreeSet<_>>();
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::<BTreeSet<_>>();
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::<BTreeSet<_>>();
if tags.is_empty() {
None
} else {
Some(LockErrorHint::PlatformTags {
package: self.id.name.clone(),
version: self.id.version.clone(),
tags,
best,
})
}
}
_ => None,
}
} }
/// Convert the source of this [`Package`] to a [`SourceDist`] that can be used in installation. /// Convert the source of this [`Package`] to a [`SourceDist`] that can be used in installation.
@ -2729,43 +2670,6 @@ impl Package {
Ok(table) Ok(table)
} }
/// Returns an iterator over the compatible Python tags of the available wheels.
fn python_tags(&self) -> impl Iterator<Item = LanguageTag> + '_ {
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<Item = AbiTag> + '_ {
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<Item = &'a PlatformTag> + '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<usize> { fn find_best_wheel(&self, tag_policy: TagPolicy<'_>) -> Option<usize> {
type WheelPriority<'lock> = (TagPriority, Option<&'lock BuildTag>); type WheelPriority<'lock> = (TagPriority, Option<&'lock BuildTag>);
@ -4782,7 +4686,7 @@ fn normalize_requirement(
#[derive(Debug)] #[derive(Debug)]
pub struct LockError { pub struct LockError {
kind: Box<LockErrorKind>, kind: Box<LockErrorKind>,
hint: Option<LockErrorHint>, hint: Option<WheelTagHint>,
} }
impl std::error::Error for LockError { impl std::error::Error for LockError {
@ -4822,7 +4726,7 @@ where
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
#[allow(clippy::enum_variant_names)] #[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., /// 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`). /// `cp310` in `cp310-abi3-manylinux_2_17_x86_64.whl`).
LanguageTags { 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<WheelTagHint> {
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::<BTreeSet<_>>();
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::<BTreeSet<_>>();
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::<BTreeSet<_>>();
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<Item = &'a WheelFilename> + 'a,
) -> impl Iterator<Item = LanguageTag> + '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<Item = &'a WheelFilename> + 'a,
) -> impl Iterator<Item = AbiTag> + '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<Item = &'a WheelFilename> + 'a,
tags: &'a Tags,
) -> impl Iterator<Item = &'a PlatformTag> + '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 { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self { match self {
Self::LanguageTags { Self::LanguageTags {

View File

@ -5892,6 +5892,8 @@ fn pep_751_wheel_only() -> Result<()> {
----- stderr ----- ----- stderr -----
error: Package `torch` can't be installed because it doesn't have a source distribution or wheel for the current platform 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`
" "
); );