diff --git a/crates/uv-resolver/src/lock/mod.rs b/crates/uv-resolver/src/lock/mod.rs index 2821ae97ee1..b2d72cee67a 100644 --- a/crates/uv-resolver/src/lock/mod.rs +++ b/crates/uv-resolver/src/lock/mod.rs @@ -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}; @@ -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 { diff --git a/crates/uv/src/commands/project/export.rs b/crates/uv/src/commands/project/export.rs index be2e7351186..11489b14439 100644 --- a/crates/uv/src/commands/project/export.rs +++ b/crates/uv/src/commands/project/export.rs @@ -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. diff --git a/crates/uv/src/commands/project/install_target.rs b/crates/uv/src/commands/project/install_target.rs index 7197035a5c4..cc0add24cf0 100644 --- a/crates/uv/src/commands/project/install_target.rs +++ b/crates/uv/src/commands/project/install_target.rs @@ -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::>(); // Collect the packages by name for efficient lookup. @@ -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 { @@ -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))); } @@ -550,11 +574,24 @@ 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))); } @@ -562,15 +599,11 @@ impl<'lock> InstallTarget<'lock> { } } - 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()) } } } diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index 3e914f71eba..2924192f951 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -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, @@ -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 @@ -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 = vec![]; @@ -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 { diff --git a/crates/uv/src/commands/project/sync.rs b/crates/uv/src/commands/project/sync.rs index 8e1ba1bc86c..449e8f8f4f4 100644 --- a/crates/uv/src/commands/project/sync.rs +++ b/crates/uv/src/commands/project/sync.rs @@ -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() { diff --git a/crates/uv/tests/lock_scenarios/lock_conflict.rs b/crates/uv/tests/lock_scenarios/lock_conflict.rs index 265de2bc0b7..d6af2fb22b1 100644 --- a/crates/uv/tests/lock_scenarios/lock_conflict.rs +++ b/crates/uv/tests/lock_scenarios/lock_conflict.rs @@ -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( @@ -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(()) @@ -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(()) @@ -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 diff --git a/crates/uv/tests/project/export.rs b/crates/uv/tests/project/export.rs index 7481d33598c..8ad0cf2bbe0 100644 --- a/crates/uv/tests/project/export.rs +++ b/crates/uv/tests/project/export.rs @@ -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")] diff --git a/crates/uv/tests/sync/sync.rs b/crates/uv/tests/sync/sync.rs index 7cc62781a64..117f9d58883 100644 --- a/crates/uv/tests/sync/sync.rs +++ b/crates/uv/tests/sync/sync.rs @@ -8412,6 +8412,84 @@ fn no_binary_error() -> Result<()> { Ok(()) } +/// Dependency-activated extras must only participate in conflicts on an applicable platform. +#[test] +fn sync_transitive_extra_conflict_platform() -> Result<()> { + let context = uv_test::test_context!("3.12"); + let active_platform = if cfg!(windows) { + "win32" + } else if cfg!(target_os = "macos") { + "darwin" + } else { + "linux" + }; + let inactive_platform = if cfg!(windows) { "darwin" } else { "win32" }; + + let write_pyproject = |platform| { + context + .temp_dir + .child("pyproject.toml") + .write_str(&formatdoc! {r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["child[feature]; sys_platform == '{platform}'"] + + [dependency-groups] + dev = ["child"] + + [tool.uv] + conflicts = [[ + {{ group = "dev" }}, + {{ package = "child", extra = "feature" }}, + ]] + + [tool.uv.workspace] + members = ["child"] + + [tool.uv.sources] + child = {{ workspace = true }} + "#}) + }; + write_pyproject(inactive_platform)?; + 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 = [] + "#, + )?; + + uv_snapshot!(context.filters(), context.sync().arg("--group").arg("dev").arg("--no-install-workspace"), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 2 packages in [TIME] + Checked in [TIME] + "); + + write_pyproject(active_platform)?; + uv_snapshot!(context.filters(), context.sync().arg("--group").arg("dev").arg("--no-install-workspace"), @" + 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(()) +} + #[test] fn no_build() -> Result<()> { let context = uv_test::test_context!("3.12");