diff --git a/crates/uv-resolver/src/lock/installable.rs b/crates/uv-resolver/src/lock/installable.rs index adcb00ce8..6d1c6bbf7 100644 --- a/crates/uv-resolver/src/lock/installable.rs +++ b/crates/uv-resolver/src/lock/installable.rs @@ -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)) } diff --git a/crates/uv-resolver/src/lock/mod.rs b/crates/uv-resolver/src/lock/mod.rs index e0d397387..89d29bb05 100644 --- a/crates/uv-resolver/src/lock/mod.rs +++ b/crates/uv-resolver/src/lock/mod.rs @@ -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. diff --git a/crates/uv/tests/it/lock_conflict.rs b/crates/uv/tests/it/lock_conflict.rs index e614f8cdf..5081a837d 100644 --- a/crates/uv/tests/it/lock_conflict.rs +++ b/crates/uv/tests/it/lock_conflict.rs @@ -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]