Improve handling of package conflicts

This commit is contained in:
Zanie Blue 2025-07-24 12:38:46 -05:00
parent 827708e67e
commit 88eb0e94c4
4 changed files with 245 additions and 66 deletions

View File

@ -3,7 +3,7 @@ use tracing::trace;
use uv_distribution_types::{RequiresPython, RequiresPythonRange};
use uv_pep440::VersionSpecifiers;
use uv_pep508::{MarkerEnvironment, MarkerTree};
use uv_pypi_types::{ConflictItem, ConflictItemRef, ResolverMarkerEnvironment};
use uv_pypi_types::{ConflictItem, ConflictItemRef, ConflictKindRef, ResolverMarkerEnvironment};
use crate::pubgrub::{PubGrubDependency, PubGrubPackage};
use crate::resolver::ForkState;

View File

@ -35,7 +35,7 @@ use uv_pep508::{
MarkerEnvironment, MarkerExpression, MarkerOperator, MarkerTree, MarkerValueString,
};
use uv_platform_tags::Tags;
use uv_pypi_types::{ConflictItem, ConflictItemRef, Conflicts, VerbatimParsedUrl};
use uv_pypi_types::{ConflictItem, ConflictItemRef, ConflictKindRef, Conflicts, VerbatimParsedUrl};
use uv_types::{BuildContext, HashStrategy, InstalledPackagesProvider};
use uv_warnings::warn_user_once;
@ -3637,7 +3637,7 @@ impl Forks {
continue;
}
// Create a fork that excludes ALL extras.
// Create a fork that excludes ALL conflicts.
if let Some(fork_none) = fork.clone().filter(set.iter().cloned().map(Err)) {
new.push(fork_none);
}
@ -3770,6 +3770,17 @@ impl Fork {
};
if self.env.included_by_group(conflicting_item) {
return true;
} else {
match conflicting_item.kind() {
// We should not filter entire projects unless they're a top-level dependency
ConflictKindRef::Project => {
if dep.parent.is_some() {
return true;
}
}
ConflictKindRef::Group(_) => {}
ConflictKindRef::Extra(_) => {}
}
}
if let Some(conflicting_item) = dep.conflicting_item() {
self.conflicts.remove(&conflicting_item);

View File

@ -2768,14 +2768,14 @@ fn lock_conflicting_project_basic1() -> Result<()> {
"#,
)?;
uv_snapshot!(context.filters(), context.lock(), @r###"
uv_snapshot!(context.filters(), context.lock(), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 3 packages in [TIME]
"###);
");
let lock = context.read("uv.lock");
@ -2866,7 +2866,7 @@ fn lock_conflicting_project_basic1() -> Result<()> {
----- stdout -----
----- stderr -----
error: Group `foo` and the project are incompatible with the declared conflicts: {`project:foo`, the project}
error: Group `foo` and package `project` are incompatible with the declared conflicts: {`project:foo`, project}
");
// Another install, but this time with `--only-group=foo`,
// which excludes the project and is thus okay.
@ -2941,14 +2941,14 @@ fn lock_conflicting_workspace_members() -> Result<()> {
)?;
// Lock should succeed because we declared the conflict
uv_snapshot!(context.filters(), context.lock(), @r###"
uv_snapshot!(context.filters(), context.lock(), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 4 packages in [TIME]
"###);
");
let lock = context.read("uv.lock");
@ -3118,6 +3118,82 @@ fn lock_conflicting_workspace_members_depends_direct() -> Result<()> {
// This should fail to resolve, because these conflict
uv_snapshot!(context.filters(), context.lock(), @r"
success: false
exit_code: 1
----- stdout -----
----- stderr -----
× No solution found when resolving dependencies:
Because subexample depends on sortedcontainers==2.4.0 and example depends on sortedcontainers==2.3.0, we can conclude that example and subexample are incompatible.
And because example depends on subexample and your workspace requires example, we can conclude that your workspace's requirements are unsatisfiable.
");
Ok(())
}
/// Like [`lock_conflicting_workspace_members_depends_direct`], but the root project depends on the
/// conflicting workspace member via a direct optional dependency.
#[test]
fn lock_conflicting_workspace_members_depends_direct_extra() -> Result<()> {
let context = TestContext::new("3.12");
let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(
r#"
[project]
name = "example"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["sortedcontainers==2.3.0"]
[project.optional-dependencies]
foo = ["subexample"]
[tool.uv.workspace]
members = ["subexample"]
[tool.uv]
conflicts = [
[
{ package = "example" },
{ package = "example", extra = "foo"},
{ package = "subexample" },
],
]
[tool.uv.sources]
subexample = { workspace = true }
[build-system]
requires = ["setuptools>=42"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
include = ["example"]
"#,
)?;
// Create the subproject
let subproject_dir = context.temp_dir.child("subexample");
subproject_dir.create_dir_all()?;
let sub_pyproject_toml = subproject_dir.child("pyproject.toml");
sub_pyproject_toml.write_str(
r#"
[project]
name = "subexample"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["sortedcontainers==2.4.0"]
[build-system]
requires = ["setuptools>=42"]
build-backend = "setuptools.build_meta"
"#,
)?;
// This should succeed, because the conflict is optional
uv_snapshot!(context.filters(), context.lock(), @r"
success: true
exit_code: 0
----- stdout -----
@ -3127,6 +3203,7 @@ fn lock_conflicting_workspace_members_depends_direct() -> Result<()> {
");
let lock = context.read("uv.lock");
insta::with_settings!({
filters => context.filters(),
}, {
@ -3136,6 +3213,7 @@ fn lock_conflicting_workspace_members_depends_direct() -> Result<()> {
revision = 2
requires-python = ">=3.12"
conflicts = [[
{ package = "example", extra = "foo" },
{ package = "example" },
{ package = "subexample" },
]]
@ -3154,14 +3232,15 @@ fn lock_conflicting_workspace_members_depends_direct() -> Result<()> {
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "sortedcontainers", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'project-7-example'" },
{ name = "sortedcontainers", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-7-example-foo' or extra == 'project-7-example'" },
]
[package.metadata]
requires-dist = [
{ name = "sortedcontainers", specifier = "==2.3.0" },
{ name = "subexample", editable = "subexample" },
{ name = "subexample", marker = "extra == 'foo'", editable = "subexample" },
]
provides-extras = ["foo"]
[[package]]
name = "sortedcontainers"
@ -3186,27 +3265,116 @@ fn lock_conflicting_workspace_members_depends_direct() -> Result<()> {
version = "0.1.0"
source = { editable = "subexample" }
dependencies = [
{ name = "sortedcontainers", version = "2.4.0", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'project-10-subexample'" },
{ name = "sortedcontainers", version = "2.4.0", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'project-10-subexample' or (extra == 'extra-7-example-foo' and extra == 'project-7-example')" },
]
[package.metadata]
requires-dist = [{ name = "sortedcontainers", specifier = "==2.4.0" }]
"#)});
"#
);
});
// Syncing should fail too
uv_snapshot!(context.filters(), context.sync(), @r"
// Install from the lockfile
uv_snapshot!(context.filters(), context.sync().arg("--frozen"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 4 packages in [TIME]
Prepared 2 packages in [TIME]
Installed 2 packages in [TIME]
+ example==0.1.0 (from file://[TEMP_DIR]/)
+ sortedcontainers==2.3.0
");
// Attempt to install with the extra selected
uv_snapshot!(context.filters(), context.sync().arg("--frozen").arg("--extra").arg("foo"), @r"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: Extra `foo` and package `example` are incompatible with the declared conflicts: {`example[foo]`, example, subexample}
");
// Install just the child package
uv_snapshot!(context.filters(), context.sync().arg("--frozen").arg("--package").arg("subexample"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Prepared 2 packages in [TIME]
Uninstalled 2 packages in [TIME]
Installed 2 packages in [TIME]
- example==0.1.0 (from file://[TEMP_DIR]/)
- sortedcontainers==2.3.0
+ sortedcontainers==2.4.0
+ subexample==0.1.0 (from file://[TEMP_DIR]/subexample)
");
// Install with just development dependencies
uv_snapshot!(context.filters(), context.sync().arg("--frozen").arg("--only-dev"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Uninstalled 2 packages in [TIME]
- sortedcontainers==2.4.0
- subexample==0.1.0 (from file://[TEMP_DIR]/subexample)
");
Ok(())
}
/// Mar
#[test]
fn lock_conflicting_extras_depends() -> Result<()> {
let context = TestContext::new("3.12");
let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(
r#"
[project]
name = "example"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = []
[project.optional-dependencies]
foo = ["sortedcontainers==2.3.0", "example[bar]"]
bar = ["sortedcontainers==2.4.0"]
[tool.uv]
conflicts = [
[
{ extra = "foo" },
{ extra = "bar" },
],
]
[build-system]
requires = ["setuptools>=42"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
include = ["example"]
"#,
)?;
// This should fail to resolve, because the extras are always required together
uv_snapshot!(context.filters(), context.lock(), @r"
success: false
exit_code: 1
----- stdout -----
----- stderr -----
× No solution found when resolving dependencies:
Because example[foo] depends on sortedcontainers==2.3.0 and sortedcontainers==2.4.0, we can conclude that example[foo]'s requirements are unsatisfiable.
And because your project requires example[foo], we can conclude that your project's requirements are unsatisfiable.
");
Ok(())
}
@ -3373,7 +3541,7 @@ fn lock_conflicting_project_basic2() -> Result<()> {
----- stdout -----
----- stderr -----
error: Group `foo` and the project are incompatible with the declared conflicts: {`example:foo`, the project}
error: Group `foo` and package `example` are incompatible with the declared conflicts: {`example:foo`, example}
");
// Another install, but this time with `--only-group=foo`,
// which excludes the project and is thus okay.

View File

@ -1049,23 +1049,23 @@ fn extra_unconditional() -> Result<()> {
"#,
)?;
uv_snapshot!(context.filters(), context.lock(), @r###"
uv_snapshot!(context.filters(), context.lock(), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 6 packages in [TIME]
"###);
");
// This should error since we're enabling two conflicting extras.
uv_snapshot!(context.filters(), context.sync().arg("--frozen"), @r###"
uv_snapshot!(context.filters(), context.sync().arg("--frozen"), @r"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: Found conflicting extras `proxy1[extra1]` and `proxy1[extra2]` enabled simultaneously
"###);
");
root_pyproject_toml.write_str(
r#"
@ -1084,14 +1084,14 @@ fn extra_unconditional() -> Result<()> {
proxy1 = { workspace = true }
"#,
)?;
uv_snapshot!(context.filters(), context.lock(), @r###"
uv_snapshot!(context.filters(), context.lock(), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 6 packages in [TIME]
"###);
");
// This is fine because we are only enabling one
// extra, and thus, there is no conflict.
uv_snapshot!(context.filters(), context.sync().arg("--frozen"), @r"
@ -1126,17 +1126,17 @@ fn extra_unconditional() -> Result<()> {
proxy1 = { workspace = true }
"#,
)?;
uv_snapshot!(context.filters(), context.lock(), @r###"
uv_snapshot!(context.filters(), context.lock(), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 6 packages in [TIME]
"###);
");
// This is fine because we are only enabling one
// extra, and thus, there is no conflict.
uv_snapshot!(context.filters(), context.sync().arg("--frozen"), @r###"
uv_snapshot!(context.filters(), context.sync().arg("--frozen"), @r"
success: true
exit_code: 0
----- stdout -----
@ -1147,7 +1147,7 @@ fn extra_unconditional() -> Result<()> {
Installed 1 package in [TIME]
- anyio==4.1.0
+ anyio==4.2.0
"###);
");
Ok(())
}
@ -1202,14 +1202,14 @@ fn extra_unconditional_non_conflicting() -> Result<()> {
"#,
)?;
uv_snapshot!(context.filters(), context.lock(), @r###"
uv_snapshot!(context.filters(), context.lock(), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 5 packages in [TIME]
"###);
");
// This *should* install `anyio==4.1.0`, but when this
// test was initially written, it didn't. This was because
@ -1425,26 +1425,26 @@ fn extra_unconditional_non_local_conflict() -> Result<()> {
// that can never be installed! Namely, because two different
// conflicting extras are enabled unconditionally in all
// configurations.
uv_snapshot!(context.filters(), context.lock(), @r###"
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###"
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(())
}
@ -1711,14 +1711,14 @@ fn group_basic() -> Result<()> {
"#,
)?;
uv_snapshot!(context.filters(), context.lock(), @r###"
uv_snapshot!(context.filters(), context.lock(), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 3 packages in [TIME]
"###);
");
let lock = context.read("uv.lock");
@ -1866,14 +1866,14 @@ fn group_default() -> Result<()> {
"#,
)?;
uv_snapshot!(context.filters(), context.lock(), @r###"
uv_snapshot!(context.filters(), context.lock(), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 3 packages in [TIME]
"###);
");
let lock = context.read("uv.lock");
@ -2398,14 +2398,14 @@ fn multiple_sources_index_disjoint_groups() -> Result<()> {
"#,
)?;
uv_snapshot!(context.filters(), context.lock(), @r###"
uv_snapshot!(context.filters(), context.lock(), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 4 packages in [TIME]
"###);
");
let lock = fs_err::read_to_string(context.temp_dir.join("uv.lock")).unwrap();
@ -2881,7 +2881,7 @@ fn non_optional_dependency_extra() -> Result<()> {
"#,
)?;
uv_snapshot!(context.filters(), context.sync(), @r###"
uv_snapshot!(context.filters(), context.sync(), @r"
success: true
exit_code: 0
----- stdout -----
@ -2891,7 +2891,7 @@ fn non_optional_dependency_extra() -> Result<()> {
Prepared 1 package in [TIME]
Installed 1 package in [TIME]
+ sniffio==1.3.1
"###);
");
Ok(())
}
@ -2928,7 +2928,7 @@ fn non_optional_dependency_group() -> Result<()> {
"#,
)?;
uv_snapshot!(context.filters(), context.sync(), @r###"
uv_snapshot!(context.filters(), context.sync(), @r"
success: true
exit_code: 0
----- stdout -----
@ -2938,7 +2938,7 @@ fn non_optional_dependency_group() -> Result<()> {
Prepared 1 package in [TIME]
Installed 1 package in [TIME]
+ sniffio==1.3.1
"###);
");
Ok(())
}
@ -2978,7 +2978,7 @@ fn non_optional_dependency_mixed() -> Result<()> {
"#,
)?;
uv_snapshot!(context.filters(), context.sync(), @r###"
uv_snapshot!(context.filters(), context.sync(), @r"
success: true
exit_code: 0
----- stdout -----
@ -2988,7 +2988,7 @@ fn non_optional_dependency_mixed() -> Result<()> {
Prepared 1 package in [TIME]
Installed 1 package in [TIME]
+ sniffio==1.3.1
"###);
");
Ok(())
}
@ -3178,7 +3178,7 @@ fn shared_optional_dependency_group1() -> Result<()> {
)?;
// This shouldn't install two versions of `idna`, only one, `idna==3.5`.
uv_snapshot!(context.filters(), context.sync().arg("--group=baz").arg("--group=foo"), @r###"
uv_snapshot!(context.filters(), context.sync().arg("--group=baz").arg("--group=foo"), @r"
success: true
exit_code: 0
----- stdout -----
@ -3190,7 +3190,7 @@ fn shared_optional_dependency_group1() -> Result<()> {
+ anyio==4.3.0
+ idna==3.5
+ sniffio==1.3.1
"###);
");
let lock = fs_err::read_to_string(context.temp_dir.join("uv.lock")).unwrap();
insta::with_settings!({
@ -3605,7 +3605,7 @@ fn shared_optional_dependency_group2() -> Result<()> {
)?;
// This shouldn't install two versions of `idna`, only one, `idna==3.5`.
uv_snapshot!(context.filters(), context.sync().arg("--group=bar"), @r###"
uv_snapshot!(context.filters(), context.sync().arg("--group=bar"), @r"
success: true
exit_code: 0
----- stdout -----
@ -3617,7 +3617,7 @@ fn shared_optional_dependency_group2() -> Result<()> {
+ anyio==4.3.0
+ idna==3.6
+ sniffio==1.3.1
"###);
");
let lock = fs_err::read_to_string(context.temp_dir.join("uv.lock")).unwrap();
insta::with_settings!({
@ -3895,7 +3895,7 @@ fn shared_dependency_extra() -> Result<()> {
"#,
)?;
uv_snapshot!(context.filters(), context.sync(), @r###"
uv_snapshot!(context.filters(), context.sync(), @r"
success: true
exit_code: 0
----- stdout -----
@ -3907,7 +3907,7 @@ fn shared_dependency_extra() -> Result<()> {
+ anyio==4.3.0
+ idna==3.6
+ sniffio==1.3.1
"###);
");
let lock = fs_err::read_to_string(context.temp_dir.join("uv.lock")).unwrap();
insta::with_settings!({
@ -3996,7 +3996,7 @@ fn shared_dependency_extra() -> Result<()> {
// This shouldn't install two versions of `idna`, only one, `idna==3.5`.
// So this should remove `idna==3.6` installed above.
uv_snapshot!(context.filters(), context.sync().arg("--extra=foo"), @r###"
uv_snapshot!(context.filters(), context.sync().arg("--extra=foo"), @r"
success: true
exit_code: 0
----- stdout -----
@ -4008,9 +4008,9 @@ fn shared_dependency_extra() -> Result<()> {
Installed 1 package in [TIME]
- idna==3.6
+ idna==3.5
"###);
");
uv_snapshot!(context.filters(), context.sync().arg("--extra=bar"), @r###"
uv_snapshot!(context.filters(), context.sync().arg("--extra=bar"), @r"
success: true
exit_code: 0
----- stdout -----
@ -4021,9 +4021,9 @@ fn shared_dependency_extra() -> Result<()> {
Installed 1 package in [TIME]
- idna==3.5
+ idna==3.6
"###);
");
uv_snapshot!(context.filters(), context.sync(), @r###"
uv_snapshot!(context.filters(), context.sync(), @r"
success: true
exit_code: 0
----- stdout -----
@ -4031,7 +4031,7 @@ fn shared_dependency_extra() -> Result<()> {
----- stderr -----
Resolved 5 packages in [TIME]
Audited 3 packages in [TIME]
"###);
");
Ok(())
}
@ -4070,7 +4070,7 @@ fn shared_dependency_group() -> Result<()> {
"#,
)?;
uv_snapshot!(context.filters(), context.sync(), @r###"
uv_snapshot!(context.filters(), context.sync(), @r"
success: true
exit_code: 0
----- stdout -----
@ -4082,7 +4082,7 @@ fn shared_dependency_group() -> Result<()> {
+ anyio==4.3.0
+ idna==3.6
+ sniffio==1.3.1
"###);
");
let lock = fs_err::read_to_string(context.temp_dir.join("uv.lock")).unwrap();
insta::with_settings!({
@ -4246,7 +4246,7 @@ fn shared_dependency_mixed() -> Result<()> {
"#,
)?;
uv_snapshot!(context.filters(), context.sync(), @r###"
uv_snapshot!(context.filters(), context.sync(), @r"
success: true
exit_code: 0
----- stdout -----
@ -4258,7 +4258,7 @@ fn shared_dependency_mixed() -> Result<()> {
+ anyio==4.3.0
+ idna==3.6
+ sniffio==1.3.1
"###);
");
let lock = fs_err::read_to_string(context.temp_dir.join("uv.lock")).unwrap();
insta::with_settings!({
@ -4351,7 +4351,7 @@ fn shared_dependency_mixed() -> Result<()> {
// This shouldn't install two versions of `idna`, only one, `idna==3.5`.
// So this should remove `idna==3.6` installed above.
uv_snapshot!(context.filters(), context.sync().arg("--extra=foo"), @r###"
uv_snapshot!(context.filters(), context.sync().arg("--extra=foo"), @r"
success: true
exit_code: 0
----- stdout -----
@ -4363,9 +4363,9 @@ fn shared_dependency_mixed() -> Result<()> {
Installed 1 package in [TIME]
- idna==3.6
+ idna==3.5
"###);
");
uv_snapshot!(context.filters(), context.sync().arg("--group=bar"), @r###"
uv_snapshot!(context.filters(), context.sync().arg("--group=bar"), @r"
success: true
exit_code: 0
----- stdout -----
@ -4376,9 +4376,9 @@ fn shared_dependency_mixed() -> Result<()> {
Installed 1 package in [TIME]
- idna==3.5
+ idna==3.6
"###);
");
uv_snapshot!(context.filters(), context.sync(), @r###"
uv_snapshot!(context.filters(), context.sync(), @r"
success: true
exit_code: 0
----- stdout -----
@ -4386,7 +4386,7 @@ fn shared_dependency_mixed() -> Result<()> {
----- stderr -----
Resolved 5 packages in [TIME]
Audited 3 packages in [TIME]
"###);
");
Ok(())
}