Add incompatibility from proxy to base package (#15200)

Add an incompatibility that lets pubgrub skip of marker packages when
the base package already has an incompatible version to improve the
error messages (https://github.com/astral-sh/uv/issues/15199).

The change is also a small perf improvement. Overall this should be able
to improve performance in slow cases by avoiding trying proxy package
versions that are impossible anyway, for a (ideally very small cost) for
tracking the additional incompatibility and tracking the base package
for each proxy package.

```
$ hhyperfine --warmup 2 "uv pip compile --universal scripts/requirements/airflow.in" "target/release/uv pip compile --universal scripts/requirements/airflow.in"
Benchmark 1: uv pip compile --universal scripts/requirements/airflow.in
  Time (mean ± σ):     145.5 ms ±   3.9 ms    [User: 154.7 ms, System: 140.7 ms]
  Range (min … max):   139.2 ms … 153.4 ms    20 runs
 
Benchmark 2: target/release/uv pip compile --universal scripts/requirements/airflow.in
  Time (mean ± σ):     128.7 ms ±   5.5 ms    [User: 141.9 ms, System: 137.3 ms]
  Range (min … max):   121.8 ms … 142.0 ms    23 runs
 
Summary
  target/release/uv pip compile --universal scripts/requirements/airflow.in ran
    1.13 ± 0.06 times faster than uv pip compile --universal scripts/requirements/airflow.in
```

This implementation is the basic version: When we see a proxy
`foo{...}>=x,<y` we add a dependency edge `foo{...}>=x,<y` ->
`foo>=x,<y`. There are several way to extend this, which likely help
more with performance than with error messages.

One idea is that if we see `foo{...}>=x,<y` but we already made a
selection for `foo==z` outside that range, we can insert a dependency
`foo{...}!=z` -> `foo!=z`. This avoids trying any version of the proxy
package except the version that matches our previous selection.

Another is that if we see a dependency `foo>=x,<y`, we also add
`foo{...}>=x,y` -> `foo>=x,<y`. This allows backtracking beyond `foo`
immediately if all version of `foo{...}>=x,<y` are incompatible, since
`foo{...}>=x,<y` incompatible -> `foo>=x,<y` incompatible -> the package
that depended of `foo>=x,<y` is incompatible.

The cost for each of these operations is tracking an additional
incompatibility per virtual package. An alternative approach is to only
add the incompatibility lazily, only when we've tried several version of
the virtual package already. This needs to be weighed of with the better
error messages that the incompatibility gives, we unfortunately have
only few large reference examples.

Requires https://github.com/astral-sh/pubgrub/pull/45

Closes https://github.com/astral-sh/uv/issues/15199
This commit is contained in:
konsti 2025-09-22 13:26:08 +02:00 committed by GitHub
parent 1d7d7fdf00
commit a6daab422f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 99 additions and 61 deletions

4
Cargo.lock generated
View File

@ -3109,7 +3109,7 @@ dependencies = [
[[package]]
name = "pubgrub"
version = "0.3.0"
source = "git+https://github.com/astral-sh/pubgrub?rev=06ec5a5f59ffaeb6cf5079c6cb184467da06c9db#06ec5a5f59ffaeb6cf5079c6cb184467da06c9db"
source = "git+https://github.com/astral-sh/pubgrub?rev=d8efd77673c9a90792da9da31b6c0da7ea8a324b#d8efd77673c9a90792da9da31b6c0da7ea8a324b"
dependencies = [
"indexmap",
"log",
@ -6614,7 +6614,7 @@ checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
name = "version-ranges"
version = "0.1.1"
source = "git+https://github.com/astral-sh/pubgrub?rev=06ec5a5f59ffaeb6cf5079c6cb184467da06c9db#06ec5a5f59ffaeb6cf5079c6cb184467da06c9db"
source = "git+https://github.com/astral-sh/pubgrub?rev=d8efd77673c9a90792da9da31b6c0da7ea8a324b#d8efd77673c9a90792da9da31b6c0da7ea8a324b"
dependencies = [
"smallvec",
]

View File

@ -144,7 +144,7 @@ percent-encoding = { version = "2.3.1" }
petgraph = { version = "0.8.0" }
proc-macro2 = { version = "1.0.86" }
procfs = { version = "0.17.0", default-features = false, features = ["flate2"] }
pubgrub = { git = "https://github.com/astral-sh/pubgrub", rev = "06ec5a5f59ffaeb6cf5079c6cb184467da06c9db" }
pubgrub = { git = "https://github.com/astral-sh/pubgrub", rev = "d8efd77673c9a90792da9da31b6c0da7ea8a324b" }
quote = { version = "1.0.37" }
rayon = { version = "1.10.0" }
ref-cast = { version = "1.0.24" }
@ -193,7 +193,7 @@ unicode-width = { version = "0.2.0" }
unscanny = { version = "0.1.0" }
url = { version = "2.5.2", features = ["serde"] }
uuid = { version = "1.16.0" }
version-ranges = { git = "https://github.com/astral-sh/pubgrub", rev = "06ec5a5f59ffaeb6cf5079c6cb184467da06c9db" }
version-ranges = { git = "https://github.com/astral-sh/pubgrub", rev = "d8efd77673c9a90792da9da31b6c0da7ea8a324b" }
walkdir = { version = "2.5.0" }
which = { version = "8.0.0", features = ["regex"] }
windows = { version = "0.59.0", features = ["Win32_Globalization", "Win32_Security", "Win32_System_Console", "Win32_System_Kernel", "Win32_System_Diagnostics_Debug", "Win32_Storage_FileSystem", "Win32_System_Registry", "Win32_System_IO", "Win32_System_Ioctl"] }

View File

@ -133,6 +133,32 @@ impl PubGrubPackage {
}
}
/// If this package is a proxy package, return the base package it depends on.
///
/// While dependency groups may be attached to a package, we don't consider them here as
/// there is no (mandatory) dependency from a dependency group to the package.
pub(crate) fn base_package(&self) -> Option<Self> {
match &**self {
PubGrubPackageInner::Root(_)
| PubGrubPackageInner::Python(_)
| PubGrubPackageInner::System(_)
| PubGrubPackageInner::Package { .. } => None,
PubGrubPackageInner::Group { .. } => {
// The dependency groups of a package do not by themselves require the package
// itself.
None
}
PubGrubPackageInner::Extra { name, .. } | PubGrubPackageInner::Marker { name, .. } => {
Some(Self::from_package(
name.clone(),
None,
None,
MarkerTree::TRUE,
))
}
}
}
/// Returns the name of this PubGrub package, if it has one.
pub(crate) fn name(&self) -> Option<&PackageName> {
match &**self {

View File

@ -152,7 +152,7 @@ impl PubGrubPriorities {
Some(tiebreaker) => *tiebreaker,
None => {
if cfg!(debug_assertions) {
panic!("Virtual package not known: `{package}`")
panic!("Package not registered in prioritization: `{package:?}`")
} else {
PubGrubTiebreaker(Reverse(u32::MAX))
}

View File

@ -2952,6 +2952,12 @@ impl ForkState {
// Update the package priorities.
self.priorities.insert(package, version, &self.fork_urls);
// As we're adding an incompatibility from the proxy package to the base package,
// we need to register the base package.
if let Some(base_package) = package.base_package() {
self.priorities
.insert(&base_package, version, &self.fork_urls);
}
}
Ok(())
@ -2964,6 +2970,24 @@ impl ForkState {
for_version: &Version,
dependencies: Vec<PubGrubDependency>,
) {
for dependency in &dependencies {
let PubGrubDependency {
package,
version,
parent: _,
url: _,
} = dependency;
let Some(base_package) = package.base_package() else {
continue;
};
let proxy_package = self.pubgrub.package_store.alloc(package.clone());
let base_package_id = self.pubgrub.package_store.alloc(base_package.clone());
self.pubgrub
.add_proxy_package(proxy_package, base_package_id, version.clone());
}
let conflict = self.pubgrub.add_package_version_dependencies(
self.next,
for_version.clone(),

View File

@ -13103,7 +13103,7 @@ fn unconditional_overlapping_marker_disjoint_version_constraints() -> Result<()>
----- stderr -----
× No solution found when resolving dependencies:
Because only datasets<=2.18.0 is available and your project depends on datasets>=2.19, we can conclude that your project's requirements are unsatisfiable.
Because your project depends on datasets<2.19 and datasets>=2.19, we can conclude that your project's requirements are unsatisfiable.
");
Ok(())
@ -26786,17 +26786,17 @@ fn lock_self_marker_incompatible() -> Result<()> {
"#,
)?;
uv_snapshot!(context.filters(), context.lock(), @r###"
uv_snapshot!(context.filters(), context.lock(), @r"
success: false
exit_code: 1
----- stdout -----
----- stderr -----
× No solution found when resolving dependencies:
Because only project{sys_platform == 'win32'}<=0.1 is available and your project depends on itself at an incompatible version (project{sys_platform == 'win32'}>0.1), we can conclude that your project's requirements are unsatisfiable.
Because your project depends on itself at an incompatible version (project{sys_platform == 'win32'}>0.1), we can conclude that your project's requirements are unsatisfiable.
hint: The project `project` depends on itself at an incompatible version. This is likely a mistake. If you intended to depend on a third-party package named `project`, consider renaming the project `project` to avoid creating a conflict.
"###);
");
Ok(())
}
@ -29965,40 +29965,8 @@ fn lock_conflict_for_disjoint_python_version() -> Result<()> {
----- stderr -----
× No solution found when resolving dependencies for split (markers: python_full_version >= '3.11'):
Because only the following versions of numpy{python_full_version >= '3.10'} are available:
numpy{python_full_version >= '3.10'}<=1.21.0
numpy{python_full_version >= '3.10'}==1.21.1
numpy{python_full_version >= '3.10'}==1.21.2
numpy{python_full_version >= '3.10'}==1.21.3
numpy{python_full_version >= '3.10'}==1.21.4
numpy{python_full_version >= '3.10'}==1.21.5
numpy{python_full_version >= '3.10'}==1.21.6
numpy{python_full_version >= '3.10'}==1.22.0
numpy{python_full_version >= '3.10'}==1.22.1
numpy{python_full_version >= '3.10'}==1.22.2
numpy{python_full_version >= '3.10'}==1.22.3
numpy{python_full_version >= '3.10'}==1.22.4
numpy{python_full_version >= '3.10'}==1.23.0
numpy{python_full_version >= '3.10'}==1.23.1
numpy{python_full_version >= '3.10'}==1.23.2
numpy{python_full_version >= '3.10'}==1.23.3
numpy{python_full_version >= '3.10'}==1.23.4
numpy{python_full_version >= '3.10'}==1.23.5
numpy{python_full_version >= '3.10'}==1.24.0
numpy{python_full_version >= '3.10'}==1.24.1
numpy{python_full_version >= '3.10'}==1.24.2
numpy{python_full_version >= '3.10'}==1.24.3
numpy{python_full_version >= '3.10'}==1.24.4
numpy{python_full_version >= '3.10'}==1.25.0
numpy{python_full_version >= '3.10'}==1.25.1
numpy{python_full_version >= '3.10'}==1.25.2
numpy{python_full_version >= '3.10'}==1.26.0
numpy{python_full_version >= '3.10'}==1.26.1
numpy{python_full_version >= '3.10'}==1.26.2
numpy{python_full_version >= '3.10'}==1.26.3
numpy{python_full_version >= '3.10'}==1.26.4
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.
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.
Because pandas==1.5.3 depends on numpy{python_full_version >= '3.10'}>=1.21.0 and your project depends on numpy==1.20.3, we can conclude that your project and pandas==1.5.3 are incompatible.
And because your project depends on pandas==1.5.3, we can conclude that your project's requirements are unsatisfiable.
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`.
");
@ -30219,18 +30187,7 @@ fn lock_conflict_for_disjoint_platform() -> Result<()> {
----- stderr -----
× No solution found when resolving dependencies for split (markers: 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.
Because your project depends on numpy{sys_platform == 'exotic'}>=1.24,<1.26 and 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`.
");
@ -31700,3 +31657,38 @@ fn lock_required_intersection() -> Result<()> {
Ok(())
}
/// Ensure conflicts on virtual packages (such as markers) give good error messages.
#[test]
fn collapsed_error_with_marker_packages() -> Result<()> {
let context = TestContext::new("3.12");
let pyproject_toml = indoc! {r#"
[project]
name = "test-project"
version = "1.0.0"
requires-python = ">=3.12"
dependencies = [
"anyio<=4.3.0; sys_platform == 'other'",
"anyio>=4.4.0; python_version < '3.14'",
]
"#};
context
.temp_dir
.child("pyproject.toml")
.write_str(pyproject_toml)?;
uv_snapshot!(context.filters(), context.lock(), @r"
success: false
exit_code: 1
----- stdout -----
----- stderr -----
× No solution found when resolving dependencies for split (markers: python_full_version < '3.14' and sys_platform == 'other'):
Because your project depends on anyio{sys_platform == 'other'} and anyio{python_full_version < '3.14'}>=4.4.0, 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`.
");
Ok(())
}

View File

@ -3148,7 +3148,7 @@ fn fork_non_local_fork_marker_direct() -> Result<()> {
----- stderr -----
× No solution found when resolving dependencies:
Because package-a==1.0.0 depends on package-c<2.0.0 and package-b==1.0.0 depends on package-c>=2.0.0, we can conclude that package-a{sys_platform == 'linux'}==1.0.0 and package-b{sys_platform == 'darwin'}==1.0.0 are incompatible.
Because package-a==1.0.0 depends on package-c<2.0.0 and package-b==1.0.0 depends on package-c>=2.0.0, we can conclude that package-b==1.0.0 and package-a{sys_platform == 'linux'}==1.0.0 are incompatible.
And because your project depends on package-a{sys_platform == 'linux'}==1.0.0 and package-b{sys_platform == 'darwin'}==1.0.0, we can conclude that your project's requirements are unsatisfiable.
"
);
@ -3220,11 +3220,7 @@ fn fork_non_local_fork_marker_transitive() -> Result<()> {
----- stderr -----
× No solution found when resolving dependencies:
Because package-a==1.0.0 depends on package-c{sys_platform == 'linux'}<2.0.0 and only the following versions of package-c{sys_platform == 'linux'} are available:
package-c{sys_platform == 'linux'}==1.0.0
package-c{sys_platform == 'linux'}>2.0.0
we can conclude that package-a==1.0.0 depends on package-c{sys_platform == 'linux'}==1.0.0.
And because only package-c{sys_platform == 'darwin'}<=2.0.0 is available and package-b==1.0.0 depends on package-c{sys_platform == 'darwin'}>=2.0.0, we can conclude that package-a==1.0.0 and package-b==1.0.0 are incompatible.
Because package-a==1.0.0 depends on package-c{sys_platform == 'linux'}<2.0.0 and package-b==1.0.0 depends on package-c{sys_platform == 'darwin'}>=2.0.0, we can conclude that package-a==1.0.0 and package-b==1.0.0 are incompatible.
And because your project depends on package-a==1.0.0 and package-b==1.0.0, we can conclude that your project's requirements are unsatisfiable.
"
);