diff --git a/crates/uv-resolver/src/pubgrub/dependencies.rs b/crates/uv-resolver/src/pubgrub/dependencies.rs index 9e43628aae30e..21a49293d50a0 100644 --- a/crates/uv-resolver/src/pubgrub/dependencies.rs +++ b/crates/uv-resolver/src/pubgrub/dependencies.rs @@ -7,8 +7,8 @@ use tracing::warn; use uv_normalize::{ExtraName, GroupName, PackageName}; use uv_pep440::{Version, VersionSpecifiers}; use uv_pypi_types::{ - ParsedArchiveUrl, ParsedDirectoryUrl, ParsedGitUrl, ParsedPathUrl, ParsedUrl, Requirement, - RequirementSource, VerbatimParsedUrl, + Conflicts, ParsedArchiveUrl, ParsedDirectoryUrl, ParsedGitUrl, ParsedPathUrl, ParsedUrl, + Requirement, RequirementSource, VerbatimParsedUrl, }; use crate::pubgrub::{PubGrubPackage, PubGrubPackageInner}; @@ -26,6 +26,7 @@ pub(crate) struct PubGrubDependency { impl PubGrubDependency { pub(crate) fn from_requirement<'a>( + conflicts: &Conflicts, requirement: &'a Requirement, dev: Option<&'a GroupName>, source_name: Option<&'a PackageName>, @@ -33,7 +34,32 @@ impl PubGrubDependency { let iter = if requirement.extras.is_empty() { Either::Left(iter::once(None)) } else { - Either::Right(requirement.extras.clone().into_iter().map(Some)) + // This is crazy subtle, but if any of the extras in the + // requirement are part of a declared conflict, then we + // specifically need (at time of writing) to include the + // base package as a dependency. This results in both + // the base package and the extra package being sibling + // dependencies at the point in which forks are created + // base on conflicting extras. If the base package isn't + // present at that point, then it's impossible for the + // fork that excludes all conflicting extras to reach + // the non-extra dependency, which may be necessary for + // correctness. + // + // But why do we not include the base package in the first + // place? Well, that's part of an optimization[1]. + // + // [1]: https://github.com/astral-sh/uv/pull/9540 + let base = if requirement + .extras + .iter() + .any(|extra| conflicts.contains(&requirement.name, extra)) + { + Either::Left(iter::once(None)) + } else { + Either::Right(iter::empty()) + }; + Either::Right(base.chain(requirement.extras.clone().into_iter().map(Some))) }; // Add the package, plus any extra variants. diff --git a/crates/uv-resolver/src/resolver/mod.rs b/crates/uv-resolver/src/resolver/mod.rs index a3f519d305df1..91a9326f7794a 100644 --- a/crates/uv-resolver/src/resolver/mod.rs +++ b/crates/uv-resolver/src/resolver/mod.rs @@ -1272,7 +1272,12 @@ impl ResolverState ResolverState = requirements .iter() .flat_map(|requirement| { - PubGrubDependency::from_requirement(requirement, dev.as_ref(), Some(name)) + PubGrubDependency::from_requirement( + &self.conflicts, + requirement, + dev.as_ref(), + Some(name), + ) }) .collect(); diff --git a/crates/uv/tests/it/lock_conflict.rs b/crates/uv/tests/it/lock_conflict.rs index e7ae777780f36..35e5808b9c10e 100644 --- a/crates/uv/tests/it/lock_conflict.rs +++ b/crates/uv/tests/it/lock_conflict.rs @@ -1,16 +1,8 @@ use anyhow::Result; -use assert_cmd::assert::OutputAssertExt; use assert_fs::prelude::*; -use indoc::{formatdoc, indoc}; use insta::assert_snapshot; -use std::io::BufReader; -use url::Url; - -use crate::common::{ - self, build_vendor_links_url, decode_token, download_to_disk, packse_index_url, uv_snapshot, - venv_bin_path, TestContext, -}; -use uv_fs::Simplified; + +use crate::common::{uv_snapshot, TestContext}; use uv_static::EnvVars; // All of the tests in this file should use `tool.uv.conflicts` in some way. @@ -1329,7 +1321,7 @@ fn extra_nested_across_workspace() -> Result<()> { × No solution found when resolving dependencies: ╰─▶ Because dummy[extra2] depends on proxy1[extra2] and only proxy1[extra2]==0.1.0 is available, we can conclude that dummy[extra2] depends on proxy1[extra2]==0.1.0. (1) - Because proxy1[extra1]==0.1.0 depends on anyio==4.1.0 and proxy1[extra2]==0.1.0 depends on anyio==4.2.0, we can conclude that proxy1[extra1]==0.1.0 and proxy1[extra2]==0.1.0 are incompatible. + Because proxy1[extra2]==0.1.0 depends on anyio==4.2.0 and proxy1[extra1]==0.1.0 depends on anyio==4.1.0, we can conclude that proxy1[extra1]==0.1.0 and proxy1[extra2]==0.1.0 are incompatible. And because we know from (1) that dummy[extra2] depends on proxy1[extra2]==0.1.0, we can conclude that dummy[extra2] and proxy1[extra1]==0.1.0 are incompatible. And because only proxy1[extra1]==0.1.0 is available and dummysub[extra1] depends on proxy1[extra1], we can conclude that dummysub[extra1] and dummy[extra2] are incompatible. And because your workspace requires dummy[extra2] and dummysub[extra1], we can conclude that your workspace's requirements are unsatisfiable. @@ -1845,8 +1837,8 @@ fn mixed() -> Result<()> { ----- stderr ----- × No solution found when resolving dependencies: - ╰─▶ Because project:group1 depends on sortedcontainers==2.3.0 and project[extra1] depends on sortedcontainers==2.4.0, we can conclude that project[extra1] and project:group1 are incompatible. - And because your project depends on project:group1 and your project requires project[extra1], we can conclude that your projects's requirements are unsatisfiable. + ╰─▶ Because project:group1 depends on sortedcontainers==2.3.0 and your project depends on project:group1, we can conclude that your project depends on sortedcontainers==2.3.0. + And because project[extra1] depends on sortedcontainers==2.4.0 and your project requires project[extra1], we can conclude that your projects's requirements are unsatisfiable. "###); // And now with the same extra/group configuration, we tell uv @@ -2655,3 +2647,98 @@ fn multiple_sources_index_disjoint_extras_with_marker() -> Result<()> { Ok(()) } + +/// Tests that forks excluding both conflicting extras are handled correctly. +/// +/// This previously failed where running `uv sync` wouldn't install anything, +/// despite `sniffio` being an unconditional dependency. +#[test] +fn non_optional_dependency_extra() -> Result<()> { + let context = TestContext::new("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.11" + dependencies = [ + "sniffio>=1", + ] + + [project.optional-dependencies] + x1 = ["idna==3.5"] + x2 = ["idna==3.6"] + + [tool.uv] + conflicts = [ + [ + {package = "project", extra = "x1"}, + {package = "project", extra = "x2"}, + ], + ] + "#, + )?; + + uv_snapshot!(context.filters(), context.sync(), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 4 packages in [TIME] + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + sniffio==1.3.1 + "###); + + Ok(()) +} + +/// Like `non_optional_dependency_extra`, but for groups. +/// +/// This test never regressed, but we added it here to ensure it doesn't. +#[test] +fn non_optional_dependency_group() -> Result<()> { + let context = TestContext::new("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.11" + dependencies = [ + "sniffio>=1", + ] + + [dependency-groups] + g1 = ["idna==3.5"] + g2 = ["idna==3.6"] + + [tool.uv] + conflicts = [ + [ + {package = "project", group = "g1"}, + {package = "project", group = "g2"}, + ], + ] + "#, + )?; + + uv_snapshot!(context.filters(), context.sync(), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 4 packages in [TIME] + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + sniffio==1.3.1 + "###); + + Ok(()) +} diff --git a/crates/uv/tests/it/main.rs b/crates/uv/tests/it/main.rs index 74393449a10b9..1768dd5e7a4f1 100644 --- a/crates/uv/tests/it/main.rs +++ b/crates/uv/tests/it/main.rs @@ -34,6 +34,9 @@ mod init; #[cfg(all(feature = "python", feature = "pypi"))] mod lock; +#[cfg(all(feature = "python", feature = "pypi"))] +mod lock_conflict; + mod lock_scenarios; mod pip_check;