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