Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

re-enable conflicting extra/group tests and fix regression from #9540 #9582

Merged
merged 2 commits into from
Dec 2, 2024
Merged
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
32 changes: 29 additions & 3 deletions crates/uv-resolver/src/pubgrub/dependencies.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -26,14 +26,40 @@ 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>,
) -> impl Iterator<Item = Self> + 'a {
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.
Expand Down
14 changes: 12 additions & 2 deletions crates/uv-resolver/src/resolver/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1272,7 +1272,12 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
requirements
.iter()
.flat_map(|requirement| {
PubGrubDependency::from_requirement(requirement, None, None)
PubGrubDependency::from_requirement(
&self.conflicts,
requirement,
None,
None,
)
})
.collect()
}
Expand Down Expand Up @@ -1460,7 +1465,12 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
let mut dependencies: Vec<_> = 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();

Expand Down
113 changes: 100 additions & 13 deletions crates/uv/tests/it/lock_conflict.rs
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(())
}
3 changes: 3 additions & 0 deletions crates/uv/tests/it/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading