diff --git a/crates/uv-resolver/src/resolver/environment.rs b/crates/uv-resolver/src/resolver/environment.rs index 6e816f991..3184ea300 100644 --- a/crates/uv-resolver/src/resolver/environment.rs +++ b/crates/uv-resolver/src/resolver/environment.rs @@ -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; diff --git a/crates/uv-resolver/src/resolver/mod.rs b/crates/uv-resolver/src/resolver/mod.rs index c43bb2d0f..a2ea83b2c 100644 --- a/crates/uv-resolver/src/resolver/mod.rs +++ b/crates/uv-resolver/src/resolver/mod.rs @@ -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); diff --git a/crates/uv/tests/it/lock.rs b/crates/uv/tests/it/lock.rs index 2e2c52e03..c5f15ea4e 100644 --- a/crates/uv/tests/it/lock.rs +++ b/crates/uv/tests/it/lock.rs @@ -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. diff --git a/crates/uv/tests/it/lock_conflict.rs b/crates/uv/tests/it/lock_conflict.rs index d67736c88..48e6f4351 100644 --- a/crates/uv/tests/it/lock_conflict.rs +++ b/crates/uv/tests/it/lock_conflict.rs @@ -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(()) }