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
73 changes: 73 additions & 0 deletions crates/uv-resolver/src/lock/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use std::error::Error;
use std::fmt::{Debug, Display, Formatter};
use std::io;
use std::path::{Path, PathBuf};
use std::slice;
use std::str::FromStr;
use std::sync::{Arc, LazyLock};

Expand Down Expand Up @@ -4110,6 +4111,78 @@ impl Package {
&self.dependency_groups
}

/// Returns whether a resolved dependency applies to an environment using the original
/// requirement marker, when available.
pub fn dependency_applies_to_environment(
&self,
dependency: &Dependency,
marker_environment: &MarkerEnvironment,
extra: Option<&ExtraName>,
group: Option<&GroupName>,
) -> bool {
let requirements = group.map_or(Some(&self.metadata.requires_dist), |group| {
self.metadata.dependency_groups.get(group)
});
let Some(requirements) = requirements.filter(|requirements| !requirements.is_empty())
else {
return dependency
.complexified_marker
.pep508()
.evaluate(marker_environment, &[]);
};

let mut requirements = requirements
.iter()
.filter(|requirement| requirement.name == *dependency.package_name());
let Some(requirement) = requirements.next() else {
return dependency
.complexified_marker
.pep508()
.evaluate(marker_environment, &[]);
};

let extras: &[ExtraName] = extra.map_or(&[], slice::from_ref);
requirement.marker.evaluate(marker_environment, extras)
|| requirements
.any(|requirement| requirement.marker.evaluate(marker_environment, extras))
}

/// Returns the extras activated by a resolved dependency using the original requirement,
/// when available.
pub fn dependency_extras<'a>(
&'a self,
dependency: &'a Dependency,
marker_environment: Option<&MarkerEnvironment>,
extra: Option<&ExtraName>,
group: Option<&GroupName>,
) -> BTreeSet<&'a ExtraName> {
let requirements = group.map_or(Some(&self.metadata.requires_dist), |group| {
self.metadata.dependency_groups.get(group)
});
let Some(requirements) = requirements.filter(|requirements| !requirements.is_empty())
else {
return dependency.extra.iter().collect();
};

let mut requirements = requirements
.iter()
.filter(|requirement| requirement.name == *dependency.package_name());
let Some(requirement) = requirements.next() else {
return dependency.extra.iter().collect();
};

let selected_extras: &[ExtraName] = extra.map_or(&[], slice::from_ref);
std::iter::once(requirement)
.chain(requirements)
.filter(|requirement| {
requirement
.marker
.evaluate_optional_environment(marker_environment, selected_extras)
})
.flat_map(|requirement| requirement.extras.iter())
.collect()
}

/// Returns an [`InstallTarget`] view for filtering decisions.
fn as_install_target(&self) -> InstallTarget<'_> {
InstallTarget {
Expand Down
2 changes: 1 addition & 1 deletion crates/uv/src/commands/project/export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -348,7 +348,7 @@ pub(crate) async fn export(

// Skip conflict detection for CycloneDX exports, as SBOMs are meant to document all dependencies including conflicts.
if !matches!(format, ExportFormat::CycloneDX1_5) {
detect_conflicts(&target, &extras, &groups)?;
detect_conflicts(&target, &extras, &groups, None)?;
}

// If the user is exporting to PEP 751, ensure the filename matches the specification.
Expand Down
53 changes: 43 additions & 10 deletions crates/uv/src/commands/project/install_target.rs
Original file line number Diff line number Diff line change
Expand Up @@ -467,9 +467,16 @@ impl<'lock> InstallTarget<'lock> {
&self,
extras: &ExtrasSpecification,
groups: &DependencyGroupsWithDefaults,
) -> BTreeSet<&PackageName> {
marker_env: Option<&ResolverMarkerEnvironment>,
) -> (
BTreeSet<&PackageName>,
FxHashSet<(&PackageName, &ExtraName)>,
) {
match self {
Self::Project { lock, .. } | Self::Projects { lock, .. } => {
Self::Project { lock, .. }
| Self::Projects { lock, .. }
| Self::Workspace { lock, .. }
| Self::NonProjectWorkspace { lock, .. } => {
let roots = self.roots().collect::<FxHashSet<_>>();

// Collect the packages by name for efficient lookup.
Expand All @@ -488,6 +495,7 @@ impl<'lock> InstallTarget<'lock> {
// Find all workspace member dependencies recursively for all specified packages
let mut queue: VecDeque<(&PackageName, Option<&ExtraName>)> = VecDeque::new();
let mut seen: FxHashSet<(&PackageName, Option<&ExtraName>)> = FxHashSet::default();
let mut activated_extras = FxHashSet::default();

for name in roots {
let Some(root_package) = packages.get(name) else {
Expand Down Expand Up @@ -515,11 +523,27 @@ impl<'lock> InstallTarget<'lock> {
continue;
}
for dependency in dependencies {
if marker_env.is_some_and(|marker_env| {
!root_package.dependency_applies_to_environment(
dependency,
marker_env,
None,
Some(group_name),
)
}) {
continue;
}
let dep_name = dependency.package_name();
if seen.insert((dep_name, None)) {
queue.push_back((dep_name, None));
}
for extra in dependency.extra() {
for extra in root_package.dependency_extras(
dependency,
marker_env.map(ResolverMarkerEnvironment::markers),
None,
Some(group_name),
) {
activated_extras.insert((dep_name, extra));
if seen.insert((dep_name, Some(extra))) {
queue.push_back((dep_name, Some(extra)));
}
Expand Down Expand Up @@ -550,27 +574,36 @@ impl<'lock> InstallTarget<'lock> {
};

for dependency in dependencies {
if marker_env.is_some_and(|marker_env| {
!package.dependency_applies_to_environment(
dependency, marker_env, extra, None,
)
}) {
continue;
}
let name = dependency.package_name();
if seen.insert((name, None)) {
queue.push_back((name, None));
}
for extra in dependency.extra() {
for extra in package.dependency_extras(
dependency,
marker_env.map(ResolverMarkerEnvironment::markers),
extra,
None,
) {
activated_extras.insert((name, extra));
if seen.insert((name, Some(extra))) {
queue.push_back((name, Some(extra)));
}
}
}
}

required_members
}
Self::Workspace { lock, .. } | Self::NonProjectWorkspace { lock, .. } => {
// Return all workspace members
lock.members().iter().collect()
(required_members, activated_extras)
}
Self::Script { .. } => {
// Scripts don't have workspace members
BTreeSet::new()
(BTreeSet::new(), FxHashSet::default())
}
}
}
Expand Down
11 changes: 8 additions & 3 deletions crates/uv/src/commands/project/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@ use uv_normalize::{DEV_DEPENDENCIES, DefaultGroups, ExtraName, GroupName, Packag
use uv_pep440::{TildeVersionSpecifier, Version, VersionSpecifiers};
use uv_pep508::MarkerTreeContents;
use uv_preview::{Preview, PreviewFeature};
use uv_pypi_types::{ConflictItem, ConflictKind, ConflictSet, Conflicts};
use uv_pypi_types::{
ConflictItem, ConflictKind, ConflictSet, Conflicts, ResolverMarkerEnvironment,
};
use uv_python::managed::{ManagedPythonInstallation, PythonMinorVersionLink};
use uv_python::{
BrokenLink, EnvironmentPreference, Interpreter, InvalidEnvironmentKind,
Expand Down Expand Up @@ -3100,6 +3102,7 @@ pub(crate) fn detect_conflicts(
target: &InstallTarget,
extras: &ExtrasSpecification,
groups: &DependencyGroupsWithDefaults,
marker_env: Option<&ResolverMarkerEnvironment>,
) -> Result<(), ProjectError> {
// Validate that we aren't trying to install extras or groups that
// are declared as conflicting. Note that we need to collect all
Expand All @@ -3108,7 +3111,7 @@ pub(crate) fn detect_conflicts(
// group `g` are declared as conflicting, then enabling both of
// those should result in an error.
let lock = target.lock();
let packages = target.packages(extras, groups);
let (packages, activated_extras) = target.packages(extras, groups, marker_env);
let conflicts = lock.conflicts();
for set in conflicts.iter() {
let mut conflicts: Vec<ConflictItem> = vec![];
Expand All @@ -3119,7 +3122,9 @@ pub(crate) fn detect_conflicts(
}
let is_conflicting = match item.kind() {
ConflictKind::Project => groups.prod(),
ConflictKind::Extra(extra) => extras.contains(extra),
ConflictKind::Extra(extra) => {
extras.contains(extra) || activated_extras.contains(&(item.package(), extra))
}
ConflictKind::Group(group1) => groups.contains(group1),
};
if is_conflicting {
Expand Down
8 changes: 4 additions & 4 deletions crates/uv/src/commands/project/sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -718,16 +718,16 @@ pub(crate) async fn do_sync(
));
}

// Determine the markers to use for resolution.
let marker_env = resolution_markers(None, python_platform, venv.interpreter());

// Validate that the set of requested extras and development groups are compatible.
detect_conflicts(&target, extras, groups)?;
detect_conflicts(&target, extras, groups, Some(&marker_env))?;

// Validate that the set of requested extras and development groups are defined in the lockfile.
target.validate_extras(extras)?;
target.validate_groups(groups)?;

// Determine the markers to use for resolution.
let marker_env = resolution_markers(None, python_platform, venv.interpreter());

// Validate that the platform is supported by the lockfile.
let environments = target.lock().supported_environments();
if !environments.is_empty() {
Expand Down
8 changes: 4 additions & 4 deletions crates/uv/tests/lock_scenarios/lock_conflict.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1159,7 +1159,7 @@ fn extra_unconditional() -> Result<()> {
----- stdout -----

----- stderr -----
error: Found conflicting extras `proxy1[extra1]` and `proxy1[extra2]` enabled simultaneously
error: Extras `extra1` and `extra2` are incompatible with the declared conflicts: {`proxy1[extra1]`, `proxy1[extra2]`}
");

root_pyproject_toml.write_str(
Expand Down Expand Up @@ -1434,7 +1434,7 @@ fn extra_unconditional_in_optional() -> Result<()> {
----- stdout -----

----- stderr -----
error: Found conflicting extras `proxy1[nested-x1]` and `proxy1[nested-x2]` enabled simultaneously
error: Extras `nested-x1` and `nested-x2` are incompatible with the declared conflicts: {`proxy1[nested-x1]`, `proxy1[nested-x2]`}
");

Ok(())
Expand Down Expand Up @@ -1538,7 +1538,7 @@ fn extra_unconditional_non_local_conflict() -> Result<()> {
----- stdout -----

----- stderr -----
error: Found conflicting extras `c[x1]` and `c[x2]` enabled simultaneously
error: Extras `x1` and `x2` are incompatible with the declared conflicts: {`c[x1]`, `c[x2]`}
");

Ok(())
Expand Down Expand Up @@ -1971,7 +1971,7 @@ fn extra_depends_on_conflicting_extra_transitive() -> Result<()> {
----- stdout -----

----- stderr -----
error: Found conflicting extras `example[bar]` and `example[foo]` enabled simultaneously
error: Extras `bar` and `foo` are incompatible with the declared conflicts: {`example[bar]`, `example[foo]`}
");

// Install the child package
Expand Down
71 changes: 71 additions & 0 deletions crates/uv/tests/project/export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9408,6 +9408,77 @@ fn requirements_txt_conflicting_workspace_member_package() -> Result<()> {
Ok(())
}

/// A dependency-activated extra must participate in cross-kind conflict validation.
#[cfg(feature = "test-universal")]
#[test]
fn requirements_txt_transitive_extra_conflict() -> Result<()> {
let context = uv_test::test_context!("3.12");

context.temp_dir.child("pyproject.toml").write_str(
r#"
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["child[feature]"]

[dependency-groups]
dev = ["child"]

[tool.uv]
conflicts = [[
{ group = "dev" },
{ package = "child", extra = "feature" },
]]

[tool.uv.workspace]
members = ["child"]

[tool.uv.sources]
child = { workspace = true }
"#,
)?;
context.temp_dir.child("child/pyproject.toml").write_str(
r#"
[project]
name = "child"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = []

[project.optional-dependencies]
feature = []
"#,
)?;

context.lock().assert().success();

uv_snapshot!(context.filters(), context.export().arg("--group").arg("dev"), @"
success: false
exit_code: 2
----- stdout -----

----- stderr -----
Resolved 2 packages in [TIME]
error: Extra `feature` and group `dev` are incompatible with the declared conflicts: {`child[feature]`, `project:dev`}
");

uv_snapshot!(context.filters(), context.export()
.arg("--all-packages")
.arg("--group")
.arg("dev"), @"
success: false
exit_code: 2
----- stdout -----

----- stderr -----
Resolved 2 packages in [TIME]
error: Extra `feature` and group `dev` are incompatible with the declared conflicts: {`child[feature]`, `project:dev`}
");

Ok(())
}

/// Exporting the workspace root package should continue to include its dependencies
/// even when it conflicts with another workspace member.
#[cfg(feature = "test-universal")]
Expand Down
Loading
Loading