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(())