uv-resolver: error during installation for conflicting extras

This collects ALL activated extras while traversing the lock file to
produce a `Resolution` for installation. If any two extras are activated
that are conflicting, then an error is produced.

We add a couple of tests to demonstrate the behavior. One case is
desirable (where we conditionally depend on `package[extra]`) and the
other case is undesirable (where we create an uninstallable lock file).

Fixes #9942, Fixes #10590
This commit is contained in:
Andrew Gallant 2025-01-22 14:25:21 -05:00 committed by Andrew Gallant
parent 1676e63603
commit a8d23da59c
3 changed files with 256 additions and 0 deletions

View file

@ -1,9 +1,11 @@
use std::borrow::Cow;
use std::collections::hash_map::Entry;
use std::collections::BTreeSet;
use std::collections::VecDeque;
use std::path::Path;
use either::Either;
use itertools::Itertools;
use petgraph::Graph;
use rustc_hash::{FxBuildHasher, FxHashMap, FxHashSet};
@ -265,6 +267,8 @@ pub trait Installable<'lock> {
}
}
let mut all_activated_extras: BTreeSet<(&PackageName, &ExtraName)> =
activated_extras.iter().copied().collect();
while let Some((package, extra)) = queue.pop_front() {
let deps = if let Some(extra) = extra {
Either::Left(
@ -283,6 +287,7 @@ pub trait Installable<'lock> {
let mut extended = activated_extras.to_vec();
for extra in &dep.extra {
extended.push((&dep.package_id.name, extra));
all_activated_extras.insert((&dep.package_id.name, extra));
}
activated_extras = Cow::Owned(extended);
}
@ -338,6 +343,32 @@ pub trait Installable<'lock> {
}
}
// At time of writing, it's somewhat expected that the set of
// conflicting extras is pretty small. With that said, the
// time complexity of the following routine is pretty gross.
// Namely, `set.contains` is linear in the size of the set,
// iteration over all conflicts is also obviously linear in
// the number of conflicting sets and then for each of those,
// we visit every possible pair of activated extra from above,
// which is quadratic in the total number of extras enabled. I
// believe the simplest improvement here, if it's necessary, is
// to adjust the `Conflicts` internals to own these sorts of
// checks. ---AG
for set in self.lock().conflicts().iter() {
for ((pkg1, extra1), (pkg2, extra2)) in all_activated_extras.iter().tuple_combinations()
{
if set.contains(pkg1, *extra1) && set.contains(pkg2, *extra2) {
return Err(LockErrorKind::ConflictingExtra {
package1: (*pkg1).clone(),
extra1: (*extra1).clone(),
package2: (*pkg2).clone(),
extra2: (*extra2).clone(),
}
.into());
}
}
}
Ok(Resolution::new(petgraph))
}

View file

@ -4897,6 +4897,16 @@ enum LockErrorKind {
#[source]
err: uv_distribution::Error,
},
#[error(
"Found conflicting extras `{package1}[{extra1}]` \
and `{package2}[{extra2}]` enabled simultaneously"
)]
ConflictingExtra {
package1: PackageName,
extra1: ExtraName,
package2: PackageName,
extra2: ExtraName,
},
}
/// An error that occurs when a source string could not be parsed.

View file

@ -1201,6 +1201,221 @@ fn extra_unconditional_non_conflicting() -> Result<()> {
Ok(())
}
#[test]
fn extra_unconditional_in_optional() -> Result<()> {
let context = TestContext::new("3.12");
let root_pyproject_toml = context.temp_dir.child("pyproject.toml");
root_pyproject_toml.write_str(
r#"
[project]
name = "foo"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.10.0"
dependencies = []
[tool.uv.workspace]
members = ["proxy1"]
[tool.uv.sources]
proxy1 = { workspace = true }
[project.optional-dependencies]
x1 = ["proxy1[nested-x1]"]
x2 = ["proxy1[nested-x2]"]
"#,
)?;
let proxy1_pyproject_toml = context.temp_dir.child("proxy1").child("pyproject.toml");
proxy1_pyproject_toml.write_str(
r#"
[project]
name = "proxy1"
version = "0.1.0"
requires-python = ">=3.10.0"
dependencies = []
[project.optional-dependencies]
nested-x1 = ["sortedcontainers==2.3.0"]
nested-x2 = ["sortedcontainers==2.4.0"]
[tool.uv]
conflicts = [
[
{ extra = "nested-x1" },
{ extra = "nested-x2" },
],
]
"#,
)?;
uv_snapshot!(context.filters(), context.lock(), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 4 packages in [TIME]
"###);
// This shouldn't install anything.
uv_snapshot!(context.filters(), context.sync().arg("--frozen"), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Audited in [TIME]
"###);
// This should install `sortedcontainers==2.3.0`.
uv_snapshot!(context.filters(), context.sync().arg("--frozen").arg("--extra=x1"), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Prepared 1 package in [TIME]
Installed 1 package in [TIME]
+ sortedcontainers==2.3.0
"###);
// This should install `sortedcontainers==2.4.0`.
uv_snapshot!(context.filters(), context.sync().arg("--frozen").arg("--extra=x2"), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Prepared 1 package in [TIME]
Uninstalled 1 package in [TIME]
Installed 1 package in [TIME]
- sortedcontainers==2.3.0
+ sortedcontainers==2.4.0
"###);
// This should error!
uv_snapshot!(
context.filters(),
context.sync().arg("--frozen").arg("--extra=x1").arg("--extra=x2"),
@r###"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: Found conflicting extras `proxy1[nested-x1]` and `proxy1[nested-x2]` enabled simultaneously
"###);
Ok(())
}
#[test]
fn extra_unconditional_non_local_conflict() -> Result<()> {
let context = TestContext::new("3.12");
let root_pyproject_toml = context.temp_dir.child("pyproject.toml");
root_pyproject_toml.write_str(
r#"
[project]
name = "foo"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.10.0"
dependencies = ["a", "b"]
[tool.uv.workspace]
members = ["a", "b", "c"]
[tool.uv.sources]
a = { workspace = true }
b = { workspace = true }
c = { workspace = true }
"#,
)?;
let a_pyproject_toml = context.temp_dir.child("a").child("pyproject.toml");
a_pyproject_toml.write_str(
r#"
[project]
name = "a"
version = "0.1.0"
requires-python = ">=3.10.0"
dependencies = ["c[x1]"]
[tool.uv.sources]
c = { workspace = true }
"#,
)?;
let b_pyproject_toml = context.temp_dir.child("b").child("pyproject.toml");
b_pyproject_toml.write_str(
r#"
[project]
name = "b"
version = "0.1.0"
requires-python = ">=3.10.0"
dependencies = ["c[x2]"]
[tool.uv.sources]
c = { workspace = true }
"#,
)?;
let c_pyproject_toml = context.temp_dir.child("c").child("pyproject.toml");
c_pyproject_toml.write_str(
r#"
[project]
name = "c"
version = "0.1.0"
requires-python = ">=3.10.0"
dependencies = []
[project.optional-dependencies]
x1 = ["sortedcontainers==2.3.0"]
x2 = ["sortedcontainers==2.4.0"]
[tool.uv]
conflicts = [
[
{ extra = "x1" },
{ extra = "x2" },
],
]
"#,
)?;
// Regrettably, this produces a lock file, and it is one
// that can never be installed! Namely, because two different
// conflicting extras are enabled unconditionally in all
// configurations.
uv_snapshot!(context.filters(), context.lock(), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 6 packages in [TIME]
"###);
// This should fail. If it doesn't and we generated a lock
// file above, then this will likely result in the installation
// of two different versions of the same package.
uv_snapshot!(context.filters(), context.sync().arg("--frozen"), @r###"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: Found conflicting extras `c[x1]` and `c[x2]` enabled simultaneously
"###);
Ok(())
}
/// This tests how we deal with mutually conflicting extras that span multiple
/// packages in a workspace.
#[test]