Skip to content

Commit f49d6b4

Browse files
committed
uv-resolver: error on unconditional conflicting extras
If we don't do this, then it would be like permitting `uv sync --extra x1 --extra x2` even when `x1` and `x2` are declared as conflicting. Technically, we should only report an error when 2 or more conflicting extras are unconditionally enabled. Instead, here, we report an error if just 1 is found. The reason is that it seems tricky to detect all possible cases of 2 or more since I believe it would require looking at the full dependency tree.
1 parent ed12f93 commit f49d6b4

File tree

3 files changed

+189
-1
lines changed

3 files changed

+189
-1
lines changed

crates/uv-resolver/src/error.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ use tracing::trace;
1212
use uv_distribution_types::{
1313
BuiltDist, IndexCapabilities, IndexLocations, IndexUrl, InstalledDist, SourceDist,
1414
};
15-
use uv_normalize::PackageName;
15+
use uv_normalize::{ExtraName, PackageName};
1616
use uv_pep440::{LocalVersionSlice, Version};
1717
use uv_static::EnvVars;
1818

@@ -41,6 +41,13 @@ pub enum ResolveError {
4141
#[error("Attempted to wait on an unregistered task: `{_0}`")]
4242
UnregisteredTask(String),
4343

44+
#[error("Found conflicting extra `{extra}` unconditionally enabled in `{requirement}`")]
45+
ConflictingExtra {
46+
// Boxed because `Requirement` is large.
47+
requirement: Box<uv_pypi_types::Requirement>,
48+
extra: ExtraName,
49+
},
50+
4451
#[error("Overrides contain conflicting URLs for package `{0}`:\n- {1}\n- {2}")]
4552
ConflictingOverrideUrls(PackageName, String, String),
4653

crates/uv-resolver/src/resolver/mod.rs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1333,6 +1333,18 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
13331333
}
13341334
};
13351335

1336+
if let Some(err) =
1337+
find_conflicting_extra(&self.conflicting_groups, &metadata.requires_dist)
1338+
{
1339+
return Err(err);
1340+
}
1341+
for dependencies in metadata.dependency_groups.values() {
1342+
if let Some(err) =
1343+
find_conflicting_extra(&self.conflicting_groups, dependencies)
1344+
{
1345+
return Err(err);
1346+
}
1347+
}
13361348
let requirements = self.flatten_requirements(
13371349
&metadata.requires_dist,
13381350
&metadata.dependency_groups,
@@ -3075,3 +3087,36 @@ impl PartialOrd for Fork {
30753087
Some(self.cmp(other))
30763088
}
30773089
}
3090+
3091+
/// Returns an error if a conflicting extra is found in the given requirements.
3092+
///
3093+
/// Specifically, if there is any conflicting extra (just one is enough) that
3094+
/// is unconditionally enabled as part of a dependency specification, then this
3095+
/// returns an error.
3096+
///
3097+
/// The reason why we're so conservative here is because it avoids us needing
3098+
/// the look at the entire dependency tree at once.
3099+
///
3100+
/// For example, consider packages `root`, `a`, `b` and `c`, where `c` has
3101+
/// declared conflicting extras of `x1` and `x2`.
3102+
///
3103+
/// Now imagine `root` depends on `a` and `b`, `a` depends on `c[x1]` and `b`
3104+
/// depends on `c[x2]`. That's a conflict, but not easily detectable unless
3105+
/// you reject either `c[x1]` or `c[x2]` on the grounds that `x1` and `x2` are
3106+
/// conflicting and thus cannot be enabled unconditionally.
3107+
fn find_conflicting_extra(
3108+
conflicting: &ConflictingGroupList,
3109+
reqs: &[Requirement],
3110+
) -> Option<ResolveError> {
3111+
for req in reqs {
3112+
for extra in &req.extras {
3113+
if conflicting.contains(&req.name, extra) {
3114+
return Some(ResolveError::ConflictingExtra {
3115+
requirement: Box::new(req.clone()),
3116+
extra: extra.clone(),
3117+
});
3118+
}
3119+
}
3120+
}
3121+
None
3122+
}

crates/uv/tests/it/lock.rs

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3243,6 +3243,142 @@ fn lock_conflicting_extra_config_change_ignore_lockfile() -> Result<()> {
32433243
Ok(())
32443244
}
32453245

3246+
/// This tests that we report an error when a requirement unconditionally
3247+
/// enables a conflicting extra.
3248+
#[test]
3249+
fn lock_conflicting_extra_unconditional() -> Result<()> {
3250+
let context = TestContext::new("3.12");
3251+
3252+
let root_pyproject_toml = context.temp_dir.child("pyproject.toml");
3253+
root_pyproject_toml.write_str(
3254+
r#"
3255+
[project]
3256+
name = "dummy"
3257+
version = "0.1.0"
3258+
requires-python = "==3.12.*"
3259+
dependencies = [
3260+
"proxy1[project1,project2]"
3261+
]
3262+
3263+
[tool.uv]
3264+
conflicting-groups = [
3265+
[
3266+
{ package = "proxy1", extra = "project1" },
3267+
{ package = "proxy1", extra = "project2" },
3268+
],
3269+
]
3270+
3271+
[tool.uv.sources]
3272+
proxy1 = { path = "./proxy1" }
3273+
3274+
[build-system]
3275+
requires = ["hatchling"]
3276+
build-backend = "hatchling.build"
3277+
3278+
"#,
3279+
)?;
3280+
3281+
let proxy1_pyproject_toml = context.temp_dir.child("proxy1").child("pyproject.toml");
3282+
proxy1_pyproject_toml.write_str(
3283+
r#"
3284+
[project]
3285+
name = "proxy1"
3286+
version = "0.1.0"
3287+
requires-python = "==3.12.*"
3288+
dependencies = []
3289+
3290+
[project.optional-dependencies]
3291+
project1 = ["anyio==4.1.0"]
3292+
project2 = ["anyio==4.2.0"]
3293+
"#,
3294+
)?;
3295+
3296+
uv_snapshot!(context.filters(), context.lock(), @r###"
3297+
success: false
3298+
exit_code: 2
3299+
----- stdout -----
3300+
3301+
----- stderr -----
3302+
error: Found conflicting extra `project1` unconditionally enabled in `proxy1[project1,project2] @ file://[TEMP_DIR]/proxy1`
3303+
"###);
3304+
3305+
// An error should occur even when only one conflicting extra is enabled.
3306+
root_pyproject_toml.write_str(
3307+
r#"
3308+
[project]
3309+
name = "dummy"
3310+
version = "0.1.0"
3311+
requires-python = "==3.12.*"
3312+
dependencies = [
3313+
"proxy1[project1]"
3314+
]
3315+
3316+
[tool.uv]
3317+
conflicting-groups = [
3318+
[
3319+
{ package = "proxy1", extra = "project1" },
3320+
{ package = "proxy1", extra = "project2" },
3321+
],
3322+
]
3323+
3324+
[tool.uv.sources]
3325+
proxy1 = { path = "./proxy1" }
3326+
3327+
[build-system]
3328+
requires = ["hatchling"]
3329+
build-backend = "hatchling.build"
3330+
3331+
"#,
3332+
)?;
3333+
uv_snapshot!(context.filters(), context.lock(), @r###"
3334+
success: false
3335+
exit_code: 2
3336+
----- stdout -----
3337+
3338+
----- stderr -----
3339+
error: Found conflicting extra `project1` unconditionally enabled in `proxy1[project1] @ file://[TEMP_DIR]/proxy1`
3340+
"###);
3341+
3342+
// And same thing for the other extra.
3343+
root_pyproject_toml.write_str(
3344+
r#"
3345+
[project]
3346+
name = "dummy"
3347+
version = "0.1.0"
3348+
requires-python = "==3.12.*"
3349+
dependencies = [
3350+
"proxy1[project2]"
3351+
]
3352+
3353+
[tool.uv]
3354+
conflicting-groups = [
3355+
[
3356+
{ package = "proxy1", extra = "project1" },
3357+
{ package = "proxy1", extra = "project2" },
3358+
],
3359+
]
3360+
3361+
[tool.uv.sources]
3362+
proxy1 = { path = "./proxy1" }
3363+
3364+
[build-system]
3365+
requires = ["hatchling"]
3366+
build-backend = "hatchling.build"
3367+
3368+
"#,
3369+
)?;
3370+
uv_snapshot!(context.filters(), context.lock(), @r###"
3371+
success: false
3372+
exit_code: 2
3373+
----- stdout -----
3374+
3375+
----- stderr -----
3376+
error: Found conflicting extra `project2` unconditionally enabled in `proxy1[project2] @ file://[TEMP_DIR]/proxy1`
3377+
"###);
3378+
3379+
Ok(())
3380+
}
3381+
32463382
/// Show updated dependencies on `lock --upgrade`.
32473383
#[test]
32483384
fn lock_upgrade_log() -> Result<()> {

0 commit comments

Comments
 (0)