diff --git a/crates/uv-resolver/src/lock/installable.rs b/crates/uv-resolver/src/lock/installable.rs index 34e2d921dce..9b251b51db7 100644 --- a/crates/uv-resolver/src/lock/installable.rs +++ b/crates/uv-resolver/src/lock/installable.rs @@ -232,11 +232,26 @@ trait InstallableExt<'lock>: Installable<'lock> { // Determine the set of activated extras and groups, from the root. // - // TODO(charlie): This isn't quite right. Below, when we add the dependency groups to the - // graph, we rely on the activated extras and dependency groups, to evaluate the conflict - // marker. But at that point, we don't know the full set of activated extras; this is only - // computed below. We somehow need to add the dependency groups _after_ we've computed all - // enabled extras, but the groups themselves could depend on the set of enabled extras. + // Extras activated by dependency groups (via `pkg[extra]` entries in the group) are + // accumulated below, when we process the groups themselves. This ensures that when we + // later evaluate conflict markers on transitive dependencies, self-extras enabled by an + // active group are treated as enabled. + // + // TODO(zanieb): For completeness, the group-dep loop below still has two structural + // soundness gaps. Neither is reachable through lockfiles the resolver currently + // produces — they'd require a group-dep entry with a strict positive conflict marker + // referencing an extra *other* than the entry's own self-extra, and the resolver + // either emits self-extra markers (handled by `newly_activated_extras` below) or + // markers with vacuously-true disjuncts. But the code should still handle them: + // + // 1. Ordering: an earlier group-dep entry whose marker references an extra activated + // by a later entry is evaluated with an incomplete `activated_extras` set. + // 2. Transitive self-extras: a group dep `pkg[a]` where `pkg.optional_dependencies.a` + // includes `pkg[b]` only activates `(pkg, b)` during the first-pass traversal + // below, so any group dep whose marker needs `(pkg, b)` is evaluated too early. + // + // Fixing these correctly likely means iterating group-dep activation to a fixed point + // or interleaving it with the first-pass traversal. if has_conflicts { for dist in roots.iter().copied() { // Track the activated extras. @@ -369,6 +384,14 @@ trait InstallableExt<'lock>: Installable<'lock> { Edge::Dev(group.clone()), ); + // Persist any self-extras activated by this group dependency (e.g., a group + // that references `pkg[extra]`). Without this, conflict markers on transitive + // dependencies gated by the activated extra would not evaluate to `true` + // during the graph traversals below. + for key in additional_activated_extras { + activated_extras.push(key); + } + // Push its dependencies on the queue. add_reachability( &mut conflict_reachability, @@ -507,6 +530,17 @@ trait InstallableExt<'lock>: Installable<'lock> { // Add the edge. petgraph.add_edge(root, index, Edge::Dev(group.clone())); + // Persist any self-extras activated by this group dependency. Mirrors the + // handling in the package-level `dependency_groups` loop above; without this, + // conflict markers on transitive dependencies gated by the activated extra + // would not evaluate to `true` during the graph traversals below. + for extra in &dependency.extras { + let key = (&dist.id.name, extra); + if !activated_extras.contains(&key) { + activated_extras.push(key); + } + } + // Push its dependencies on the queue. add_reachability( &mut conflict_reachability, diff --git a/crates/uv/tests/lock_scenarios/lock_conflict.rs b/crates/uv/tests/lock_scenarios/lock_conflict.rs index 153a058dd4a..35e92a623f5 100644 --- a/crates/uv/tests/lock_scenarios/lock_conflict.rs +++ b/crates/uv/tests/lock_scenarios/lock_conflict.rs @@ -2669,6 +2669,243 @@ fn mixed() -> Result<()> { Ok(()) } +/// See +#[test] +fn group_activates_self_extra() -> Result<()> { + let context = uv_test::test_context!("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [] + + [project.optional-dependencies] + dev = ["anyio"] + summarize = ["anyio", "idna==3.6"] + foo = ["idna==3.5"] + + [dependency-groups] + dev = ["project[dev]"] + + [tool.uv] + conflicts = [ + [{ extra = "dev" }, { extra = "summarize" }], + [{ extra = "foo" }, { extra = "summarize" }], + ] + "#, + )?; + + uv_snapshot!(context.filters(), context.lock(), @" + exit_code: 0 (success) + ----- stderr ----- + Resolved 5 packages in [TIME] + "); + + let lock = context.read("uv.lock"); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r#" + version = 1 + revision = 3 + requires-python = ">=3.12" + conflicts = [[ + { package = "project", extra = "dev" }, + { package = "project", extra = "summarize" }, + ], [ + { package = "project", extra = "foo" }, + { package = "project", extra = "summarize" }, + ]] + + [options] + exclude-newer = "2024-03-25T00:00:00Z" + + [[package]] + name = "anyio" + version = "4.3.0" + source = { registry = "https://pypi.org/simple" } + dependencies = [ + { name = "idna", version = "3.5", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-7-project-dev' or (extra == 'extra-7-project-foo' and extra == 'extra-7-project-summarize')" }, + { name = "idna", version = "3.6", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-7-project-summarize'" }, + { name = "sniffio" }, + ] + sdist = { url = "https://files.pythonhosted.org/packages/db/4d/3970183622f0330d3c23d9b8a5f52e365e50381fd484d08e3285104333d3/anyio-4.3.0.tar.gz", hash = "sha256:f75253795a87df48568485fd18cdd2a3fa5c4f7c5be8e5e36637733fce06fed6", size = 159642, upload-time = "2024-02-19T08:36:28.641Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/14/fd/2f20c40b45e4fb4324834aea24bd4afdf1143390242c0b33774da0e2e34f/anyio-4.3.0-py3-none-any.whl", hash = "sha256:048e05d0f6caeed70d731f3db756d35dcc1f35747c8c403364a8332c630441b8", size = 85584, upload-time = "2024-02-19T08:36:26.842Z" }, + ] + + [[package]] + name = "idna" + version = "3.5" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/9b/c4/db3e4b22ebc18ee797dae8e14b5db68e5826ae6337334c276f1cb4ff84fb/idna-3.5.tar.gz", hash = "sha256:27009fe2735bf8723353582d48575b23c533cc2c2de7b5a68908d91b5eb18d08", size = 64640, upload-time = "2023-11-24T18:07:06.343Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/65/9c7a31be86861d43da3d4f8661f677b38120320540773a04979ad6fa9ecd/idna-3.5-py3-none-any.whl", hash = "sha256:79b8f0ac92d2351be5f6122356c9a592c96d81c9a79e4b488bf2a6a15f88057a", size = 61566, upload-time = "2023-11-24T18:07:03.851Z" }, + ] + + [[package]] + name = "idna" + version = "3.6" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/bf/3f/ea4b9117521a1e9c50344b909be7886dd00a519552724809bb1f486986c2/idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca", size = 175426, upload-time = "2023-11-25T15:40:54.902Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/e7/a82b05cf63a603df6e68d59ae6a68bf5064484a0718ea5033660af4b54a9/idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f", size = 61567, upload-time = "2023-11-25T15:40:52.604Z" }, + ] + + [[package]] + name = "project" + version = "0.1.0" + source = { virtual = "." } + + [package.optional-dependencies] + dev = [ + { name = "anyio" }, + ] + foo = [ + { name = "idna", version = "3.5", source = { registry = "https://pypi.org/simple" } }, + ] + summarize = [ + { name = "anyio" }, + { name = "idna", version = "3.6", source = { registry = "https://pypi.org/simple" } }, + ] + + [package.dev-dependencies] + dev = [ + { name = "project" }, + { name = "project", extra = ["dev"], marker = "extra == 'extra-7-project-dev' or (extra == 'extra-7-project-foo' and extra == 'extra-7-project-summarize')" }, + ] + + [package.metadata] + requires-dist = [ + { name = "anyio", marker = "extra == 'dev'" }, + { name = "anyio", marker = "extra == 'summarize'" }, + { name = "idna", marker = "extra == 'foo'", specifier = "==3.5" }, + { name = "idna", marker = "extra == 'summarize'", specifier = "==3.6" }, + ] + provides-extras = ["dev", "summarize", "foo"] + + [package.metadata.requires-dev] + dev = [{ name = "project", extras = ["dev"] }] + + [[package]] + name = "sniffio" + version = "1.3.1" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, + ] + "# + ); + }); + + // Re-run with `--locked`. + uv_snapshot!(context.filters(), context.lock().arg("--locked"), @" + exit_code: 0 (success) + ----- stderr ----- + Resolved 5 packages in [TIME] + "); + + // Activating the `dev` group (the default) should install `idna==3.5` via `anyio`'s + // conflict-gated edge, because the group itself enables the `dev` self-extra. + uv_snapshot!(context.filters(), context.sync().arg("--frozen"), @" + exit_code: 0 (success) + ----- stderr ----- + Prepared 3 packages in [TIME] + Installed 3 packages in [TIME] + + anyio==4.3.0 + + idna==3.5 + + sniffio==1.3.1 + "); + + // Enabling the extra explicitly should produce the same environment. + uv_snapshot!(context.filters(), context.sync().arg("--frozen").arg("--extra=dev"), @" + exit_code: 0 (success) + ----- stderr ----- + Checked 3 packages in [TIME] + "); + + Ok(()) +} + +/// See [`group_activates_self_extra`] +/// +/// This covers the non-project workspace case, which uses a separate code path. +#[test] +fn group_activates_self_extra_non_project_workspace() -> Result<()> { + let context = uv_test::test_context!("3.12"); + + let root_pyproject_toml = context.temp_dir.child("pyproject.toml"); + root_pyproject_toml.write_str( + r#" + [tool.uv.workspace] + members = ["pkg1"] + + [tool.uv.sources] + pkg1 = { workspace = true } + + [dependency-groups] + dev = ["pkg1[dev]"] + + [tool.uv] + conflicts = [ + [{ package = "pkg1", extra = "dev" }, { package = "pkg1", extra = "summarize" }], + [{ package = "pkg1", extra = "foo" }, { package = "pkg1", extra = "summarize" }], + ] + "#, + )?; + + let pkg1_pyproject_toml = context.temp_dir.child("pkg1").child("pyproject.toml"); + pkg1_pyproject_toml.write_str( + r#" + [project] + name = "pkg1" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [] + + [project.optional-dependencies] + dev = ["anyio"] + summarize = ["anyio", "idna==3.6"] + foo = ["idna==3.5"] + "#, + )?; + + uv_snapshot!(context.filters(), context.lock(), @" + exit_code: 0 (success) + ----- stderr ----- + Resolved 5 packages in [TIME] + "); + + // Activating the `dev` group (the default) installs `idna==3.5` via `anyio`'s + // conflict-gated edge, because the group references `pkg1[dev]`. + uv_snapshot!(context.filters(), context.sync().arg("--frozen"), @" + exit_code: 0 (success) + ----- stderr ----- + Prepared 4 packages in [TIME] + Installed 4 packages in [TIME] + + anyio==4.3.0 + + idna==3.5 + + pkg1==0.1.0 (from file://[TEMP_DIR]/pkg1) + + sniffio==1.3.1 + "); + + // Enabling the extra explicitly produces the same environment. + uv_snapshot!(context.filters(), context.sync().arg("--frozen").arg("--extra=dev"), @" + exit_code: 0 (success) + ----- stderr ----- + Checked 4 packages in [TIME] + "); + + Ok(()) +} + #[test] fn multiple_sources_index_disjoint_extras() -> Result<()> { let context = uv_test::test_context!("3.12").with_exclude_newer("2025-01-30T00:00Z");