Skip to content

Commit 7f88996

Browse files
committed
Conflicting groups should handle conflicting inclusions automatically
1 parent 40dce4e commit 7f88996

File tree

13 files changed

+706
-173
lines changed

13 files changed

+706
-173
lines changed

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/uv-pypi-types/Cargo.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,12 @@ indexmap = { workspace = true, features = ["serde"] }
2929
itertools = { workspace = true }
3030
jiff = { workspace = true, features = ["serde"] }
3131
mailparse = { workspace = true }
32+
petgraph = { workspace = true }
3233
regex = { workspace = true }
3334
rkyv = { workspace = true }
35+
rustc-hash = { workspace = true }
3436
schemars = { workspace = true, optional = true }
35-
serde = { workspace = true }
37+
serde = { workspace = true, optional = true }
3638
serde-untagged = { workspace = true }
3739
thiserror = { workspace = true }
3840
toml = { workspace = true }

crates/uv-pypi-types/src/conflicts.rs

Lines changed: 213 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
1+
use petgraph::{
2+
algo::toposort,
3+
graph::{DiGraph, NodeIndex},
4+
};
5+
use rustc_hash::{FxHashMap, FxHashSet};
6+
use std::{collections::BTreeSet, hash::Hash, rc::Rc};
17
use uv_normalize::{ExtraName, GroupName, PackageName};
28

9+
use crate::dependency_groups::{DependencyGroupSpecifier, DependencyGroups};
10+
311
/// A list of conflicting sets of extras/groups pre-defined by an end user.
412
///
513
/// This is useful to force the resolver to fork according to extras that have
@@ -47,6 +55,155 @@ impl Conflicts {
4755
pub fn append(&mut self, other: &mut Conflicts) {
4856
self.0.append(&mut other.0);
4957
}
58+
59+
/// Expand [`Conflicts`]s to include all [`ConflictSet`]s that can
60+
/// be transitively inferred from group conflicts directly defined
61+
/// in configuration.
62+
///
63+
/// A directed acyclic graph (DAG) is created representing all
64+
/// transitive group includes, with nodes corresponding to group conflict
65+
/// items. For every conflict item directly mentioned in configuration,
66+
/// its node starts with a set of canonical items with itself as the only
67+
/// member.
68+
///
69+
/// The graph is traversed one node at a time in topological order and
70+
/// canonical items are propagated to each neighbor. We also update our
71+
/// substitutions at each neighbor to reflect that this neighbor transitively
72+
/// includes all canonical items visited so far to reach it.
73+
///
74+
/// Finally, we apply the substitutions to the conflict sets that were
75+
/// directly defined in configuration to generate all transitively inferable
76+
/// [`ConflictSet`]s.
77+
///
78+
/// There is an assumption that inclusion graphs will not be very large
79+
/// or complex. This algorithm creates all combinations of substitutions.
80+
/// Each resulting [`ConflictSet`] would also later correspond to a separate
81+
/// resolver fork during resolution.
82+
pub fn expand_transitive_group_includes(
83+
&mut self,
84+
package: &PackageName,
85+
groups: &DependencyGroups,
86+
) {
87+
let mut graph = DiGraph::new();
88+
let mut group_node_idxs: FxHashMap<GroupName, NodeIndex> = FxHashMap::default();
89+
let mut node_conflict_items: FxHashMap<NodeIndex, Rc<ConflictItem>> = FxHashMap::default();
90+
// Used for transitively deriving new conflict sets with substitutions.
91+
// The keys are canonical items (mentioned directly in configured conflicts).
92+
// The values correspond to groups that transitively include them.
93+
let mut substitutions: FxHashMap<Rc<ConflictItem>, FxHashSet<Rc<ConflictItem>>> =
94+
FxHashMap::default();
95+
96+
// Conflict sets that were directly defined in configuration.
97+
let mut direct_conflict_sets: FxHashSet<ConflictSet> = FxHashSet::default();
98+
// Conflict sets that we will transitively infer in this method.
99+
let mut transitive_conflict_sets: FxHashSet<ConflictSet> = FxHashSet::default();
100+
101+
// Add groups in directly defined conflict sets to the graph.
102+
let mut seen: std::collections::HashSet<&GroupName, rustc_hash::FxBuildHasher> =
103+
FxHashSet::default();
104+
for set in &self.0 {
105+
direct_conflict_sets.insert(set.clone());
106+
for item in set.iter() {
107+
let ConflictPackage::Group(group) = &item.conflict else {
108+
// TODO: Do we also want to handle extras here?
109+
continue;
110+
};
111+
if !seen.contains(group) {
112+
let item = Rc::new(item.clone());
113+
let mut canonical_items = FxHashSet::default();
114+
canonical_items.insert(item.clone());
115+
let node_id = graph.add_node(canonical_items);
116+
group_node_idxs.insert(group.clone(), node_id);
117+
seen.insert(group);
118+
node_conflict_items.insert(node_id, item.clone());
119+
}
120+
}
121+
}
122+
123+
// Create conflict items for remaining groups and add them to the graph.
124+
for group in groups.keys() {
125+
if !seen.contains(group) {
126+
seen.insert(group);
127+
let group_conflict_item = ConflictItem {
128+
package: package.clone(),
129+
conflict: ConflictPackage::Group(group.clone()),
130+
};
131+
let node_id = graph.add_node(FxHashSet::default());
132+
group_node_idxs.insert(group.clone(), node_id);
133+
node_conflict_items.insert(node_id, Rc::new(group_conflict_item));
134+
}
135+
}
136+
137+
// Create edges representing group inclusion (with edges reversed so that
138+
// included groups point to including groups).
139+
for (group, specifiers) in groups {
140+
let includer = group_node_idxs
141+
.get(group)
142+
.expect("Group should have been added to graph");
143+
for specifier in specifiers {
144+
if let DependencyGroupSpecifier::IncludeGroup { include_group } = specifier {
145+
let included = group_node_idxs
146+
.get(include_group)
147+
.expect("Group should have been added to graph");
148+
graph.add_edge(*included, *includer, ());
149+
}
150+
}
151+
}
152+
153+
// Propagate canonical items through the graph and populate substitutions.
154+
// FIXME: Have we already done cycle detection before this method was
155+
// called or do we need to propagate error?
156+
for node in toposort(&graph, None).unwrap() {
157+
for neighbor_idx in graph.neighbors(node).collect::<Vec<_>>() {
158+
let mut neighbor_canonical_items = Vec::new();
159+
if let Some(canonical_items) = graph.node_weight(node) {
160+
let neighbor_item = node_conflict_items
161+
.get(&neighbor_idx)
162+
.expect("ConflictItem should already be in graph")
163+
.clone();
164+
for canonical_item in canonical_items {
165+
neighbor_canonical_items.push(canonical_item.clone());
166+
substitutions
167+
.entry(canonical_item.clone())
168+
.or_default()
169+
.insert(neighbor_item.clone());
170+
}
171+
}
172+
graph
173+
.node_weight_mut(neighbor_idx)
174+
.expect("Graph node should have weight")
175+
.extend(neighbor_canonical_items.into_iter());
176+
}
177+
}
178+
179+
// Create new conflict sets for all possible replacements of canonical
180+
// items by substitution items.
181+
// Note that new sets are (potentially) added to transitive_conflict_sets
182+
// at the end of each iteration.
183+
for (canonical_item, subs) in substitutions {
184+
let mut new_conflict_sets = FxHashSet::default();
185+
for conflict_set in direct_conflict_sets
186+
.iter()
187+
.chain(transitive_conflict_sets.iter())
188+
.filter(|set| set.contains_item(&canonical_item))
189+
{
190+
for sub in &subs {
191+
let mut new_set = conflict_set
192+
.replaced_item(&canonical_item, (**sub).clone())
193+
.expect("`ConflictItem` should be in `ConflictSet`");
194+
if !direct_conflict_sets.contains(&new_set) {
195+
new_set.set_as_inferred_conflict();
196+
if !transitive_conflict_sets.contains(&new_set) {
197+
new_conflict_sets.insert(new_set);
198+
}
199+
}
200+
}
201+
}
202+
transitive_conflict_sets.extend(new_conflict_sets.into_iter());
203+
}
204+
205+
self.0.extend(transitive_conflict_sets);
206+
}
50207
}
51208

52209
/// A single set of package-extra pairs that conflict with one another.
@@ -58,23 +215,24 @@ impl Conflicts {
58215
///
59216
/// A `TryFrom<Vec<ConflictItem>>` impl may be used to build a set from a
60217
/// sequence. Note though that at least 2 items are required.
61-
#[derive(Debug, Default, Clone, Eq, PartialEq, serde::Serialize)]
62-
pub struct ConflictSet(Vec<ConflictItem>);
218+
#[derive(Debug, Default, Clone, Hash, Eq, PartialEq, serde::Serialize)]
219+
pub struct ConflictSet {
220+
set: BTreeSet<ConflictItem>,
221+
is_inferred_conflict: bool,
222+
}
63223

64224
impl ConflictSet {
65225
/// Create a pair of items that conflict with one another.
66226
pub fn pair(item1: ConflictItem, item2: ConflictItem) -> ConflictSet {
67-
ConflictSet(vec![item1, item2])
68-
}
69-
70-
/// Add a new conflicting item to this set.
71-
pub fn push(&mut self, item: ConflictItem) {
72-
self.0.push(item);
227+
ConflictSet {
228+
set: BTreeSet::from_iter(vec![item1, item2]),
229+
is_inferred_conflict: false,
230+
}
73231
}
74232

75233
/// Returns an iterator over all conflicting items.
76234
pub fn iter(&self) -> impl Iterator<Item = &'_ ConflictItem> + Clone + '_ {
77-
self.0.iter()
235+
self.set.iter()
78236
}
79237

80238
/// Returns true if this conflicting item contains the given package and
@@ -88,6 +246,42 @@ impl ConflictSet {
88246
self.iter()
89247
.any(|set| set.package() == package && *set.conflict() == conflict)
90248
}
249+
250+
/// Returns true if these conflicts contain any set that contains the given
251+
/// [`ConflictItem`].
252+
pub fn contains_item(&self, conflict_item: &ConflictItem) -> bool {
253+
self.set.contains(conflict_item)
254+
}
255+
256+
/// This [`ConflictSet`] was inferred from directly defined conflicts.
257+
pub fn is_inferred_conflict(&self) -> bool {
258+
self.is_inferred_conflict
259+
}
260+
261+
// FIXME: Error if old is not present
262+
/// Replace an old [`ConflictItem`] with a new one.
263+
pub fn replaced_item(
264+
&self,
265+
old: &ConflictItem,
266+
new: ConflictItem,
267+
) -> Result<Self, ConflictError> {
268+
let mut new_set = self.set.clone();
269+
if !new_set.contains(old) {
270+
return Err(ConflictError::ReplaceMissingConflictItem);
271+
}
272+
new_set.remove(old);
273+
new_set.insert(new);
274+
Ok(Self {
275+
set: new_set,
276+
is_inferred_conflict: false,
277+
})
278+
}
279+
280+
/// Mark this [`ConflictSet`] as being inferred from directly
281+
/// defined conflicts.
282+
pub fn set_as_inferred_conflict(&mut self) {
283+
self.is_inferred_conflict = true;
284+
}
91285
}
92286

93287
impl<'de> serde::Deserialize<'de> for ConflictSet {
@@ -109,14 +303,17 @@ impl TryFrom<Vec<ConflictItem>> for ConflictSet {
109303
1 => return Err(ConflictError::OneItem),
110304
_ => {}
111305
}
112-
Ok(ConflictSet(items))
306+
Ok(ConflictSet {
307+
set: BTreeSet::from_iter(items),
308+
is_inferred_conflict: false,
309+
})
113310
}
114311
}
115312

116313
/// A single item in a conflicting set.
117314
///
118-
/// Each item is a pair of a package and a corresponding extra name for that
119-
/// package.
315+
/// Each item is a pair of a package and a corresponding extra or group name
316+
/// for that package.
120317
#[derive(
121318
Debug, Clone, Eq, Hash, PartialEq, PartialOrd, Ord, serde::Deserialize, serde::Serialize,
122319
)]
@@ -364,6 +561,10 @@ pub enum ConflictError {
364561
/// An error that occurs when both `extra` and `group` are present.
365562
#[error("Expected one of `extra` or `group` in conflicting entry, but found both")]
366563
FoundExtraAndGroup,
564+
#[error("Cycle detected in transitive conflict inclusion")]
565+
ConflictInclusionCycle,
566+
#[error("Expected `ConflictSet` to contain `ConflictItem` to replace")]
567+
ReplaceMissingConflictItem,
367568
}
368569

369570
/// Like [`Conflicts`], but for deserialization in `pyproject.toml`.

0 commit comments

Comments
 (0)