Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 39 additions & 5 deletions crates/uv-resolver/src/lock/installable.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,11 +69,26 @@ pub trait 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 !self.lock().conflicts().is_empty() {
for root_name in self.roots() {
let dist = self
Expand Down Expand Up @@ -213,6 +228,14 @@ pub trait 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.
if seen.insert((&dep.package_id, None)) {
queue.push_back((dep_dist, None));
Expand Down Expand Up @@ -330,6 +353,17 @@ pub trait 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.
if seen.insert((&dist.id, None)) {
queue.push_back((dist, None));
Expand Down
258 changes: 258 additions & 0 deletions crates/uv/tests/it/lock_conflict.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2616,6 +2616,264 @@ fn mixed() -> Result<()> {
Ok(())
}

/// See <https://github.com/astral-sh/uv/issues/19106>
#[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(())
}

/// 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(), @r"
success: true
exit_code: 0
----- stdout -----

----- 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 -----

----- 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"), @r"
success: true
exit_code: 0
----- stdout -----

----- 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");
Expand Down
Loading