Improve error messaging when a dependency is not found (#1241)

Previously, whenever we encountered a missing package we would throw an
error without information about why the package was requested. This
meant that if a transitive dependency required a missing package, the
user would have no idea why it was even selected. Here, we track
`NotFound` and `NoIndex` errors as `NoVersions` incompatibilities with
an attached reason. Improves our test coverage for `--no-index` without
`--find-links`.

The
[snapshots](https://github.com/astral-sh/puffin/pull/1241/files#diff-3eea1658f165476252f1f061d0aa9f915aabdceafac21611cdf45019447f60ec)
show a nice improvement.

I think this will also enable backtracking to another version if some
version of transitive dependency has a missing dependency. I'll write a
scenario for that next.

Requires https://github.com/zanieb/pubgrub/pull/22
This commit is contained in:
Zanie Blue 2024-02-05 08:43:05 -06:00 committed by GitHub
parent be9125b0f0
commit d090acf13d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 351 additions and 74 deletions

View file

@ -17,7 +17,7 @@ use puffin_normalize::PackageName;
use crate::candidate_selector::CandidateSelector;
use crate::pubgrub::{PubGrubPackage, PubGrubPython, PubGrubReportFormatter};
use crate::python_requirement::PythonRequirement;
use crate::version_map::VersionMap;
use crate::resolver::VersionsResponse;
#[derive(Debug, thiserror::Error)]
pub enum ResolveError {
@ -168,7 +168,7 @@ impl NoSolutionError {
mut self,
python_requirement: &PythonRequirement,
visited: &DashSet<PackageName>,
package_versions: &OnceMap<PackageName, VersionMap>,
package_versions: &OnceMap<PackageName, VersionsResponse>,
) -> Self {
let mut available_versions = IndexMap::default();
for package in self.derivation_tree.packages() {
@ -192,14 +192,16 @@ impl NoSolutionError {
// these packages, but it's non-deterministic, and omitting them ensures that
// we represent the state of the resolver at the time of failure.
if visited.contains(name) {
if let Some(version_map) = package_versions.get(name) {
available_versions.insert(
package.clone(),
version_map
.iter()
.map(|(version, _)| version.clone())
.collect(),
);
if let Some(response) = package_versions.get(name) {
if let VersionsResponse::Found(ref version_map) = *response {
available_versions.insert(
package.clone(),
version_map
.iter()
.map(|(version, _)| version.clone())
.collect(),
);
}
}
}
}

View file

@ -35,7 +35,7 @@ impl ReportFormatter<PubGrubPackage, Range<Version>> for PubGrubReportFormatter<
External::NotRoot(package, version) => {
format!("we are solving dependencies of {package} {version}")
}
External::NoVersions(package, set) => {
External::NoVersions(package, set, reason) => {
if matches!(package, PubGrubPackage::Python(_)) {
if let Some(python) = self.python_requirement {
if python.target() == python.installed() {
@ -75,6 +75,17 @@ impl ReportFormatter<PubGrubPackage, Range<Version>> for PubGrubReportFormatter<
);
}
let set = self.simplify_set(set, package);
// Check for a reason
if let Some(reason) = reason {
let formatted = if set.as_ref() == &Range::full() {
format!("{package} {reason}")
} else {
format!("{package}{set} {reason}")
};
return formatted;
}
if set.as_ref() == &Range::full() {
format!("there are no versions of {package}")
} else if set.as_singleton().is_some() {
@ -353,7 +364,7 @@ impl PubGrubReportFormatter<'_> {
let mut hints = IndexSet::default();
match derivation_tree {
DerivationTree::External(external) => match external {
External::NoVersions(package, set) => {
External::NoVersions(package, set, _) => {
if set.bounds().any(Version::any_prerelease) {
// A pre-release marker appeared in the version requirements.
if !allowed_prerelease(package, selector) {

View file

@ -8,6 +8,7 @@ use petgraph::Direction;
use pubgrub::range::Range;
use pubgrub::solver::{Kind, State};
use pubgrub::type_aliases::SelectedDependencies;
use rustc_hash::FxHashMap;
use url::Url;
@ -20,7 +21,8 @@ use pypi_types::{Hashes, Metadata21};
use crate::pins::FilePins;
use crate::pubgrub::{PubGrubDistribution, PubGrubPackage, PubGrubPriority};
use crate::version_map::VersionMap;
use crate::resolver::VersionsResponse;
use crate::ResolveError;
/// A complete resolution graph in which every node represents a pinned package and every edge
@ -42,7 +44,7 @@ impl ResolutionGraph {
pub(crate) fn from_state(
selection: &SelectedDependencies<PubGrubPackage, Version>,
pins: &FilePins,
packages: &OnceMap<PackageName, VersionMap>,
packages: &OnceMap<PackageName, VersionsResponse>,
distributions: &OnceMap<PackageId, Metadata21>,
redirects: &DashMap<Url, Url>,
state: &State<PubGrubPackage, Range<Version>, PubGrubPriority>,
@ -68,12 +70,14 @@ impl ResolutionGraph {
.clone();
// Add its hashes to the index.
if let Some(version_map) = packages.get(package_name) {
hashes.insert(package_name.clone(), {
let mut hashes = version_map.hashes(version);
hashes.sort_unstable();
hashes
});
if let Some(versions_response) = packages.get(package_name) {
if let VersionsResponse::Found(ref version_map) = *versions_response {
hashes.insert(package_name.clone(), {
let mut hashes = version_map.hashes(version);
hashes.sort_unstable();
hashes
});
}
}
// Add the distribution to the graph.
@ -93,12 +97,14 @@ impl ResolutionGraph {
};
// Add its hashes to the index.
if let Some(version_map) = packages.get(package_name) {
hashes.insert(package_name.clone(), {
let mut hashes = version_map.hashes(version);
hashes.sort_unstable();
hashes
});
if let Some(versions_response) = packages.get(package_name) {
if let VersionsResponse::Found(ref version_map) = *versions_response {
hashes.insert(package_name.clone(), {
let mut hashes = version_map.hashes(version);
hashes.sort_unstable();
hashes
});
}
}
// Add the distribution to the graph.

View file

@ -6,14 +6,14 @@ use once_map::OnceMap;
use puffin_normalize::PackageName;
use pypi_types::Metadata21;
use crate::version_map::VersionMap;
use super::provider::VersionsResponse;
/// In-memory index of package metadata.
#[derive(Default)]
pub struct InMemoryIndex {
/// A map from package name to the metadata for that package and the index where the metadata
/// came from.
pub(crate) packages: OnceMap<PackageName, VersionMap>,
pub(crate) packages: OnceMap<PackageName, VersionsResponse>,
/// A map from package ID to metadata for that distribution.
pub(crate) distributions: OnceMap<PackageId, Metadata21>,

View file

@ -47,9 +47,9 @@ use crate::resolver::allowed_urls::AllowedUrls;
pub use crate::resolver::index::InMemoryIndex;
use crate::resolver::provider::DefaultResolverProvider;
pub use crate::resolver::provider::ResolverProvider;
pub(crate) use crate::resolver::provider::VersionsResponse;
use crate::resolver::reporter::Facade;
pub use crate::resolver::reporter::{BuildId, Reporter};
use crate::version_map::VersionMap;
use crate::{DependencyMode, Options};
mod allowed_urls;
@ -57,6 +57,23 @@ mod index;
mod provider;
mod reporter;
/// The package version is unavailable and cannot be used
/// Unlike [`PackageUnavailable`] this applies to a single version of the package
#[derive(Debug, Clone)]
pub(crate) enum UnavailableVersion {
/// Version is incompatible due to the `Requires-Python` version specifiers for that package.
RequiresPython(VersionSpecifiers),
}
/// The package is unavailable and cannot be used
#[derive(Debug, Clone)]
pub(crate) enum UnavailablePackage {
/// The `--no-index` flag was passed and the package is not available locally
NoIndex,
/// The package was not found in the registry
NotFound,
}
pub struct Resolver<'a, Provider: ResolverProvider> {
project: Option<PackageName>,
requirements: Vec<Requirement>,
@ -68,8 +85,10 @@ pub struct Resolver<'a, Provider: ResolverProvider> {
python_requirement: PythonRequirement,
selector: CandidateSelector,
index: &'a InMemoryIndex,
/// A map from [`PackageId`] to the `Requires-Python` version specifiers for that package.
incompatibilities: DashMap<PackageId, VersionSpecifiers>,
/// Incompatibilities for specific package versions
unavailable_versions: DashMap<PackageId, UnavailableVersion>,
/// Incompatibilities for packages that are entirely unavailable
unavailable_packages: DashMap<PackageName, UnavailablePackage>,
/// The set of all registry-based packages visited during resolution.
visited: DashSet<PackageName>,
editables: FxHashMap<PackageName, (LocalEditable, Metadata21)>,
@ -170,7 +189,8 @@ impl<'a, Provider: ResolverProvider> Resolver<'a, Provider> {
Self {
index,
incompatibilities: DashMap::default(),
unavailable_versions: DashMap::default(),
unavailable_packages: DashMap::default(),
visited: DashSet::default(),
selector,
allowed_urls,
@ -314,7 +334,30 @@ impl<'a, Provider: ResolverProvider> Resolver<'a, Provider> {
.term_intersection_for_package(&next)
.expect("a package was chosen but we don't have a term.");
let inc = Incompatibility::no_versions(next.clone(), term_intersection.clone());
let reason = {
if let PubGrubPackage::Package(ref package_name, _, _) = next {
// Check if the decision was due to the package being unavailable
self.unavailable_packages
.get(package_name)
.map(|entry| match *entry {
UnavailablePackage::NoIndex => {
"was not found in the provided links"
}
UnavailablePackage::NotFound => {
"was not found in the package registry"
}
})
} else {
None
}
};
let inc = Incompatibility::no_versions(
next.clone(),
term_intersection.clone(),
reason.map(ToString::to_string),
);
state.add_incompatibility(inc);
continue;
}
@ -510,7 +553,7 @@ impl<'a, Provider: ResolverProvider> Resolver<'a, Provider> {
PubGrubPackage::Package(package_name, extra, None) => {
// Wait for the metadata to be available.
let version_map = self
let versions_response = self
.index
.packages
.wait(package_name)
@ -519,6 +562,23 @@ impl<'a, Provider: ResolverProvider> Resolver<'a, Provider> {
.ok_or(ResolveError::Unregistered)?;
self.visited.insert(package_name.clone());
let version_map = match *versions_response {
VersionsResponse::Found(ref version_map) => version_map,
// Short-circuit if we do not find any versions for the package
VersionsResponse::NoIndex => {
self.unavailable_packages
.insert(package_name.clone(), UnavailablePackage::NoIndex);
return Ok(None);
}
VersionsResponse::NotFound => {
self.unavailable_packages
.insert(package_name.clone(), UnavailablePackage::NotFound);
return Ok(None);
}
};
if let Some(extra) = extra {
debug!(
"Searching for a compatible version of {package_name}[{extra}] ({range})",
@ -528,16 +588,17 @@ impl<'a, Provider: ResolverProvider> Resolver<'a, Provider> {
}
// Find a compatible version.
let Some(candidate) = self.selector.select(package_name, range, &version_map)
else {
let Some(candidate) = self.selector.select(package_name, range, version_map) else {
// Short circuit: we couldn't find _any_ compatible versions for a package.
return Ok(None);
};
// If the version is incompatible, short-circuit.
if let Some(requires_python) = candidate.validate(&self.python_requirement) {
self.incompatibilities
.insert(candidate.package_id(), requires_python.clone());
self.unavailable_versions.insert(
candidate.package_id(),
UnavailableVersion::RequiresPython(requires_python.clone()),
);
return Ok(Some(candidate.version().clone()));
}
@ -655,17 +716,29 @@ impl<'a, Provider: ResolverProvider> Resolver<'a, Provider> {
return Ok(Dependencies::Available(DependencyConstraints::default()));
}
// Wait for the metadata to be available.
// Determine the distribution to lookup
let dist = match url {
Some(url) => PubGrubDistribution::from_url(package_name, url),
None => PubGrubDistribution::from_registry(package_name, version),
};
let package_id = dist.package_id();
// If the package does not exist in the registry, we cannot fetch its dependencies
if self.unavailable_packages.get(package_name).is_some() {
debug_assert!(
false,
"Dependencies were requested for a package that is not available"
);
return Ok(Dependencies::Unavailable(
"The package is unavailable".to_string(),
));
}
// If the package is known to be incompatible, return the Python version as an
// incompatibility, and skip fetching the metadata.
if let Some(entry) = self.incompatibilities.get(&package_id) {
let requires_python = entry;
if let Some(entry) = self.unavailable_versions.get(&package_id) {
// TODO(zanieb): Handle additional variants here
let UnavailableVersion::RequiresPython(requires_python) = entry.value();
let version = requires_python
.iter()
.map(PubGrubSpecifier::try_from)
@ -682,6 +755,7 @@ impl<'a, Provider: ResolverProvider> Resolver<'a, Provider> {
return Ok(Dependencies::Available(constraints));
}
// Wait for the metadata to be available.
let metadata = self
.index
.distributions
@ -779,13 +853,14 @@ impl<'a, Provider: ResolverProvider> Resolver<'a, Provider> {
match request {
// Fetch package metadata from the registry.
Request::Package(package_name) => {
let version_map = self
let package_versions = self
.provider
.get_version_map(&package_name)
.get_package_versions(&package_name)
.boxed()
.await
.map_err(ResolveError::Client)?;
Ok(Some(Response::Package(package_name, version_map)))
Ok(Some(Response::Package(package_name, package_versions)))
}
// Fetch distribution metadata from the distribution database.
@ -817,24 +892,43 @@ impl<'a, Provider: ResolverProvider> Resolver<'a, Provider> {
// Pre-fetch the package and distribution metadata.
Request::Prefetch(package_name, range) => {
// Wait for the package metadata to become available.
let version_map = self
let versions_response = self
.index
.packages
.wait(&package_name)
.await
.ok_or(ResolveError::Unregistered)?;
let version_map = match *versions_response {
VersionsResponse::Found(ref version_map) => version_map,
// Short-circuit if we did not find any versions for the package
VersionsResponse::NoIndex => {
self.unavailable_packages
.insert(package_name.clone(), UnavailablePackage::NoIndex);
return Ok(None);
}
VersionsResponse::NotFound => {
self.unavailable_packages
.insert(package_name.clone(), UnavailablePackage::NotFound);
return Ok(None);
}
};
// Try to find a compatible version. If there aren't any compatible versions,
// short-circuit and return `None`.
let Some(candidate) = self.selector.select(&package_name, &range, &version_map)
let Some(candidate) = self.selector.select(&package_name, &range, version_map)
else {
return Ok(None);
};
// If the version is incompatible, short-circuit.
if let Some(requires_python) = candidate.validate(&self.python_requirement) {
self.incompatibilities
.insert(candidate.package_id(), requires_python.clone());
self.unavailable_versions.insert(
candidate.package_id(),
UnavailableVersion::RequiresPython(requires_python.clone()),
);
return Ok(None);
}
@ -928,7 +1022,7 @@ impl Display for Request {
#[allow(clippy::large_enum_variant)]
enum Response {
/// The returned metadata for a package hosted on a registry.
Package(PackageName, VersionMap),
Package(PackageName, VersionsResponse),
/// The returned metadata for a distribution.
Dist {
dist: Dist,

View file

@ -18,15 +18,26 @@ use crate::python_requirement::PythonRequirement;
use crate::version_map::VersionMap;
use crate::yanks::AllowedYanks;
type VersionMapResponse = Result<VersionMap, puffin_client::Error>;
type WheelMetadataResponse = Result<(Metadata21, Option<Url>), puffin_distribution::Error>;
type PackageVersionsResult = Result<VersionsResponse, puffin_client::Error>;
type WheelMetadataResult = Result<(Metadata21, Option<Url>), puffin_distribution::Error>;
/// The response when requesting versions for a package
#[derive(Debug)]
pub enum VersionsResponse {
/// The package was found in the registry with the included versions
Found(VersionMap),
/// The package was not found in the registry
NotFound,
/// The package was not found in the local registry
NoIndex,
}
pub trait ResolverProvider: Send + Sync {
/// Get the version map for a package.
fn get_version_map<'io>(
fn get_package_versions<'io>(
&'io self,
package_name: &'io PackageName,
) -> impl Future<Output = VersionMapResponse> + Send + 'io;
) -> impl Future<Output = PackageVersionsResult> + Send + 'io;
/// Get the metadata for a distribution.
///
@ -36,7 +47,7 @@ pub trait ResolverProvider: Send + Sync {
fn get_or_build_wheel_metadata<'io>(
&'io self,
dist: &'io Dist,
) -> impl Future<Output = WheelMetadataResponse> + Send + 'io;
) -> impl Future<Output = WheelMetadataResult> + Send + 'io;
/// Set the [`puffin_distribution::Reporter`] to use for this installer.
#[must_use]
@ -104,7 +115,10 @@ impl<'a, Context: BuildContext + Send + Sync> ResolverProvider
for DefaultResolverProvider<'a, Context>
{
/// Make a simple api request for the package and convert the result to a [`VersionMap`].
async fn get_version_map<'io>(&'io self, package_name: &'io PackageName) -> VersionMapResponse {
async fn get_package_versions<'io>(
&'io self,
package_name: &'io PackageName,
) -> PackageVersionsResult {
let result = self.client.simple(package_name).await;
// If the simple api request was successful, perform on the slow conversion to `VersionMap` on the tokio
@ -114,7 +128,7 @@ impl<'a, Context: BuildContext + Send + Sync> ResolverProvider
let self_send = self.inner.clone();
let package_name_owned = package_name.clone();
Ok(tokio::task::spawn_blocking(move || {
VersionMap::from_metadata(
VersionsResponse::Found(VersionMap::from_metadata(
metadata,
&package_name_owned,
&index,
@ -124,18 +138,24 @@ impl<'a, Context: BuildContext + Send + Sync> ResolverProvider
self_send.exclude_newer.as_ref(),
self_send.flat_index.get(&package_name_owned).cloned(),
&self_send.no_binary,
)
))
})
.await
.expect("Tokio executor failed, was there a panic?"))
}
Err(err) => match err.into_kind() {
kind @ (puffin_client::ErrorKind::PackageNotFound(_)
| puffin_client::ErrorKind::NoIndex(_)) => {
puffin_client::ErrorKind::PackageNotFound(_) => {
if let Some(flat_index) = self.flat_index.get(package_name).cloned() {
Ok(VersionMap::from(flat_index))
Ok(VersionsResponse::Found(VersionMap::from(flat_index)))
} else {
Err(kind.into())
Ok(VersionsResponse::NotFound)
}
}
puffin_client::ErrorKind::NoIndex(_) => {
if let Some(flat_index) = self.flat_index.get(package_name).cloned() {
Ok(VersionsResponse::Found(VersionMap::from(flat_index)))
} else {
Ok(VersionsResponse::NoIndex)
}
}
kind => Err(kind.into()),
@ -143,7 +163,7 @@ impl<'a, Context: BuildContext + Send + Sync> ResolverProvider
}
}
async fn get_or_build_wheel_metadata<'io>(&'io self, dist: &'io Dist) -> WheelMetadataResponse {
async fn get_or_build_wheel_metadata<'io>(&'io self, dist: &'io Dist) -> WheelMetadataResult {
self.fetcher.get_or_build_wheel_metadata(dist).await
}