From 0109af1aa510bd4f0759b0d53d842c3716e5cc3f Mon Sep 17 00:00:00 2001 From: konsti Date: Fri, 6 Jun 2025 16:17:52 +0200 Subject: [PATCH] Hint at `tool.uv.environments` on resolution error (#13455) Users are not (yet) properly familiar with the concept of universal resolution and its implication that we need to resolve for all possible platforms and Python versions. Some projects only target a specific platform or Python version, and users experience resolution errors due to failures for other platforms. Indicated by the number of questions we get about it, `tool.uv.environments` for restricting environments is not well discoverable. We add a special hint when resolution failed on a fork disjoint with the current environment, hinting the user to constrain `requires-python` and `tool.uv.environments` respectively. The hint has false positives for cases where the resolution failed on a different platform, but equally fails on the current platform, in cases where the non-current fork was tried earlier. Given that conflicts can be based on `requires-python`, afaik we can't parse whether the current platform would also be affected from the derivation tree. Two cases not covered by this are build errors as well as install errors that need `tool.uv.required-environments`. --- crates/uv-bench/benches/uv.rs | 1 + crates/uv-dispatch/src/lib.rs | 1 + crates/uv-resolver/src/error.rs | 49 ++++++- .../uv-resolver/src/resolver/environment.rs | 8 ++ crates/uv-resolver/src/resolver/mod.rs | 13 +- crates/uv/src/commands/pip/compile.rs | 1 + crates/uv/src/commands/pip/install.rs | 1 + crates/uv/src/commands/pip/operations.rs | 3 + crates/uv/src/commands/pip/sync.rs | 1 + crates/uv/src/commands/project/lock.rs | 1 + crates/uv/src/commands/project/mod.rs | 2 + crates/uv/tests/it/lock.rs | 135 ++++++++++++++++++ crates/uv/tests/it/lock_scenarios.rs | 2 + crates/uv/tests/it/pip_compile.rs | 6 +- 14 files changed, 220 insertions(+), 4 deletions(-) diff --git a/crates/uv-bench/benches/uv.rs b/crates/uv-bench/benches/uv.rs index 03a360ad5..36838b6de 100644 --- a/crates/uv-bench/benches/uv.rs +++ b/crates/uv-bench/benches/uv.rs @@ -206,6 +206,7 @@ mod resolver { options, &python_requirement, markers, + interpreter.markers(), conflicts, Some(&TAGS), &flat_index, diff --git a/crates/uv-dispatch/src/lib.rs b/crates/uv-dispatch/src/lib.rs index 891ca8a38..3b0ad5555 100644 --- a/crates/uv-dispatch/src/lib.rs +++ b/crates/uv-dispatch/src/lib.rs @@ -226,6 +226,7 @@ impl BuildContext for BuildDispatch<'_> { .build(), &python_requirement, ResolverEnvironment::specific(marker_env), + self.interpreter.markers(), // Conflicting groups only make sense when doing universal resolution. Conflicts::empty(), Some(tags), diff --git a/crates/uv-resolver/src/error.rs b/crates/uv-resolver/src/error.rs index e3bb9cf23..adbdc3cc7 100644 --- a/crates/uv-resolver/src/error.rs +++ b/crates/uv-resolver/src/error.rs @@ -3,6 +3,7 @@ use std::fmt::Formatter; use std::sync::Arc; use indexmap::IndexSet; +use owo_colors::OwoColorize; use pubgrub::{ DefaultStringReporter, DerivationTree, Derived, External, Range, Ranges, Reporter, Term, }; @@ -13,7 +14,8 @@ use uv_distribution_types::{ DerivationChain, DistErrorKind, IndexCapabilities, IndexLocations, IndexUrl, RequestedDist, }; use uv_normalize::{ExtraName, InvalidNameError, PackageName}; -use uv_pep440::{LocalVersionSlice, LowerBound, Version}; +use uv_pep440::{LocalVersionSlice, LowerBound, Version, VersionSpecifier}; +use uv_pep508::{MarkerEnvironment, MarkerExpression, MarkerTree, MarkerValueVersion}; use uv_platform_tags::Tags; use uv_static::EnvVars; @@ -163,6 +165,7 @@ pub struct NoSolutionError { fork_urls: ForkUrls, fork_indexes: ForkIndexes, env: ResolverEnvironment, + current_environment: MarkerEnvironment, tags: Option, workspace_members: BTreeSet, options: Options, @@ -184,6 +187,7 @@ impl NoSolutionError { fork_urls: ForkUrls, fork_indexes: ForkIndexes, env: ResolverEnvironment, + current_environment: MarkerEnvironment, tags: Option, workspace_members: BTreeSet, options: Options, @@ -202,6 +206,7 @@ impl NoSolutionError { fork_urls, fork_indexes, env, + current_environment, tags, workspace_members, options, @@ -353,6 +358,44 @@ impl NoSolutionError { pub fn header(&self) -> NoSolutionHeader { NoSolutionHeader::new(self.env.clone()) } + + /// Hint at limiting the resolver environment if universal resolution failed for a target + /// that is not the current platform or not the current Python version. + fn hint_disjoint_targets(&self, f: &mut Formatter) -> std::fmt::Result { + // Only applicable to universal resolution. + let Some(markers) = self.env.fork_markers() else { + return Ok(()); + }; + + // TODO(konsti): This is a crude approximation to telling the user the difference + // between their Python version and the relevant Python version range from the marker. + let current_python_version = self.current_environment.python_version().version.clone(); + let current_python_marker = MarkerTree::expression(MarkerExpression::Version { + key: MarkerValueVersion::PythonVersion, + specifier: VersionSpecifier::equals_version(current_python_version.clone()), + }); + if markers.is_disjoint(current_python_marker) { + write!( + f, + "\n\n{}{} While the active Python version is {}, \ + the resolution failed for other Python versions supported by your \ + project. Consider limiting your project's supported Python versions \ + using `requires-python`.", + "hint".bold().cyan(), + ":".bold(), + current_python_version, + )?; + } else if !markers.evaluate(&self.current_environment, &[]) { + write!( + f, + "\n\n{}{} The resolution failed for an environment that is not the current one, \ + consider limiting the environments with `tool.uv.environments`.", + "hint".bold().cyan(), + ":".bold(), + )?; + } + Ok(()) + } } impl std::fmt::Debug for NoSolutionError { @@ -372,6 +415,7 @@ impl std::fmt::Debug for NoSolutionError { fork_urls, fork_indexes, env, + current_environment, tags, workspace_members, options, @@ -389,6 +433,7 @@ impl std::fmt::Debug for NoSolutionError { .field("fork_urls", fork_urls) .field("fork_indexes", fork_indexes) .field("env", env) + .field("current_environment", current_environment) .field("tags", tags) .field("workspace_members", workspace_members) .field("options", options) @@ -473,6 +518,8 @@ impl std::fmt::Display for NoSolutionError { write!(f, "\n\n{hint}")?; } + self.hint_disjoint_targets(f)?; + Ok(()) } } diff --git a/crates/uv-resolver/src/resolver/environment.rs b/crates/uv-resolver/src/resolver/environment.rs index 2a87bac47..354941886 100644 --- a/crates/uv-resolver/src/resolver/environment.rs +++ b/crates/uv-resolver/src/resolver/environment.rs @@ -198,6 +198,14 @@ impl ResolverEnvironment { crate::marker::requires_python(pep508_marker) } + /// For a universal resolution, return the markers of the current fork. + pub(crate) fn fork_markers(&self) -> Option { + match self.kind { + Kind::Specific { .. } => None, + Kind::Universal { markers, .. } => Some(markers), + } + } + /// Narrow this environment given the forking markers. /// /// This effectively intersects any markers in this environment with the diff --git a/crates/uv-resolver/src/resolver/mod.rs b/crates/uv-resolver/src/resolver/mod.rs index 0194611d4..43fcca24f 100644 --- a/crates/uv-resolver/src/resolver/mod.rs +++ b/crates/uv-resolver/src/resolver/mod.rs @@ -31,7 +31,9 @@ use uv_distribution_types::{ use uv_git::GitResolver; use uv_normalize::{ExtraName, GroupName, PackageName}; use uv_pep440::{MIN_VERSION, Version, VersionSpecifiers, release_specifiers_to_ranges}; -use uv_pep508::{MarkerExpression, MarkerOperator, MarkerTree, MarkerValueString}; +use uv_pep508::{ + MarkerEnvironment, MarkerExpression, MarkerOperator, MarkerTree, MarkerValueString, +}; use uv_platform_tags::Tags; use uv_pypi_types::{ConflictItem, ConflictItemRef, Conflicts, VerbatimParsedUrl}; use uv_types::{BuildContext, HashStrategy, InstalledPackagesProvider}; @@ -115,6 +117,8 @@ struct ResolverState { dependency_mode: DependencyMode, hasher: HashStrategy, env: ResolverEnvironment, + // The environment of the current Python interpreter. + current_environment: MarkerEnvironment, tags: Option, python_requirement: PythonRequirement, conflicts: Conflicts, @@ -158,6 +162,7 @@ impl<'a, Context: BuildContext, InstalledPackages: InstalledPackagesProvider> options: Options, python_requirement: &'a PythonRequirement, env: ResolverEnvironment, + current_environment: &MarkerEnvironment, conflicts: Conflicts, tags: Option<&'a Tags>, flat_index: &'a FlatIndex, @@ -184,6 +189,7 @@ impl<'a, Context: BuildContext, InstalledPackages: InstalledPackagesProvider> options, hasher, env, + current_environment, tags.cloned(), python_requirement, conflicts, @@ -206,6 +212,7 @@ impl options: Options, hasher: &HashStrategy, env: ResolverEnvironment, + current_environment: &MarkerEnvironment, tags: Option, python_requirement: &PythonRequirement, conflicts: Conflicts, @@ -234,6 +241,7 @@ impl hasher: hasher.clone(), locations: locations.clone(), env, + current_environment: current_environment.clone(), tags, python_requirement: python_requirement.clone(), conflicts, @@ -354,6 +362,7 @@ impl ResolverState ResolverState, ) -> ResolveError { err = NoSolutionError::collapse_local_version_segments(NoSolutionError::collapse_proxies( @@ -2589,6 +2599,7 @@ impl ResolverState( tags: Option<&Tags>, resolver_env: ResolverEnvironment, python_requirement: PythonRequirement, + current_environment: &MarkerEnvironment, conflicts: Conflicts, client: &RegistryClient, flat_index: &FlatIndex, @@ -303,6 +305,7 @@ pub(crate) async fn resolve( options, &python_requirement, resolver_env, + current_environment, conflicts, tags, flat_index, diff --git a/crates/uv/src/commands/pip/sync.rs b/crates/uv/src/commands/pip/sync.rs index 7831eee04..35cef5907 100644 --- a/crates/uv/src/commands/pip/sync.rs +++ b/crates/uv/src/commands/pip/sync.rs @@ -416,6 +416,7 @@ pub(crate) async fn pip_sync( Some(&tags), ResolverEnvironment::specific(marker_env.clone()), python_requirement, + interpreter.markers(), Conflicts::empty(), &client, &flat_index, diff --git a/crates/uv/src/commands/project/lock.rs b/crates/uv/src/commands/project/lock.rs index 93b51a60a..89b3713cc 100644 --- a/crates/uv/src/commands/project/lock.rs +++ b/crates/uv/src/commands/project/lock.rs @@ -822,6 +822,7 @@ async fn do_lock( None, resolver_env, python_requirement, + interpreter.markers(), conflicts.clone(), &client, &flat_index, diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index 2569b962f..d2efc3ccd 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -1924,6 +1924,7 @@ pub(crate) async fn resolve_environment( Some(tags), ResolverEnvironment::specific(marker_env), python_requirement, + interpreter.markers(), Conflicts::empty(), &client, &flat_index, @@ -2296,6 +2297,7 @@ pub(crate) async fn update_environment( Some(tags), ResolverEnvironment::specific(marker_env.clone()), python_requirement, + venv.interpreter().markers(), Conflicts::empty(), &client, &flat_index, diff --git a/crates/uv/tests/it/lock.rs b/crates/uv/tests/it/lock.rs index 07d59d338..a1fa1721e 100644 --- a/crates/uv/tests/it/lock.rs +++ b/crates/uv/tests/it/lock.rs @@ -3688,6 +3688,8 @@ fn lock_requires_python() -> Result<()> { hint: Pre-releases are available for `pygls` in the requested range (e.g., 2.0.0a4), but pre-releases weren't enabled (try: `--prerelease=allow`) hint: The `requires-python` value (>=3.7) includes Python versions that are not supported by your dependencies (e.g., pygls>=1.1.0,<=1.2.1 only supports >=3.7.9, <4). Consider using a more restrictive `requires-python` value (like >=3.7.9, <4). + + hint: While the active Python version is 3.12, the resolution failed for other Python versions supported by your project. Consider limiting your project's supported Python versions using `requires-python`. "); // Require >=3.7, and allow locking to a version of `pygls` that is compatible (==1.0.1). @@ -27393,3 +27395,136 @@ fn lock_omit_wheels_exclude_newer() -> Result<()> { Ok(()) } + +/// Check that we hint if the resolution failed in a different Python version. +#[test] +fn lock_conflict_for_disjoint_python_version() -> Result<()> { + let context = TestContext::new("3.9"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "foo" + version = "0.1.0" + requires-python = ">=3.9" + dependencies = [ + "numpy==1.20.3", + "pandas==1.5.3", + ] + "#, + )?; + + uv_snapshot!(context.filters(), context.lock(), @r" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + × No solution found when resolving dependencies for split (python_full_version >= '3.11'): + ╰─▶ Because only numpy{python_full_version >= '3.10'}<=1.26.4 is available and pandas==1.5.3 depends on numpy{python_full_version >= '3.10'}>=1.21.0, we can conclude that pandas==1.5.3 depends on numpy>=1.21.0,<=1.26.4. + And because your project depends on numpy==1.20.3 and pandas==1.5.3, we can conclude that your project's requirements are unsatisfiable. + + hint: Pre-releases are available for `numpy` in the requested range (e.g., 2.3.0rc1), but pre-releases weren't enabled (try: `--prerelease=allow`) + + hint: While the active Python version is 3.9, the resolution failed for other Python versions supported by your project. Consider limiting your project's supported Python versions using `requires-python`. + "); + + // Check that the resolution passes on the restricted Python environment. + pyproject_toml.write_str( + r#" + [project] + name = "foo" + version = "0.1.0" + requires-python = "==3.9.*" + dependencies = [ + "numpy==1.20.3", + "pandas==1.5.3", + ] + "#, + )?; + + uv_snapshot!(context.filters(), context.lock(), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 6 packages in [TIME] + "); + + Ok(()) +} + +/// Check that we hint if the resolution failed for a different platform. +#[test] +fn lock_conflict_for_disjoint_platform() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "foo" + version = "0.1.0" + requires-python = ">=3.9" + dependencies = [ + "numpy>=1.24,<1.26; sys_platform == 'exotic'", + "numpy>=1.26", + ] + "#, + )?; + + uv_snapshot!(context.filters(), context.lock(), @r" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + × No solution found when resolving dependencies for split (sys_platform == 'exotic'): + ╰─▶ Because only the following versions of numpy{sys_platform == 'exotic'} are available: + numpy{sys_platform == 'exotic'}<=1.24.0 + numpy{sys_platform == 'exotic'}==1.24.1 + numpy{sys_platform == 'exotic'}==1.24.2 + numpy{sys_platform == 'exotic'}==1.24.3 + numpy{sys_platform == 'exotic'}==1.24.4 + numpy{sys_platform == 'exotic'}==1.25.0 + numpy{sys_platform == 'exotic'}==1.25.1 + numpy{sys_platform == 'exotic'}==1.25.2 + numpy{sys_platform == 'exotic'}>1.26 + and your project depends on numpy{sys_platform == 'exotic'}>=1.24,<1.26, we can conclude that your project depends on numpy>=1.24.0,<=1.25.2. + And because your project depends on numpy>=1.26, we can conclude that your project's requirements are unsatisfiable. + + hint: The resolution failed for an environment that is not the current one, consider limiting the environments with `tool.uv.environments`. + "); + + // Check that the resolution passes on the restricted environment. + pyproject_toml.write_str( + r#" + [project] + name = "foo" + version = "0.1.0" + requires-python = ">=3.9" + dependencies = [ + "numpy>=1.24,<1.26; sys_platform == 'exotic'", + "numpy>=1.26", + ] + + [tool.uv] + environments = [ + "sys_platform == 'linux' or sys_platform == 'win32' or sys_platform == 'darwin'" + ] + "#, + )?; + + uv_snapshot!(context.filters(), context.lock(), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 2 packages in [TIME] + "); + + Ok(()) +} diff --git a/crates/uv/tests/it/lock_scenarios.rs b/crates/uv/tests/it/lock_scenarios.rs index bc49fa728..3be986ad1 100644 --- a/crates/uv/tests/it/lock_scenarios.rs +++ b/crates/uv/tests/it/lock_scenarios.rs @@ -826,6 +826,8 @@ fn conflict_in_fork() -> Result<()> { package-a{sys_platform == 'os2'}==1.0.0 package-a{sys_platform == 'os2'}>2 and your project depends on package-a{sys_platform == 'os2'}<2, we can conclude that your project's requirements are unsatisfiable. + + hint: The resolution failed for an environment that is not the current one, consider limiting the environments with `tool.uv.environments`. " ); diff --git a/crates/uv/tests/it/pip_compile.rs b/crates/uv/tests/it/pip_compile.rs index b63a8b37c..17f336dc6 100644 --- a/crates/uv/tests/it/pip_compile.rs +++ b/crates/uv/tests/it/pip_compile.rs @@ -14497,7 +14497,7 @@ fn unsupported_requires_python_dynamic_metadata() -> Result<()> { uv_snapshot!(context.filters(), context .pip_compile() .arg("--universal") - .arg("requirements.in"), @r###" + .arg("requirements.in"), @r" success: false exit_code: 1 ----- stdout ----- @@ -14507,7 +14507,9 @@ fn unsupported_requires_python_dynamic_metadata() -> Result<()> { ╰─▶ Because source-distribution==0.0.3 requires Python >=3.10 and you require source-distribution{python_full_version >= '3.10'}==0.0.3, we can conclude that your requirements are unsatisfiable. hint: The source distribution for `source-distribution` (v0.0.3) does not include static metadata. Generating metadata for this package requires Python >=3.10, but Python 3.8.[X] is installed. - "###); + + hint: While the active Python version is 3.8, the resolution failed for other Python versions supported by your project. Consider limiting your project's supported Python versions using `requires-python`. + "); Ok(()) }