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`.
This commit is contained in:
konsti 2025-06-06 16:17:52 +02:00 committed by GitHub
parent 8a88ab2c70
commit 0109af1aa5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 220 additions and 4 deletions

View file

@ -206,6 +206,7 @@ mod resolver {
options,
&python_requirement,
markers,
interpreter.markers(),
conflicts,
Some(&TAGS),
&flat_index,

View file

@ -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),

View file

@ -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<Tags>,
workspace_members: BTreeSet<PackageName>,
options: Options,
@ -184,6 +187,7 @@ impl NoSolutionError {
fork_urls: ForkUrls,
fork_indexes: ForkIndexes,
env: ResolverEnvironment,
current_environment: MarkerEnvironment,
tags: Option<Tags>,
workspace_members: BTreeSet<PackageName>,
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(())
}
}

View file

@ -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<MarkerTree> {
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

View file

@ -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<InstalledPackages: InstalledPackagesProvider> {
dependency_mode: DependencyMode,
hasher: HashStrategy,
env: ResolverEnvironment,
// The environment of the current Python interpreter.
current_environment: MarkerEnvironment,
tags: Option<Tags>,
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<Provider: ResolverProvider, InstalledPackages: InstalledPackagesProvider>
options: Options,
hasher: &HashStrategy,
env: ResolverEnvironment,
current_environment: &MarkerEnvironment,
tags: Option<Tags>,
python_requirement: &PythonRequirement,
conflicts: Conflicts,
@ -234,6 +241,7 @@ impl<Provider: ResolverProvider, InstalledPackages: InstalledPackagesProvider>
hasher: hasher.clone(),
locations: locations.clone(),
env,
current_environment: current_environment.clone(),
tags,
python_requirement: python_requirement.clone(),
conflicts,
@ -354,6 +362,7 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
state.fork_urls,
state.fork_indexes,
state.env,
self.current_environment.clone(),
&visited,
));
}
@ -2504,6 +2513,7 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
fork_urls: ForkUrls,
fork_indexes: ForkIndexes,
env: ResolverEnvironment,
current_environment: MarkerEnvironment,
visited: &FxHashSet<PackageName>,
) -> ResolveError {
err = NoSolutionError::collapse_local_version_segments(NoSolutionError::collapse_proxies(
@ -2589,6 +2599,7 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
fork_urls,
fork_indexes,
env,
current_environment,
self.tags.clone(),
self.workspace_members.clone(),
self.options.clone(),

View file

@ -517,6 +517,7 @@ pub(crate) async fn pip_compile(
tags.as_deref(),
resolver_env.clone(),
python_requirement,
interpreter.markers(),
Conflicts::empty(),
&client,
&flat_index,

View file

@ -480,6 +480,7 @@ pub(crate) async fn pip_install(
Some(&tags),
ResolverEnvironment::specific(marker_env.clone()),
python_requirement,
interpreter.markers(),
Conflicts::empty(),
&client,
&flat_index,

View file

@ -29,6 +29,7 @@ use uv_fs::Simplified;
use uv_install_wheel::LinkMode;
use uv_installer::{Plan, Planner, Preparer, SitePackages};
use uv_normalize::{GroupName, PackageName};
use uv_pep508::MarkerEnvironment;
use uv_platform_tags::Tags;
use uv_pypi_types::{Conflicts, ResolverMarkerEnvironment};
use uv_python::{PythonEnvironment, PythonInstallation};
@ -119,6 +120,7 @@ pub(crate) async fn resolve<InstalledPackages: InstalledPackagesProvider>(
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<InstalledPackages: InstalledPackagesProvider>(
options,
&python_requirement,
resolver_env,
current_environment,
conflicts,
tags,
flat_index,

View file

@ -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,

View file

@ -822,6 +822,7 @@ async fn do_lock(
None,
resolver_env,
python_requirement,
interpreter.markers(),
conflicts.clone(),
&client,
&flat_index,

View file

@ -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,

View file

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

View file

@ -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`.
"
);

View file

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