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.