Add derivation chains for dependency errors (#14824)

## Summary

This PR adds derivation chain for another class of resolver failures.
For example, if we encounter a transitive URL dependency, we now tell
the user which package included it, and the full derivation chain:

```
  × Failed to resolve dependencies for `foo` (v0.1.0)
  ╰─▶ Package `flask` was included as a URL dependency. URL dependencies must be
      expressed as direct requirements or constraints. Consider adding `flask @
      9d4508e893f34853a30fd769c02e9d/flask-3.1.1-py3-none-any.whl`
      to your dependencies or constraints file.
  help: `foo` (v0.1.0) was included because `baz` (v0.1.0) depends on `foo`
```

Closes #14795.
This commit is contained in:
Charlie Marsh 2025-07-22 15:08:33 -04:00 committed by GitHub
parent 076677da20
commit 3d1fec2732
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 164 additions and 62 deletions

View File

@ -37,6 +37,14 @@ use crate::{InMemoryIndex, Options};
#[derive(Debug, thiserror::Error)]
pub enum ResolveError {
#[error("Failed to resolve dependencies for package `{1}=={2}`")]
Dependencies(
#[source] Box<ResolveError>,
PackageName,
Version,
DerivationChain,
),
#[error(transparent)]
Client(#[from] uv_client::Error),
@ -92,9 +100,11 @@ pub enum ResolveError {
ConflictingIndexes(PackageName, String, String),
#[error(
"Package `{0}` attempted to resolve via URL: {1}. URL dependencies must be expressed as direct requirements or constraints. Consider adding `{0} @ {1}` to your dependencies or constraints file."
"Package `{name}` was included as a URL dependency. URL dependencies must be expressed as direct requirements or constraints. Consider adding `{requirement}` to your dependencies or constraints file.",
name = name.cyan(),
requirement = format!("{name} @ {url}").cyan(),
)]
DisallowedUrl(PackageName, String),
DisallowedUrl { name: PackageName, url: String },
#[error(transparent)]
DistributionType(#[from] uv_distribution_types::Error),

View File

@ -635,7 +635,8 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
}
ForkedDependencies::Unforked(dependencies) => {
// Enrich the state with any URLs, etc.
state.visit_package_version_dependencies(
state
.visit_package_version_dependencies(
next_id,
&version,
&self.urls,
@ -644,10 +645,16 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
&self.git,
&self.workspace_members,
self.selector.resolution_strategy(),
)?;
)
.map_err(|err| {
enrich_dependency_error(err, next_id, &version, &state.pubgrub)
})?;
// Emit a request to fetch the metadata for each registry package.
self.visit_dependencies(&dependencies, &state, &request_sink)?;
self.visit_dependencies(&dependencies, &state, &request_sink)
.map_err(|err| {
enrich_dependency_error(err, next_id, &version, &state.pubgrub)
})?;
// Add the dependencies to the state.
state.add_package_version_dependencies(next_id, &version, dependencies);
@ -870,7 +877,8 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
})
.map(move |(fork, mut forked_state)| {
// Enrich the state with any URLs, etc.
forked_state.visit_package_version_dependencies(
forked_state
.visit_package_version_dependencies(
package,
version,
&self.urls,
@ -879,10 +887,16 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
&self.git,
&self.workspace_members,
self.selector.resolution_strategy(),
)?;
)
.map_err(|err| {
enrich_dependency_error(err, package, version, &forked_state.pubgrub)
})?;
// Emit a request to fetch the metadata for each registry package.
self.visit_dependencies(&fork.dependencies, &forked_state, request_sink)?;
self.visit_dependencies(&fork.dependencies, &forked_state, request_sink)
.map_err(|err| {
enrich_dependency_error(err, package, version, &forked_state.pubgrub)
})?;
// Add the dependencies to the state.
forked_state.add_package_version_dependencies(package, version, fork.dependencies);
@ -3836,6 +3850,20 @@ pub(crate) struct VersionFork {
version: Option<Version>,
}
/// Enrich a [`ResolveError`] with additional information about why a given package was included.
fn enrich_dependency_error(
error: ResolveError,
id: Id<PubGrubPackage>,
version: &Version,
pubgrub: &State<UvDependencyProvider>,
) -> ResolveError {
let Some(name) = pubgrub.package_store[id].name_no_root() else {
return error;
};
let chain = DerivationChainBuilder::from_state(id, version, pubgrub).unwrap_or_default();
ResolveError::Dependencies(Box::new(error), name.clone(), version.clone(), chain)
}
/// Compute the set of markers for which a package is known to be relevant.
fn find_environments(id: Id<PubGrubPackage>, state: &State<UvDependencyProvider>) -> MarkerTree {
let package = &state.package_store[id];

View File

@ -155,10 +155,10 @@ impl Urls {
parsed_url: &'a ParsedUrl,
) -> Result<&'a VerbatimParsedUrl, ResolveError> {
let Some(expected) = self.get_regular(package_name) else {
return Err(ResolveError::DisallowedUrl(
package_name.clone(),
verbatim_url.to_string(),
));
return Err(ResolveError::DisallowedUrl {
name: package_name.clone(),
url: verbatim_url.to_string(),
});
};
let matching_urls: Vec<_> = expected

View File

@ -92,6 +92,15 @@ impl OperationDiagnostic {
requested_dist_error(kind, dist, &chain, err, self.hint);
None
}
pip::operations::Error::Resolve(uv_resolver::ResolveError::Dependencies(
error,
name,
version,
chain,
)) => {
dependencies_error(error, &name, &version, &chain, self.hint.clone());
None
}
pip::operations::Error::Requirements(uv_requirements::Error::Dist(kind, dist, err)) => {
dist_error(
kind,
@ -232,6 +241,54 @@ pub(crate) fn requested_dist_error(
anstream::eprint!("{report:?}");
}
/// Render an error in fetching a package's dependencies.
pub(crate) fn dependencies_error(
error: Box<uv_resolver::ResolveError>,
name: &PackageName,
version: &Version,
chain: &DerivationChain,
help: Option<String>,
) {
#[derive(Debug, miette::Diagnostic, thiserror::Error)]
#[error("Failed to resolve dependencies for `{}` ({})", name.cyan(), format!("v{version}").cyan())]
#[diagnostic()]
struct Diagnostic {
name: PackageName,
version: Version,
#[source]
cause: Box<uv_resolver::ResolveError>,
#[help]
help: Option<String>,
}
let help = help.or_else(|| {
SUGGESTIONS
.get(name)
.map(|suggestion| {
format!(
"`{}` is often confused for `{}` Did you mean to install `{}` instead?",
name.cyan(),
suggestion.cyan(),
suggestion.cyan(),
)
})
.or_else(|| {
if chain.is_empty() {
None
} else {
Some(format_chain(name, Some(version), chain))
}
})
});
let report = miette::Report::new(Diagnostic {
name: name.clone(),
version: version.clone(),
cause: error,
help,
});
anstream::eprint!("{report:?}");
}
/// Render a [`uv_resolver::NoSolutionError`].
pub(crate) fn no_solution(err: &uv_resolver::NoSolutionError) {
let report = miette::Report::msg(format!("{err}")).context(err.header());

View File

@ -61,16 +61,17 @@ fn branching_urls_overlapping() -> Result<()> {
"# };
make_project(context.temp_dir.path(), "a", deps)?;
uv_snapshot!(context.filters(), context.lock().current_dir(&context.temp_dir), @r###"
uv_snapshot!(context.filters(), context.lock().current_dir(&context.temp_dir), @r"
success: false
exit_code: 2
exit_code: 1
----- stdout -----
----- stderr -----
error: Requirements contain conflicting URLs for package `iniconfig` in split `python_full_version == '3.11.*'`:
× Failed to resolve dependencies for `a` (v0.1.0)
Requirements contain conflicting URLs for package `iniconfig` in split `python_full_version == '3.11.*'`:
- https://files.pythonhosted.org/packages/9b/dd/b3c12c6d707058fa947864b67f0c4e0c39ef8610988d7baea9578f3c48f3/iniconfig-1.1.1-py2.py3-none-any.whl
- https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl
"###
"
);
Ok(())
@ -128,16 +129,18 @@ fn root_package_splits_but_transitive_conflict() -> Result<()> {
"# };
make_project(&context.temp_dir.path().join("b2"), "b2", deps)?;
uv_snapshot!(context.filters(), context.lock().current_dir(&context.temp_dir), @r###"
uv_snapshot!(context.filters(), context.lock().current_dir(&context.temp_dir), @r"
success: false
exit_code: 2
exit_code: 1
----- stdout -----
----- stderr -----
error: Requirements contain conflicting URLs for package `iniconfig` in split `python_full_version >= '3.12'`:
× Failed to resolve dependencies for `b2` (v0.1.0)
Requirements contain conflicting URLs for package `iniconfig` in split `python_full_version >= '3.12'`:
- https://files.pythonhosted.org/packages/9b/dd/b3c12c6d707058fa947864b67f0c4e0c39ef8610988d7baea9578f3c48f3/iniconfig-1.1.1-py2.py3-none-any.whl
- https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl
"###
help: `b2` (v0.1.0) was included because `a` (v0.1.0) depends on `b` (v0.1.0) which depends on `b2`
"
);
Ok(())
@ -727,16 +730,17 @@ fn branching_urls_of_different_sources_conflict() -> Result<()> {
"# };
make_project(context.temp_dir.path(), "a", deps)?;
uv_snapshot!(context.filters(), context.lock().current_dir(&context.temp_dir), @r###"
uv_snapshot!(context.filters(), context.lock().current_dir(&context.temp_dir), @r"
success: false
exit_code: 2
exit_code: 1
----- stdout -----
----- stderr -----
error: Requirements contain conflicting URLs for package `iniconfig` in split `python_full_version == '3.11.*'`:
× Failed to resolve dependencies for `a` (v0.1.0)
Requirements contain conflicting URLs for package `iniconfig` in split `python_full_version == '3.11.*'`:
- git+https://github.com/pytest-dev/iniconfig@93f5930e668c0d1ddf4597e38dd0dea4e2665e7a
- https://files.pythonhosted.org/packages/9b/dd/b3c12c6d707058fa947864b67f0c4e0c39ef8610988d7baea9578f3c48f3/iniconfig-1.1.1-py2.py3-none-any.whl
"###
"
);
Ok(())

View File

@ -11088,11 +11088,12 @@ fn lock_editable() -> Result<()> {
uv_snapshot!(context.filters(), context.lock(), @r"
success: false
exit_code: 2
exit_code: 1
----- stdout -----
----- stderr -----
error: Requirements contain conflicting URLs for package `library` in all marker environments:
× Failed to resolve dependencies for `workspace` (v0.1.0)
Requirements contain conflicting URLs for package `library` in all marker environments:
- file://[TEMP_DIR]/library
- file://[TEMP_DIR]/library (editable)
");
@ -20728,16 +20729,17 @@ fn lock_multiple_sources_index_overlapping_extras() -> Result<()> {
"#,
)?;
uv_snapshot!(context.filters(), context.lock(), @r###"
uv_snapshot!(context.filters(), context.lock(), @r"
success: false
exit_code: 2
exit_code: 1
----- stdout -----
----- stderr -----
error: Requirements contain conflicting indexes for package `jinja2` in all marker environments:
× Failed to resolve dependencies for `project` (v0.1.0)
Requirements contain conflicting indexes for package `jinja2` in all marker environments:
- https://astral-sh.github.io/pytorch-mirror/whl/cu118
- https://astral-sh.github.io/pytorch-mirror/whl/cu124
"###);
");
Ok(())
}

View File

@ -14860,16 +14860,17 @@ fn universal_conflicting_override_urls() -> Result<()> {
.arg("requirements.in")
.arg("--overrides")
.arg("overrides.txt")
.arg("--universal"), @r###"
.arg("--universal"), @r"
success: false
exit_code: 2
exit_code: 1
----- stdout -----
----- stderr -----
error: Requirements contain conflicting URLs for package `sniffio` in split `sys_platform == 'win32'`:
× Failed to resolve dependencies for `anyio` (v4.3.0)
Requirements contain conflicting URLs for package `sniffio` in split `sys_platform == 'win32'`:
- https://files.pythonhosted.org/packages/c3/a0/5dba8ed157b0136607c7f2151db695885606968d1fae123dc3391e0cfdbf/sniffio-1.3.0-py3-none-any.whl
- https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl
"###
"
);
Ok(())