From 3d1fec2732b25d75a3309466d03e4faf18d16a84 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Tue, 22 Jul 2025 15:08:33 -0400 Subject: [PATCH] Add derivation chains for dependency errors (#14824) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 @ https://files.pythonhosted.org/packages/3d/68/9d4508e893976286d2ead7f8f571314af6c2037af34853a30fd769c02e9d/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. --- crates/uv-resolver/src/error.rs | 14 ++++- crates/uv-resolver/src/resolver/mod.rs | 72 +++++++++++++++++-------- crates/uv-resolver/src/resolver/urls.rs | 8 +-- crates/uv/src/commands/diagnostics.rs | 57 ++++++++++++++++++++ crates/uv/tests/it/branching_urls.rs | 40 +++++++------- crates/uv/tests/it/lock.rs | 22 ++++---- crates/uv/tests/it/pip_compile.rs | 13 ++--- 7 files changed, 164 insertions(+), 62 deletions(-) diff --git a/crates/uv-resolver/src/error.rs b/crates/uv-resolver/src/error.rs index 0916f54ac..0cc1f6847 100644 --- a/crates/uv-resolver/src/error.rs +++ b/crates/uv-resolver/src/error.rs @@ -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, + 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), diff --git a/crates/uv-resolver/src/resolver/mod.rs b/crates/uv-resolver/src/resolver/mod.rs index c30c4e947..fb4092099 100644 --- a/crates/uv-resolver/src/resolver/mod.rs +++ b/crates/uv-resolver/src/resolver/mod.rs @@ -635,19 +635,26 @@ impl ResolverState { // Enrich the state with any URLs, etc. - state.visit_package_version_dependencies( - next_id, - &version, - &self.urls, - &self.indexes, - &dependencies, - &self.git, - &self.workspace_members, - self.selector.resolution_strategy(), - )?; + state + .visit_package_version_dependencies( + next_id, + &version, + &self.urls, + &self.indexes, + &dependencies, + &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,19 +877,26 @@ impl ResolverState, } +/// Enrich a [`ResolveError`] with additional information about why a given package was included. +fn enrich_dependency_error( + error: ResolveError, + id: Id, + version: &Version, + pubgrub: &State, +) -> 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, state: &State) -> MarkerTree { let package = &state.package_store[id]; diff --git a/crates/uv-resolver/src/resolver/urls.rs b/crates/uv-resolver/src/resolver/urls.rs index 57803ed0b..eca87ef05 100644 --- a/crates/uv-resolver/src/resolver/urls.rs +++ b/crates/uv-resolver/src/resolver/urls.rs @@ -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 diff --git a/crates/uv/src/commands/diagnostics.rs b/crates/uv/src/commands/diagnostics.rs index f24aa3406..2ee04220a 100644 --- a/crates/uv/src/commands/diagnostics.rs +++ b/crates/uv/src/commands/diagnostics.rs @@ -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, + name: &PackageName, + version: &Version, + chain: &DerivationChain, + help: Option, +) { + #[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, + #[help] + help: Option, + } + + 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()); diff --git a/crates/uv/tests/it/branching_urls.rs b/crates/uv/tests/it/branching_urls.rs index a02ec0de3..aa6edd090 100644 --- a/crates/uv/tests/it/branching_urls.rs +++ b/crates/uv/tests/it/branching_urls.rs @@ -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.*'`: - - 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 - "### + × 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'`: - - 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 - "### + × 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.*'`: - - 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 - "### + × 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(()) diff --git a/crates/uv/tests/it/lock.rs b/crates/uv/tests/it/lock.rs index f4ccb7bf7..cdc246e9f 100644 --- a/crates/uv/tests/it/lock.rs +++ b/crates/uv/tests/it/lock.rs @@ -11088,13 +11088,14 @@ 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: - - file://[TEMP_DIR]/library - - file://[TEMP_DIR]/library (editable) + × 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) "); Ok(()) @@ -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: - - https://astral-sh.github.io/pytorch-mirror/whl/cu118 - - https://astral-sh.github.io/pytorch-mirror/whl/cu124 - "###); + × 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(()) } diff --git a/crates/uv/tests/it/pip_compile.rs b/crates/uv/tests/it/pip_compile.rs index 3a4dc28c4..8874949c2 100644 --- a/crates/uv/tests/it/pip_compile.rs +++ b/crates/uv/tests/it/pip_compile.rs @@ -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'`: - - 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 - "### + × 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(())