diff --git a/src/lock_file/resolve/pypi.rs b/src/lock_file/resolve/pypi.rs index 690b6657db..4f2732cee9 100644 --- a/src/lock_file/resolve/pypi.rs +++ b/src/lock_file/resolve/pypi.rs @@ -1,6 +1,6 @@ use std::{ cell::RefCell, - collections::HashMap, + collections::{HashMap, HashSet}, iter::once, ops::Deref, path::{Path, PathBuf}, @@ -46,7 +46,7 @@ use uv_pypi_types::{Conflicts, HashAlgorithm, HashDigests}; use uv_requirements::LookaheadResolver; use uv_resolver::{ AllowedYanks, DefaultResolverProvider, FlatIndex, InMemoryIndex, Manifest, Options, Preference, - PreferenceError, Preferences, PythonRequirement, Resolver, ResolverEnvironment, + PreferenceError, Preferences, PythonRequirement, ResolveError, Resolver, ResolverEnvironment, }; use uv_types::EmptyInstalledPackages; @@ -202,6 +202,60 @@ fn print_overridden_requests(package_requests: &HashMap, + #[help] + advice: Option, + }, + #[error("failed to resolve pypi dependencies")] + Other(#[from] ResolveError), +} + +/// Creates a custom `SolveError` from a `ResolveError`. +/// to add some extra information about locked conda packages +fn create_solve_error( + error: ResolveError, + conda_python_packages: &CondaPythonPackages, +) -> SolveError { + match error { + ResolveError::NoSolution(no_solution) => { + let packages: HashSet<_> = no_solution.packages().collect(); + let conflicting_packages: Vec = conda_python_packages + .iter() + .filter_map(|(pypi_name, (_, pypi_identifier))| { + if packages.contains(pypi_name) { + Some(format!( + "{}=={}", + pypi_identifier.name.as_source(), + pypi_identifier.version + )) + } else { + None + } + }) + .collect(); + + let advice = if conflicting_packages.is_empty() { + None + } else { + Some(format!( + "The following PyPI packages have been pinned by the conda solve, and this version may be causing a conflict:\n{}", + conflicting_packages.join("\n") + )) + }; + + SolveError::NoSolution { + source: no_solution, + advice, + } + } + _ => SolveError::Other(error), + } +} + #[allow(clippy::too_many_arguments)] pub async fn resolve_pypi( context: UvResolutionContext, @@ -574,8 +628,7 @@ pub async fn resolve_pypi( )) .resolve() .await - .into_diagnostic() - .context("failed to resolve pypi dependencies")?; + .map_err(|e| create_solve_error(e, &conda_python_packages))?; let resolution = Resolution::from(resolution); // Print the overridden package requests diff --git a/tests/integration_rust/pypi_tests.rs b/tests/integration_rust/pypi_tests.rs index 27e94578e1..f790fcf11a 100644 --- a/tests/integration_rust/pypi_tests.rs +++ b/tests/integration_rust/pypi_tests.rs @@ -423,3 +423,36 @@ async fn test_cross_platform_resolve_with_no_build() { .join("foo-1.0.0-py2.py3-none-any.whl") ); } + +/// This test checks that the help message is correctly generated when a PyPI package is pinned +/// by the conda solve, which may cause a conflict with the PyPI dependencies. +/// +/// We expect there to be a help message that informs the user about the pinned package +#[tokio::test] +#[cfg_attr(not(feature = "slow_integration_tests"), ignore)] +async fn test_pinned_help_message() { + let pixi = PixiControl::from_manifest( + r#" + [workspace] + channels = ["https://prefix.dev/conda-forge"] + name = "deleteme" + platforms = ["linux-64"] + version = "0.1.0" + + [dependencies] + python = "3.12.*" + pandas = "*" + + [pypi-dependencies] + databricks-sql-connector = ">=4.0.0" + "#, + ); + // First, it should fail + let result = pixi.unwrap().update_lock_file().await; + let err = result.err().unwrap(); + // Second, it should contain a help message + assert_eq!( + format!("{}", err.help().unwrap()), + "The following PyPI packages have been pinned by the conda solve, and this version may be causing a conflict:\npandas==2.3.1" + ); +}