Skip to content

Commit 333527b

Browse files
committed
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
1 parent f1bdf7e commit 333527b

File tree

3 files changed

+256
-0
lines changed

3 files changed

+256
-0
lines changed

crates/uv-resolver/src/lock/installable.rs

+31
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
use std::borrow::Cow;
22
use std::collections::hash_map::Entry;
3+
use std::collections::BTreeSet;
34
use std::collections::VecDeque;
45
use std::path::Path;
56

67
use either::Either;
8+
use itertools::Itertools;
79
use petgraph::Graph;
810
use rustc_hash::{FxBuildHasher, FxHashMap, FxHashSet};
911

@@ -265,6 +267,8 @@ pub trait Installable<'lock> {
265267
}
266268
}
267269

270+
let mut all_activated_extras: BTreeSet<(&PackageName, &ExtraName)> =
271+
activated_extras.iter().copied().collect();
268272
while let Some((package, extra)) = queue.pop_front() {
269273
let deps = if let Some(extra) = extra {
270274
Either::Left(
@@ -283,6 +287,7 @@ pub trait Installable<'lock> {
283287
let mut extended = activated_extras.to_vec();
284288
for extra in &dep.extra {
285289
extended.push((&dep.package_id.name, extra));
290+
all_activated_extras.insert((&dep.package_id.name, extra));
286291
}
287292
activated_extras = Cow::Owned(extended);
288293
}
@@ -338,6 +343,32 @@ pub trait Installable<'lock> {
338343
}
339344
}
340345

346+
// At time of writing, it's somewhat expected that the set of
347+
// conflicting extras is pretty small. With that said, the
348+
// time complexity of the following routine is pretty gross.
349+
// Namely, `set.contains` is linear in the size of the set,
350+
// iteration over all conflicts is also obviously linear in
351+
// the number of conflicting sets and then for each of those,
352+
// we visit every possible pair of activated extra from above,
353+
// which is quadratic in the total number of extras enabled. I
354+
// believe the simplest improvement here, if it's necessary, is
355+
// to adjust the `Conflicts` internals to own these sorts of
356+
// checks. ---AG
357+
for set in self.lock().conflicts().iter() {
358+
for ((pkg1, extra1), (pkg2, extra2)) in all_activated_extras.iter().tuple_combinations()
359+
{
360+
if set.contains(pkg1, *extra1) && set.contains(pkg2, *extra2) {
361+
return Err(LockErrorKind::ConflictingExtra {
362+
package1: (*pkg1).clone(),
363+
extra1: (*extra1).clone(),
364+
package2: (*pkg2).clone(),
365+
extra2: (*extra2).clone(),
366+
}
367+
.into());
368+
}
369+
}
370+
}
371+
341372
Ok(Resolution::new(petgraph))
342373
}
343374

crates/uv-resolver/src/lock/mod.rs

+10
Original file line numberDiff line numberDiff line change
@@ -4903,6 +4903,16 @@ enum LockErrorKind {
49034903
#[source]
49044904
err: uv_distribution::Error,
49054905
},
4906+
#[error(
4907+
"Found conflicting extras `{package1}[{extra1}]` \
4908+
and `{package2}[{extra2}]` enabled simultaneously"
4909+
)]
4910+
ConflictingExtra {
4911+
package1: PackageName,
4912+
extra1: ExtraName,
4913+
package2: PackageName,
4914+
extra2: ExtraName,
4915+
},
49064916
}
49074917

49084918
/// An error that occurs when a source string could not be parsed.

crates/uv/tests/it/lock_conflict.rs

+215
Original file line numberDiff line numberDiff line change
@@ -1201,6 +1201,221 @@ fn extra_unconditional_non_conflicting() -> Result<()> {
12011201
Ok(())
12021202
}
12031203

1204+
#[test]
1205+
fn extra_unconditional_in_optional() -> Result<()> {
1206+
let context = TestContext::new("3.12");
1207+
1208+
let root_pyproject_toml = context.temp_dir.child("pyproject.toml");
1209+
root_pyproject_toml.write_str(
1210+
r#"
1211+
[project]
1212+
name = "foo"
1213+
version = "0.1.0"
1214+
description = "Add your description here"
1215+
readme = "README.md"
1216+
requires-python = ">=3.10.0"
1217+
dependencies = []
1218+
1219+
[tool.uv.workspace]
1220+
members = ["proxy1"]
1221+
1222+
[tool.uv.sources]
1223+
proxy1 = { workspace = true }
1224+
1225+
[project.optional-dependencies]
1226+
x1 = ["proxy1[nested-x1]"]
1227+
x2 = ["proxy1[nested-x2]"]
1228+
"#,
1229+
)?;
1230+
1231+
let proxy1_pyproject_toml = context.temp_dir.child("proxy1").child("pyproject.toml");
1232+
proxy1_pyproject_toml.write_str(
1233+
r#"
1234+
[project]
1235+
name = "proxy1"
1236+
version = "0.1.0"
1237+
requires-python = ">=3.10.0"
1238+
dependencies = []
1239+
1240+
[project.optional-dependencies]
1241+
nested-x1 = ["sortedcontainers==2.3.0"]
1242+
nested-x2 = ["sortedcontainers==2.4.0"]
1243+
1244+
[tool.uv]
1245+
conflicts = [
1246+
[
1247+
{ extra = "nested-x1" },
1248+
{ extra = "nested-x2" },
1249+
],
1250+
]
1251+
"#,
1252+
)?;
1253+
1254+
uv_snapshot!(context.filters(), context.lock(), @r###"
1255+
success: true
1256+
exit_code: 0
1257+
----- stdout -----
1258+
1259+
----- stderr -----
1260+
Resolved 4 packages in [TIME]
1261+
"###);
1262+
1263+
// This shouldn't install anything.
1264+
uv_snapshot!(context.filters(), context.sync().arg("--frozen"), @r###"
1265+
success: true
1266+
exit_code: 0
1267+
----- stdout -----
1268+
1269+
----- stderr -----
1270+
Audited in [TIME]
1271+
"###);
1272+
1273+
// This shoudld install `sortedcontainers==2.3.0`.
1274+
uv_snapshot!(context.filters(), context.sync().arg("--frozen").arg("--extra=x1"), @r###"
1275+
success: true
1276+
exit_code: 0
1277+
----- stdout -----
1278+
1279+
----- stderr -----
1280+
Prepared 1 package in [TIME]
1281+
Installed 1 package in [TIME]
1282+
+ sortedcontainers==2.3.0
1283+
"###);
1284+
1285+
// This shoudld install `sortedcontainers==2.4.0`.
1286+
uv_snapshot!(context.filters(), context.sync().arg("--frozen").arg("--extra=x2"), @r###"
1287+
success: true
1288+
exit_code: 0
1289+
----- stdout -----
1290+
1291+
----- stderr -----
1292+
Prepared 1 package in [TIME]
1293+
Uninstalled 1 package in [TIME]
1294+
Installed 1 package in [TIME]
1295+
- sortedcontainers==2.3.0
1296+
+ sortedcontainers==2.4.0
1297+
"###);
1298+
1299+
// This should error!
1300+
uv_snapshot!(
1301+
context.filters(),
1302+
context.sync().arg("--frozen").arg("--extra=x1").arg("--extra=x2"),
1303+
@r###"
1304+
success: false
1305+
exit_code: 2
1306+
----- stdout -----
1307+
1308+
----- stderr -----
1309+
error: Found conflicting extras `proxy1[nested-x1]` and `proxy1[nested-x2]` enabled simultaneously
1310+
"###);
1311+
1312+
Ok(())
1313+
}
1314+
1315+
#[test]
1316+
fn extra_unconditional_non_local_conflict() -> Result<()> {
1317+
let context = TestContext::new("3.12");
1318+
1319+
let root_pyproject_toml = context.temp_dir.child("pyproject.toml");
1320+
root_pyproject_toml.write_str(
1321+
r#"
1322+
[project]
1323+
name = "foo"
1324+
version = "0.1.0"
1325+
description = "Add your description here"
1326+
readme = "README.md"
1327+
requires-python = ">=3.10.0"
1328+
dependencies = ["a", "b"]
1329+
1330+
[tool.uv.workspace]
1331+
members = ["a", "b", "c"]
1332+
1333+
[tool.uv.sources]
1334+
a = { workspace = true }
1335+
b = { workspace = true }
1336+
c = { workspace = true }
1337+
"#,
1338+
)?;
1339+
1340+
let a_pyproject_toml = context.temp_dir.child("a").child("pyproject.toml");
1341+
a_pyproject_toml.write_str(
1342+
r#"
1343+
[project]
1344+
name = "a"
1345+
version = "0.1.0"
1346+
requires-python = ">=3.10.0"
1347+
dependencies = ["c[x1]"]
1348+
1349+
[tool.uv.sources]
1350+
c = { workspace = true }
1351+
"#,
1352+
)?;
1353+
1354+
let b_pyproject_toml = context.temp_dir.child("b").child("pyproject.toml");
1355+
b_pyproject_toml.write_str(
1356+
r#"
1357+
[project]
1358+
name = "b"
1359+
version = "0.1.0"
1360+
requires-python = ">=3.10.0"
1361+
dependencies = ["c[x2]"]
1362+
1363+
[tool.uv.sources]
1364+
c = { workspace = true }
1365+
"#,
1366+
)?;
1367+
1368+
let c_pyproject_toml = context.temp_dir.child("c").child("pyproject.toml");
1369+
c_pyproject_toml.write_str(
1370+
r#"
1371+
[project]
1372+
name = "c"
1373+
version = "0.1.0"
1374+
requires-python = ">=3.10.0"
1375+
dependencies = []
1376+
1377+
[project.optional-dependencies]
1378+
x1 = ["sortedcontainers==2.3.0"]
1379+
x2 = ["sortedcontainers==2.4.0"]
1380+
1381+
[tool.uv]
1382+
conflicts = [
1383+
[
1384+
{ extra = "x1" },
1385+
{ extra = "x2" },
1386+
],
1387+
]
1388+
"#,
1389+
)?;
1390+
1391+
// Regretably, this produces a lock file, and it is one
1392+
// that can never be installed! Namely, because two different
1393+
// conflicting extras are enabled unconditionally in all
1394+
// configurations.
1395+
uv_snapshot!(context.filters(), context.lock(), @r###"
1396+
success: true
1397+
exit_code: 0
1398+
----- stdout -----
1399+
1400+
----- stderr -----
1401+
Resolved 6 packages in [TIME]
1402+
"###);
1403+
1404+
// This should fail. If it doesn't and we generated a lock
1405+
// file above, then this will likely result in the installation
1406+
// of two different versions of the same package.
1407+
uv_snapshot!(context.filters(), context.sync().arg("--frozen"), @r###"
1408+
success: false
1409+
exit_code: 2
1410+
----- stdout -----
1411+
1412+
----- stderr -----
1413+
error: Found conflicting extras `c[x1]` and `c[x2]` enabled simultaneously
1414+
"###);
1415+
1416+
Ok(())
1417+
}
1418+
12041419
/// This tests how we deal with mutually conflicting extras that span multiple
12051420
/// packages in a workspace.
12061421
#[test]

0 commit comments

Comments
 (0)