mirror of
https://github.com/astral-sh/uv.git
synced 2025-10-17 22:07:47 +00:00
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:
parent
1676e63603
commit
a8d23da59c
3 changed files with 256 additions and 0 deletions
|
@ -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))
|
||||
}
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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]
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue