From c21acc05a38e6accce25508fdfc17232b9395700 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 19:48:12 +0000 Subject: [PATCH 1/8] Activate self-extras from dependency groups during sync When a dependency group references a `pkg[extra]` self-extra and that extra participates in `[tool.uv.conflicts]`, activating the group should activate the extra so that conflict markers on transitive dependencies evaluate correctly. Previously, the extras gained from a group's `pkg[extra]` entries were only used for a one-shot marker check on the group dep itself and never added to the activated set, so `uv sync` could skip gated transitive dependencies that `uv sync --extra ` would install. Fixes #19106. --- crates/uv-resolver/src/lock/installable.rs | 17 +- .../uv/tests/lock_scenarios/lock_conflict.rs | 184 ++++++++++++++++++ 2 files changed, 196 insertions(+), 5 deletions(-) diff --git a/crates/uv-resolver/src/lock/installable.rs b/crates/uv-resolver/src/lock/installable.rs index 34e2d921dce..bff35961258 100644 --- a/crates/uv-resolver/src/lock/installable.rs +++ b/crates/uv-resolver/src/lock/installable.rs @@ -232,11 +232,10 @@ 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. if has_conflicts { for dist in roots.iter().copied() { // Track the activated extras. @@ -369,6 +368,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, diff --git a/crates/uv/tests/lock_scenarios/lock_conflict.rs b/crates/uv/tests/lock_scenarios/lock_conflict.rs index 153a058dd4a..ae59a436451 100644 --- a/crates/uv/tests/lock_scenarios/lock_conflict.rs +++ b/crates/uv/tests/lock_scenarios/lock_conflict.rs @@ -2669,6 +2669,190 @@ fn mixed() -> Result<()> { Ok(()) } +/// This tests a case where a dependency group references a "self-extra" (an optional +/// dependency of the project itself) and that extra also appears in a conflict set. Before +/// the fix for #19106, the group activation did not propagate the self-extra into the set of +/// activated extras used to evaluate conflict markers on transitive dependencies, so running +/// `uv sync` (which activates the group by default) would produce a different environment +/// than `uv sync --extra `, even though the two should be equivalent. +/// +/// Ref +#[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(), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- 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"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- 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"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- 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"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Checked 3 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"); From 82be542da128e1a14c8bcd028748b74ca6a9ed5b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 03:34:58 +0000 Subject: [PATCH 2/8] Document non-project workspace gap for #19106 The fix in the previous commit addresses the package-level dependency-groups loop, but an analogous block handles dependency groups on the workspace *manifest* (used when the root `pyproject.toml` has `[tool.uv.workspace]` but no `[project]` table). That loop has the same defect: self-extras activated by `pkg[extra]` group entries are never propagated into `activated_extras`, so conflict-gated transitive deps are silently dropped at sync time. Add a test that pins the buggy behavior and a `TODO(#19106)` comment at the matching spot in `installable.rs` pointing at the test. --- crates/uv-resolver/src/lock/installable.rs | 8 ++ .../uv/tests/lock_scenarios/lock_conflict.rs | 99 +++++++++++++++++++ 2 files changed, 107 insertions(+) diff --git a/crates/uv-resolver/src/lock/installable.rs b/crates/uv-resolver/src/lock/installable.rs index bff35961258..c032e236e0c 100644 --- a/crates/uv-resolver/src/lock/installable.rs +++ b/crates/uv-resolver/src/lock/installable.rs @@ -451,6 +451,14 @@ trait InstallableExt<'lock>: Installable<'lock> { // Add any dependency groups that are exclusive to the workspace root (e.g., dev // dependencies in non-project workspace roots). + // + // TODO(#19106): Like the package-level dependency-groups loop above, this loop + // does not propagate self-extras activated by `pkg[extra]` entries into + // `activated_extras`. As a result, conflict markers on transitive dependencies + // that are gated by those self-extras evaluate to `false`, so `uv sync` silently + // drops them. See the `group_activates_self_extra_non_project_workspace` test in + // `crates/uv/tests/it/lock_conflict.rs`, which currently captures the buggy + // behavior. for (group, dependency) in self .lock() .dependency_groups() diff --git a/crates/uv/tests/lock_scenarios/lock_conflict.rs b/crates/uv/tests/lock_scenarios/lock_conflict.rs index ae59a436451..a4fb7e9d180 100644 --- a/crates/uv/tests/lock_scenarios/lock_conflict.rs +++ b/crates/uv/tests/lock_scenarios/lock_conflict.rs @@ -2853,6 +2853,105 @@ fn group_activates_self_extra() -> Result<()> { Ok(()) } +/// Companion to `group_activates_self_extra`, but for the *non-project workspace root* +/// code path. When dependency groups live on the workspace manifest rather than on a +/// project package (i.e. the root `pyproject.toml` has `[tool.uv.workspace]` but no +/// `[project]` table), group deps go through a different branch in +/// `Installable::to_resolution` that *also* fails to propagate self-extras activated by +/// `pkg[extra]` entries into `activated_extras`. +/// +/// This test currently captures the *buggy* behavior: `uv sync --frozen` installs three +/// packages (missing `idna==3.5`) while `uv sync --frozen --extra=dev` installs four. +/// They should produce identical environments — the fix for the package-level path in +/// #19106 needs to be applied to the manifest-level path as well. +/// +/// TODO(#19106): Apply the same self-extra propagation fix to the manifest-level +/// dependency-groups loop in `crates/uv-resolver/src/lock/installable.rs` (the block +/// iterating `self.lock().dependency_groups()`), then update this test so the two +/// snapshots match. +#[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(), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 5 packages in [TIME] + "); + + // BUG: activating the `dev` group (the default) should install `idna==3.5` via + // `anyio`'s conflict-gated edge, because the group references `pkg1[dev]`. + // Currently it is missing. + uv_snapshot!(context.filters(), context.sync().arg("--frozen"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Prepared 3 packages in [TIME] + Installed 3 packages in [TIME] + + anyio==4.3.0 + + pkg1==0.1.0 (from file://[TEMP_DIR]/pkg1) + + sniffio==1.3.1 + "); + + // Enabling the extra explicitly adds `idna==3.5` — the package that the bare + // `sync` above should have installed as well. Once the manifest-level path is + // fixed, the bare invocation should install this package without requiring + // `--extra=dev`. + uv_snapshot!(context.filters(), context.sync().arg("--frozen").arg("--extra=dev"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + idna==3.5 + "); + + Ok(()) +} + #[test] fn multiple_sources_index_disjoint_extras() -> Result<()> { let context = uv_test::test_context!("3.12").with_exclude_newer("2025-01-30T00:00Z"); From c75cab0433ed4cad7c9b8292ec136c1a44eee46a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 15:00:10 +0000 Subject: [PATCH 3/8] Restore TODO for remaining dependency-group self-extra edge cases The previous commits removed `TODO(charlie)` because the fix resolved the specific case in #19106, but the underlying concern is only partially addressed: within-group ordering and transitive self-extra chains are still mishandled. Restore a TODO (attributed to zanieb) describing the remaining cases, and align the attribution of the TODO on the manifest-level loop and the accompanying test. --- crates/uv-resolver/src/lock/installable.rs | 21 ++++++++++++++++--- .../uv/tests/lock_scenarios/lock_conflict.rs | 4 ++-- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/crates/uv-resolver/src/lock/installable.rs b/crates/uv-resolver/src/lock/installable.rs index c032e236e0c..2844c06b053 100644 --- a/crates/uv-resolver/src/lock/installable.rs +++ b/crates/uv-resolver/src/lock/installable.rs @@ -236,6 +236,21 @@ trait InstallableExt<'lock>: Installable<'lock> { // 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): This is still not fully correct — it only propagates self-extras + // activated directly by a group dep entry. Two related cases are not handled: + // + // 1. Ordering within the group-dep loop: if an earlier group dep's conflict marker + // references an extra activated by a later entry (in the same group, or in a + // later group by BTreeMap iteration order), the earlier entry is evaluated with + // an incomplete `activated_extras` and silently dropped. + // 2. Transitive self-extras: a group dep `pkg[a]` where `pkg.optional_dependencies.a` + // contains `pkg[b]` only activates `(pkg, b)` during the first-pass graph + // traversal below; any subsequent group dep whose marker needs `(pkg, b)` would + // see it as inactive. + // + // Truly resolving this likely requires iterating group-dep activation to a fixed + // point, or interleaving it with the first-pass traversal below. if has_conflicts { for dist in roots.iter().copied() { // Track the activated extras. @@ -452,13 +467,13 @@ trait InstallableExt<'lock>: Installable<'lock> { // Add any dependency groups that are exclusive to the workspace root (e.g., dev // dependencies in non-project workspace roots). // - // TODO(#19106): Like the package-level dependency-groups loop above, this loop + // TODO(zanieb): Like the package-level dependency-groups loop above, this loop // does not propagate self-extras activated by `pkg[extra]` entries into // `activated_extras`. As a result, conflict markers on transitive dependencies // that are gated by those self-extras evaluate to `false`, so `uv sync` silently // drops them. See the `group_activates_self_extra_non_project_workspace` test in - // `crates/uv/tests/it/lock_conflict.rs`, which currently captures the buggy - // behavior. + // `crates/uv/tests/it/lock_conflict.rs` (#19106), which currently captures the + // buggy behavior. for (group, dependency) in self .lock() .dependency_groups() diff --git a/crates/uv/tests/lock_scenarios/lock_conflict.rs b/crates/uv/tests/lock_scenarios/lock_conflict.rs index a4fb7e9d180..110d9808291 100644 --- a/crates/uv/tests/lock_scenarios/lock_conflict.rs +++ b/crates/uv/tests/lock_scenarios/lock_conflict.rs @@ -2865,10 +2865,10 @@ fn group_activates_self_extra() -> Result<()> { /// They should produce identical environments — the fix for the package-level path in /// #19106 needs to be applied to the manifest-level path as well. /// -/// TODO(#19106): Apply the same self-extra propagation fix to the manifest-level +/// TODO(zanieb): Apply the same self-extra propagation fix to the manifest-level /// dependency-groups loop in `crates/uv-resolver/src/lock/installable.rs` (the block /// iterating `self.lock().dependency_groups()`), then update this test so the two -/// snapshots match. +/// snapshots match. See #19106. #[test] fn group_activates_self_extra_non_project_workspace() -> Result<()> { let context = uv_test::test_context!("3.12"); From 2ea78f4e5c04340a59e369bad208dbc85c9b5a4c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 15:24:49 +0000 Subject: [PATCH 4/8] Propagate self-extras for manifest-level dependency groups Apply the same fix as for the package-level loop: when a group entry on the workspace manifest (used with non-project workspace roots) activates a self-extra via `pkg[extra]`, add `(pkg, extra)` to `activated_extras` so conflict markers on transitive dependencies evaluate correctly. Drop the TODO on the manifest-level loop and flip the `group_activates_self_extra_non_project_workspace` test to assert the correct behavior (bare `uv sync` now installs the gated transitive dep, matching `uv sync --extra=dev`). --- crates/uv-resolver/src/lock/installable.rs | 19 +++++----- .../uv/tests/lock_scenarios/lock_conflict.rs | 35 ++++++------------- 2 files changed, 21 insertions(+), 33 deletions(-) diff --git a/crates/uv-resolver/src/lock/installable.rs b/crates/uv-resolver/src/lock/installable.rs index 2844c06b053..f68fa02deef 100644 --- a/crates/uv-resolver/src/lock/installable.rs +++ b/crates/uv-resolver/src/lock/installable.rs @@ -466,14 +466,6 @@ trait InstallableExt<'lock>: Installable<'lock> { // Add any dependency groups that are exclusive to the workspace root (e.g., dev // dependencies in non-project workspace roots). - // - // TODO(zanieb): Like the package-level dependency-groups loop above, this loop - // does not propagate self-extras activated by `pkg[extra]` entries into - // `activated_extras`. As a result, conflict markers on transitive dependencies - // that are gated by those self-extras evaluate to `false`, so `uv sync` silently - // drops them. See the `group_activates_self_extra_non_project_workspace` test in - // `crates/uv/tests/it/lock_conflict.rs` (#19106), which currently captures the - // buggy behavior. for (group, dependency) in self .lock() .dependency_groups() @@ -537,6 +529,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 110d9808291..829fa3e81da 100644 --- a/crates/uv/tests/lock_scenarios/lock_conflict.rs +++ b/crates/uv/tests/lock_scenarios/lock_conflict.rs @@ -2856,19 +2856,9 @@ fn group_activates_self_extra() -> Result<()> { /// Companion to `group_activates_self_extra`, but for the *non-project workspace root* /// code path. When dependency groups live on the workspace manifest rather than on a /// project package (i.e. the root `pyproject.toml` has `[tool.uv.workspace]` but no -/// `[project]` table), group deps go through a different branch in -/// `Installable::to_resolution` that *also* fails to propagate self-extras activated by -/// `pkg[extra]` entries into `activated_extras`. -/// -/// This test currently captures the *buggy* behavior: `uv sync --frozen` installs three -/// packages (missing `idna==3.5`) while `uv sync --frozen --extra=dev` installs four. -/// They should produce identical environments — the fix for the package-level path in -/// #19106 needs to be applied to the manifest-level path as well. -/// -/// TODO(zanieb): Apply the same self-extra propagation fix to the manifest-level -/// dependency-groups loop in `crates/uv-resolver/src/lock/installable.rs` (the block -/// iterating `self.lock().dependency_groups()`), then update this test so the two -/// snapshots match. See #19106. +/// `[project]` table), group deps go through a separate branch in +/// `Installable::to_resolution`. This test asserts that `uv sync --frozen` (group +/// active by default) produces the same environment as `uv sync --frozen --extra=dev`. #[test] fn group_activates_self_extra_non_project_workspace() -> Result<()> { let context = uv_test::test_context!("3.12"); @@ -2918,35 +2908,30 @@ fn group_activates_self_extra_non_project_workspace() -> Result<()> { Resolved 5 packages in [TIME] "); - // BUG: activating the `dev` group (the default) should install `idna==3.5` via - // `anyio`'s conflict-gated edge, because the group references `pkg1[dev]`. - // Currently it is missing. + // 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"), @r" success: true exit_code: 0 ----- stdout ----- ----- stderr ----- - Prepared 3 packages in [TIME] - Installed 3 packages in [TIME] + 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 adds `idna==3.5` — the package that the bare - // `sync` above should have installed as well. Once the manifest-level path is - // fixed, the bare invocation should install this package without requiring - // `--extra=dev`. + // Enabling the extra explicitly produces the same environment. uv_snapshot!(context.filters(), context.sync().arg("--frozen").arg("--extra=dev"), @r" success: true exit_code: 0 ----- stdout ----- ----- stderr ----- - Prepared 1 package in [TIME] - Installed 1 package in [TIME] - + idna==3.5 + Checked 4 packages in [TIME] "); Ok(()) From 05c87e0d8a4845fdb76d308a54f179bd401ef4e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 21:34:57 +0000 Subject: [PATCH 5/8] Add tests for the two uncovered TODO cases in #19106 Add two integration tests, `group_activates_self_extra_ordering` and `group_activates_self_extra_transitive`, that pin the buggy behavior described in the `TODO(zanieb)` on the package-level dependency-groups loop in `installable.rs`: - Ordering: a group-dep entry whose marker needs an extra activated by a *later* entry is dropped because the earlier entry is evaluated with an incomplete `activated_extras` set. - Transitive: `project[a]` activates `(project, a)` directly, but `(project, b)` reached via `project.optional-dependencies.a = ["project[b]"]` is only populated during the first-pass traversal, after the group-dep loop. Both tests use hand-crafted lockfiles because the resolver tends to emit markers with vacuously-true clauses that mask the bug. They assert the current (buggy) output alongside the `--extra=` output that produces the correct environment, so that once the underlying TODO is fixed the two snapshots can be collapsed. --- .../uv/tests/lock_scenarios/lock_conflict.rs | 331 ++++++++++++++++++ 1 file changed, 331 insertions(+) diff --git a/crates/uv/tests/lock_scenarios/lock_conflict.rs b/crates/uv/tests/lock_scenarios/lock_conflict.rs index 829fa3e81da..41986d20cb6 100644 --- a/crates/uv/tests/lock_scenarios/lock_conflict.rs +++ b/crates/uv/tests/lock_scenarios/lock_conflict.rs @@ -2937,6 +2937,337 @@ fn group_activates_self_extra_non_project_workspace() -> Result<()> { Ok(()) } +/// Exercises the first uncovered case in the `TODO(zanieb)` in +/// `crates/uv-resolver/src/lock/installable.rs`: ordering within the group-dep loop. +/// +/// When a group dependency's conflict marker references an extra that is activated +/// by a *later* entry in the same group (or a later group in BTreeMap iteration order), +/// the earlier entry is evaluated with an incomplete `activated_extras` set and is +/// silently dropped. The fix for #19106 only propagates self-extras from entries that +/// precede the one being evaluated, so entries that sort alphabetically *before* their +/// activator are lost. +/// +/// The test uses a hand-crafted lockfile because the resolver generally emits markers +/// with vacuously-true clauses (e.g. `extra != 'x'`) that mask this bug; the shape +/// below is what a future resolver simplification could plausibly produce. +/// +/// TODO(zanieb): Fix by iterating the group-dep loop to a fixed point, or by +/// interleaving group-dep activation with the first-pass graph traversal. Once +/// fixed, the two snapshots below should match (both installing `iniconfig`). +#[test] +fn group_activates_self_extra_ordering() -> 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] + a = ["idna==3.5"] + b = ["idna==3.6"] + + [dependency-groups] + dev = ["project[a]", "iniconfig"] + + [tool.uv] + conflicts = [ + [{ extra = "a" }, { extra = "b" }], + ] + "#, + )?; + + // Hand-crafted lockfile: dev-deps are sorted alphabetically on load, so + // `iniconfig` (with a marker needing `(project, a)`) ends up before + // `project[a]` (the activator). The group-dep loop sees `iniconfig` first + // with `activated_extras=[]` and drops it. + context.temp_dir.child("uv.lock").write_str( + r#" + version = 1 + revision = 3 + requires-python = ">=3.12" + conflicts = [[ + { package = "project", extra = "a" }, + { package = "project", extra = "b" }, + ]] + + [options] + exclude-newer = "2024-03-25T00:00:00Z" + + [[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 = "iniconfig" + version = "2.0.0" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646, upload-time = "2023-01-07T11:08:11.254Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892, upload-time = "2023-01-07T11:08:09.864Z" }, + ] + + [[package]] + name = "project" + version = "0.1.0" + source = { virtual = "." } + + [package.optional-dependencies] + a = [ + { name = "idna", version = "3.5", source = { registry = "https://pypi.org/simple" } }, + ] + b = [ + { name = "idna", version = "3.6", source = { registry = "https://pypi.org/simple" } }, + ] + + [package.dev-dependencies] + dev = [ + { name = "iniconfig", marker = "extra == 'extra-7-project-a'" }, + { name = "project", extra = ["a"] }, + ] + + [package.metadata] + requires-dist = [ + { name = "idna", marker = "extra == 'a'", specifier = "==3.5" }, + { name = "idna", marker = "extra == 'b'", specifier = "==3.6" }, + ] + provides-extras = ["a", "b"] + + [package.metadata.requires-dev] + dev = [ + { name = "iniconfig" }, + { name = "project", extras = ["a"] }, + ] + "#, + )?; + + // BUG: activating the default group should install `iniconfig` via the + // group's `project[a]` entry (which activates `(project, a)`), but the + // marker-gated `iniconfig` entry is processed first and dropped. + uv_snapshot!(context.filters(), context.sync().arg("--frozen"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + idna==3.5 + "); + + // When `a` is enabled from the CLI, the CLI-supplied extra is already in + // `activated_extras` before the group-dep loop runs, so `iniconfig`'s + // marker passes and it is installed. + uv_snapshot!(context.filters(), context.sync().arg("--frozen").arg("--extra=a"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + iniconfig==2.0.0 + "); + + Ok(()) +} + +/// Exercises the second uncovered case in the `TODO(zanieb)` in +/// `crates/uv-resolver/src/lock/installable.rs`: transitive self-extras. +/// +/// A group dep `project[a]` activates `(project, a)` directly, but if +/// `project.optional_dependencies.a` contains `project[b]`, then `(project, b)` +/// is only activated during the first-pass graph traversal — *after* the +/// group-dep loop has already checked markers on every group dep. A subsequent +/// group dep whose marker requires `(project, b)` is evaluated too early and +/// silently dropped. +/// +/// As with `group_activates_self_extra_ordering`, this test uses a hand-crafted +/// lockfile because the resolver tends to emit markers with vacuously-true +/// clauses that mask the bug. +/// +/// TODO(zanieb): Fix alongside the ordering case above (fixed-point iteration +/// or interleaving the group-dep loop with the first-pass traversal). Once +/// fixed, the two snapshots below should match (both installing +/// `sortedcontainers`). +#[test] +fn group_activates_self_extra_transitive() -> 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] + a = ["project[b]"] + b = ["anyio"] + c = ["anyio", "idna==3.6"] + + [dependency-groups] + dev = ["project[a]", "sortedcontainers"] + + [tool.uv] + conflicts = [ + [{ extra = "b" }, { extra = "c" }], + ] + "#, + )?; + + // Hand-crafted lockfile. The group dep `project[a]` activates `(project, a)`, + // which transitively activates `(project, b)` via `project.optional_dependencies.a` + // — but only during the first-pass traversal, which runs *after* the group-dep + // loop. `sortedcontainers`'s marker requires `(project, b)` and is therefore + // evaluated with `activated_extras=[(project, a)]`, causing the entry to be + // dropped. + context.temp_dir.child("uv.lock").write_str( + r#" + version = 1 + revision = 3 + requires-python = ">=3.12" + conflicts = [[ + { package = "project", extra = "b" }, + { package = "project", extra = "c" }, + ]] + + [options] + exclude-newer = "2024-03-25T00:00:00Z" + + [[package]] + name = "anyio" + version = "4.3.0" + source = { registry = "https://pypi.org/simple" } + dependencies = [ + { name = "idna" }, + { 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.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] + a = [ + { name = "project", extra = ["b"] }, + ] + b = [ + { name = "anyio" }, + ] + c = [ + { name = "anyio" }, + { name = "idna" }, + ] + + [package.dev-dependencies] + dev = [ + { name = "project", extra = ["a"] }, + { name = "sortedcontainers", marker = "extra == 'extra-7-project-b'" }, + ] + + [package.metadata] + requires-dist = [ + { name = "anyio", marker = "extra == 'b'" }, + { name = "anyio", marker = "extra == 'c'" }, + { name = "idna", marker = "extra == 'c'", specifier = "==3.6" }, + { name = "project", extras = ["b"], marker = "extra == 'a'" }, + ] + provides-extras = ["a", "b", "c"] + + [package.metadata.requires-dev] + dev = [ + { name = "project", extras = ["a"] }, + { name = "sortedcontainers" }, + ] + + [[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" }, + ] + + [[package]] + name = "sortedcontainers" + version = "2.4.0" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, + ] + "#, + )?; + + // BUG: `sortedcontainers` should be installed because activating `project[a]` + // transitively activates `(project, b)`, which satisfies `sortedcontainers`'s + // marker. But that activation only happens in the first-pass traversal, after + // the group-dep loop has already rejected `sortedcontainers`. + uv_snapshot!(context.filters(), context.sync().arg("--frozen"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Prepared 3 packages in [TIME] + Installed 3 packages in [TIME] + + anyio==4.3.0 + + idna==3.6 + + sniffio==1.3.1 + "); + + // When `b` is enabled from the CLI, `(project, b)` is in `activated_extras` + // before the group-dep loop runs, so `sortedcontainers`'s marker passes. + uv_snapshot!(context.filters(), context.sync().arg("--frozen").arg("--extra=b"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + sortedcontainers==2.4.0 + "); + + Ok(()) +} + #[test] fn multiple_sources_index_disjoint_extras() -> Result<()> { let context = uv_test::test_context!("3.12").with_exclude_newer("2025-01-30T00:00Z"); From a6978a5334155de80e8761a25571d52d784f70fe Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Wed, 22 Apr 2026 17:38:05 -0400 Subject: [PATCH 6/8] Update comments --- .../uv/tests/lock_scenarios/lock_conflict.rs | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/crates/uv/tests/lock_scenarios/lock_conflict.rs b/crates/uv/tests/lock_scenarios/lock_conflict.rs index 41986d20cb6..ec496e1cce5 100644 --- a/crates/uv/tests/lock_scenarios/lock_conflict.rs +++ b/crates/uv/tests/lock_scenarios/lock_conflict.rs @@ -2669,14 +2669,7 @@ fn mixed() -> Result<()> { Ok(()) } -/// This tests a case where a dependency group references a "self-extra" (an optional -/// dependency of the project itself) and that extra also appears in a conflict set. Before -/// the fix for #19106, the group activation did not propagate the self-extra into the set of -/// activated extras used to evaluate conflict markers on transitive dependencies, so running -/// `uv sync` (which activates the group by default) would produce a different environment -/// than `uv sync --extra `, even though the two should be equivalent. -/// -/// Ref +/// See #[test] fn group_activates_self_extra() -> Result<()> { let context = uv_test::test_context!("3.12"); @@ -2853,12 +2846,9 @@ fn group_activates_self_extra() -> Result<()> { Ok(()) } -/// Companion to `group_activates_self_extra`, but for the *non-project workspace root* -/// code path. When dependency groups live on the workspace manifest rather than on a -/// project package (i.e. the root `pyproject.toml` has `[tool.uv.workspace]` but no -/// `[project]` table), group deps go through a separate branch in -/// `Installable::to_resolution`. This test asserts that `uv sync --frozen` (group -/// active by default) produces the same environment as `uv sync --frozen --extra=dev`. +/// 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"); From 22cb171ee56b57d515ca73dc779cc04cfd001e27 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 21:39:33 +0000 Subject: [PATCH 7/8] Drop synthetic tests for unreachable TODO cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two `group_activates_self_extra_ordering` and `group_activates_self_extra_transitive` tests relied on hand-crafted lockfiles because the resolver does not produce lockfile shapes that would trigger either code path. Tests against impossible inputs don't pin real behavior, so drop them. Reword the `TODO(zanieb)` on the package-level group-dep loop to make clear that these two structural soundness gaps are not reachable via lockfiles the resolver currently emits — it either produces self-extra markers (handled by `newly_activated_extras`) or markers with vacuously-true disjuncts — but should still be fixed for correctness if a future resolver change tightens marker simplification. --- crates/uv-resolver/src/lock/installable.rs | 23 +- .../uv/tests/lock_scenarios/lock_conflict.rs | 331 ------------------ 2 files changed, 12 insertions(+), 342 deletions(-) diff --git a/crates/uv-resolver/src/lock/installable.rs b/crates/uv-resolver/src/lock/installable.rs index f68fa02deef..9b251b51db7 100644 --- a/crates/uv-resolver/src/lock/installable.rs +++ b/crates/uv-resolver/src/lock/installable.rs @@ -237,20 +237,21 @@ trait InstallableExt<'lock>: Installable<'lock> { // later evaluate conflict markers on transitive dependencies, self-extras enabled by an // active group are treated as enabled. // - // TODO(zanieb): This is still not fully correct — it only propagates self-extras - // activated directly by a group dep entry. Two related cases are not handled: + // 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 within the group-dep loop: if an earlier group dep's conflict marker - // references an extra activated by a later entry (in the same group, or in a - // later group by BTreeMap iteration order), the earlier entry is evaluated with - // an incomplete `activated_extras` and silently dropped. + // 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` - // contains `pkg[b]` only activates `(pkg, b)` during the first-pass graph - // traversal below; any subsequent group dep whose marker needs `(pkg, b)` would - // see it as inactive. + // 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. // - // Truly resolving this likely requires iterating group-dep activation to a fixed - // point, or interleaving it with the first-pass traversal below. + // 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. diff --git a/crates/uv/tests/lock_scenarios/lock_conflict.rs b/crates/uv/tests/lock_scenarios/lock_conflict.rs index ec496e1cce5..6a9f8ac12fe 100644 --- a/crates/uv/tests/lock_scenarios/lock_conflict.rs +++ b/crates/uv/tests/lock_scenarios/lock_conflict.rs @@ -2927,337 +2927,6 @@ fn group_activates_self_extra_non_project_workspace() -> Result<()> { Ok(()) } -/// Exercises the first uncovered case in the `TODO(zanieb)` in -/// `crates/uv-resolver/src/lock/installable.rs`: ordering within the group-dep loop. -/// -/// When a group dependency's conflict marker references an extra that is activated -/// by a *later* entry in the same group (or a later group in BTreeMap iteration order), -/// the earlier entry is evaluated with an incomplete `activated_extras` set and is -/// silently dropped. The fix for #19106 only propagates self-extras from entries that -/// precede the one being evaluated, so entries that sort alphabetically *before* their -/// activator are lost. -/// -/// The test uses a hand-crafted lockfile because the resolver generally emits markers -/// with vacuously-true clauses (e.g. `extra != 'x'`) that mask this bug; the shape -/// below is what a future resolver simplification could plausibly produce. -/// -/// TODO(zanieb): Fix by iterating the group-dep loop to a fixed point, or by -/// interleaving group-dep activation with the first-pass graph traversal. Once -/// fixed, the two snapshots below should match (both installing `iniconfig`). -#[test] -fn group_activates_self_extra_ordering() -> 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] - a = ["idna==3.5"] - b = ["idna==3.6"] - - [dependency-groups] - dev = ["project[a]", "iniconfig"] - - [tool.uv] - conflicts = [ - [{ extra = "a" }, { extra = "b" }], - ] - "#, - )?; - - // Hand-crafted lockfile: dev-deps are sorted alphabetically on load, so - // `iniconfig` (with a marker needing `(project, a)`) ends up before - // `project[a]` (the activator). The group-dep loop sees `iniconfig` first - // with `activated_extras=[]` and drops it. - context.temp_dir.child("uv.lock").write_str( - r#" - version = 1 - revision = 3 - requires-python = ">=3.12" - conflicts = [[ - { package = "project", extra = "a" }, - { package = "project", extra = "b" }, - ]] - - [options] - exclude-newer = "2024-03-25T00:00:00Z" - - [[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 = "iniconfig" - version = "2.0.0" - source = { registry = "https://pypi.org/simple" } - sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646, upload-time = "2023-01-07T11:08:11.254Z" } - wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892, upload-time = "2023-01-07T11:08:09.864Z" }, - ] - - [[package]] - name = "project" - version = "0.1.0" - source = { virtual = "." } - - [package.optional-dependencies] - a = [ - { name = "idna", version = "3.5", source = { registry = "https://pypi.org/simple" } }, - ] - b = [ - { name = "idna", version = "3.6", source = { registry = "https://pypi.org/simple" } }, - ] - - [package.dev-dependencies] - dev = [ - { name = "iniconfig", marker = "extra == 'extra-7-project-a'" }, - { name = "project", extra = ["a"] }, - ] - - [package.metadata] - requires-dist = [ - { name = "idna", marker = "extra == 'a'", specifier = "==3.5" }, - { name = "idna", marker = "extra == 'b'", specifier = "==3.6" }, - ] - provides-extras = ["a", "b"] - - [package.metadata.requires-dev] - dev = [ - { name = "iniconfig" }, - { name = "project", extras = ["a"] }, - ] - "#, - )?; - - // BUG: activating the default group should install `iniconfig` via the - // group's `project[a]` entry (which activates `(project, a)`), but the - // marker-gated `iniconfig` entry is processed first and dropped. - uv_snapshot!(context.filters(), context.sync().arg("--frozen"), @r" - success: true - exit_code: 0 - ----- stdout ----- - - ----- stderr ----- - Prepared 1 package in [TIME] - Installed 1 package in [TIME] - + idna==3.5 - "); - - // When `a` is enabled from the CLI, the CLI-supplied extra is already in - // `activated_extras` before the group-dep loop runs, so `iniconfig`'s - // marker passes and it is installed. - uv_snapshot!(context.filters(), context.sync().arg("--frozen").arg("--extra=a"), @r" - success: true - exit_code: 0 - ----- stdout ----- - - ----- stderr ----- - Prepared 1 package in [TIME] - Installed 1 package in [TIME] - + iniconfig==2.0.0 - "); - - Ok(()) -} - -/// Exercises the second uncovered case in the `TODO(zanieb)` in -/// `crates/uv-resolver/src/lock/installable.rs`: transitive self-extras. -/// -/// A group dep `project[a]` activates `(project, a)` directly, but if -/// `project.optional_dependencies.a` contains `project[b]`, then `(project, b)` -/// is only activated during the first-pass graph traversal — *after* the -/// group-dep loop has already checked markers on every group dep. A subsequent -/// group dep whose marker requires `(project, b)` is evaluated too early and -/// silently dropped. -/// -/// As with `group_activates_self_extra_ordering`, this test uses a hand-crafted -/// lockfile because the resolver tends to emit markers with vacuously-true -/// clauses that mask the bug. -/// -/// TODO(zanieb): Fix alongside the ordering case above (fixed-point iteration -/// or interleaving the group-dep loop with the first-pass traversal). Once -/// fixed, the two snapshots below should match (both installing -/// `sortedcontainers`). -#[test] -fn group_activates_self_extra_transitive() -> 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] - a = ["project[b]"] - b = ["anyio"] - c = ["anyio", "idna==3.6"] - - [dependency-groups] - dev = ["project[a]", "sortedcontainers"] - - [tool.uv] - conflicts = [ - [{ extra = "b" }, { extra = "c" }], - ] - "#, - )?; - - // Hand-crafted lockfile. The group dep `project[a]` activates `(project, a)`, - // which transitively activates `(project, b)` via `project.optional_dependencies.a` - // — but only during the first-pass traversal, which runs *after* the group-dep - // loop. `sortedcontainers`'s marker requires `(project, b)` and is therefore - // evaluated with `activated_extras=[(project, a)]`, causing the entry to be - // dropped. - context.temp_dir.child("uv.lock").write_str( - r#" - version = 1 - revision = 3 - requires-python = ">=3.12" - conflicts = [[ - { package = "project", extra = "b" }, - { package = "project", extra = "c" }, - ]] - - [options] - exclude-newer = "2024-03-25T00:00:00Z" - - [[package]] - name = "anyio" - version = "4.3.0" - source = { registry = "https://pypi.org/simple" } - dependencies = [ - { name = "idna" }, - { 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.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] - a = [ - { name = "project", extra = ["b"] }, - ] - b = [ - { name = "anyio" }, - ] - c = [ - { name = "anyio" }, - { name = "idna" }, - ] - - [package.dev-dependencies] - dev = [ - { name = "project", extra = ["a"] }, - { name = "sortedcontainers", marker = "extra == 'extra-7-project-b'" }, - ] - - [package.metadata] - requires-dist = [ - { name = "anyio", marker = "extra == 'b'" }, - { name = "anyio", marker = "extra == 'c'" }, - { name = "idna", marker = "extra == 'c'", specifier = "==3.6" }, - { name = "project", extras = ["b"], marker = "extra == 'a'" }, - ] - provides-extras = ["a", "b", "c"] - - [package.metadata.requires-dev] - dev = [ - { name = "project", extras = ["a"] }, - { name = "sortedcontainers" }, - ] - - [[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" }, - ] - - [[package]] - name = "sortedcontainers" - version = "2.4.0" - source = { registry = "https://pypi.org/simple" } - sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } - wheels = [ - { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, - ] - "#, - )?; - - // BUG: `sortedcontainers` should be installed because activating `project[a]` - // transitively activates `(project, b)`, which satisfies `sortedcontainers`'s - // marker. But that activation only happens in the first-pass traversal, after - // the group-dep loop has already rejected `sortedcontainers`. - uv_snapshot!(context.filters(), context.sync().arg("--frozen"), @r" - success: true - exit_code: 0 - ----- stdout ----- - - ----- stderr ----- - Prepared 3 packages in [TIME] - Installed 3 packages in [TIME] - + anyio==4.3.0 - + idna==3.6 - + sniffio==1.3.1 - "); - - // When `b` is enabled from the CLI, `(project, b)` is in `activated_extras` - // before the group-dep loop runs, so `sortedcontainers`'s marker passes. - uv_snapshot!(context.filters(), context.sync().arg("--frozen").arg("--extra=b"), @r" - success: true - exit_code: 0 - ----- stdout ----- - - ----- stderr ----- - Prepared 1 package in [TIME] - Installed 1 package in [TIME] - + sortedcontainers==2.4.0 - "); - - Ok(()) -} - #[test] fn multiple_sources_index_disjoint_extras() -> Result<()> { let context = uv_test::test_context!("3.12").with_exclude_newer("2025-01-30T00:00Z"); From 5eec81cd712addf6898299d44d4446dd1fa00a7e Mon Sep 17 00:00:00 2001 From: Dennis Keck <26092524+fellhorn@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:13:30 +0200 Subject: [PATCH 8/8] Update snapshots --- .../uv/tests/lock_scenarios/lock_conflict.rs | 49 ++++++------------- 1 file changed, 14 insertions(+), 35 deletions(-) diff --git a/crates/uv/tests/lock_scenarios/lock_conflict.rs b/crates/uv/tests/lock_scenarios/lock_conflict.rs index 6a9f8ac12fe..35e92a623f5 100644 --- a/crates/uv/tests/lock_scenarios/lock_conflict.rs +++ b/crates/uv/tests/lock_scenarios/lock_conflict.rs @@ -2699,11 +2699,8 @@ fn group_activates_self_extra() -> Result<()> { "#, )?; - uv_snapshot!(context.filters(), context.lock(), @r" - success: true - exit_code: 0 - ----- stdout ----- - + uv_snapshot!(context.filters(), context.lock(), @" + exit_code: 0 (success) ----- stderr ----- Resolved 5 packages in [TIME] "); @@ -2809,22 +2806,16 @@ fn group_activates_self_extra() -> Result<()> { }); // Re-run with `--locked`. - uv_snapshot!(context.filters(), context.lock().arg("--locked"), @r" - success: true - exit_code: 0 - ----- stdout ----- - + 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"), @r" - success: true - exit_code: 0 - ----- stdout ----- - + uv_snapshot!(context.filters(), context.sync().arg("--frozen"), @" + exit_code: 0 (success) ----- stderr ----- Prepared 3 packages in [TIME] Installed 3 packages in [TIME] @@ -2834,11 +2825,8 @@ fn group_activates_self_extra() -> Result<()> { "); // Enabling the extra explicitly should produce the same environment. - uv_snapshot!(context.filters(), context.sync().arg("--frozen").arg("--extra=dev"), @r" - success: true - exit_code: 0 - ----- stdout ----- - + uv_snapshot!(context.filters(), context.sync().arg("--frozen").arg("--extra=dev"), @" + exit_code: 0 (success) ----- stderr ----- Checked 3 packages in [TIME] "); @@ -2889,22 +2877,16 @@ fn group_activates_self_extra_non_project_workspace() -> Result<()> { "#, )?; - uv_snapshot!(context.filters(), context.lock(), @r" - success: true - exit_code: 0 - ----- stdout ----- - + 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"), @r" - success: true - exit_code: 0 - ----- stdout ----- - + uv_snapshot!(context.filters(), context.sync().arg("--frozen"), @" + exit_code: 0 (success) ----- stderr ----- Prepared 4 packages in [TIME] Installed 4 packages in [TIME] @@ -2915,11 +2897,8 @@ fn group_activates_self_extra_non_project_workspace() -> Result<()> { "); // Enabling the extra explicitly produces the same environment. - uv_snapshot!(context.filters(), context.sync().arg("--frozen").arg("--extra=dev"), @r" - success: true - exit_code: 0 - ----- stdout ----- - + uv_snapshot!(context.filters(), context.sync().arg("--frozen").arg("--extra=dev"), @" + exit_code: 0 (success) ----- stderr ----- Checked 4 packages in [TIME] ");