From 28b5217ab1eb48a2016a8a897bd730ba8cd1975d Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Thu, 12 Mar 2026 19:38:07 -0400 Subject: [PATCH] Plumb source-aware dependency edges through forks --- .../src/metadata/build_requires.rs | 3 +- .../uv-resolver/src/pubgrub/dependencies.rs | 145 +- crates/uv-resolver/src/pubgrub/package.rs | 17 + .../src/resolver/dependency_builder.rs | 601 ++++++ crates/uv-resolver/src/resolver/fork_map.rs | 57 +- crates/uv-resolver/src/resolver/mod.rs | 43 +- crates/uv-resolver/src/universal_marker.rs | 14 + crates/uv/tests/it/export.rs | 4 +- crates/uv/tests/it/lock.rs | 1724 ++++++++++++++++- crates/uv/tests/it/pip_install.rs | 49 + 10 files changed, 2562 insertions(+), 95 deletions(-) create mode 100644 crates/uv-resolver/src/resolver/dependency_builder.rs diff --git a/crates/uv-distribution/src/metadata/build_requires.rs b/crates/uv-distribution/src/metadata/build_requires.rs index 8975a61c99..ede3e69923 100644 --- a/crates/uv-distribution/src/metadata/build_requires.rs +++ b/crates/uv-distribution/src/metadata/build_requires.rs @@ -188,6 +188,7 @@ impl BuildRequires { .unwrap_or(&empty); // Lower the requirements. + let project_name = metadata.name.clone(); let requires_dist = metadata.requires_dist.into_iter(); let requires_dist = requires_dist .flat_map(|requirement| { @@ -201,7 +202,7 @@ impl BuildRequires { LoweredRequirement::from_requirement( requirement, - None, + project_name.as_ref(), workspace.install_path(), project_sources, project_indexes, diff --git a/crates/uv-resolver/src/pubgrub/dependencies.rs b/crates/uv-resolver/src/pubgrub/dependencies.rs index 82dc238edc..0f8872291d 100644 --- a/crates/uv-resolver/src/pubgrub/dependencies.rs +++ b/crates/uv-resolver/src/pubgrub/dependencies.rs @@ -7,18 +7,19 @@ use pubgrub::Ranges; use uv_distribution_types::{IndexMetadata, Requirement, RequirementSource}; use uv_normalize::{ExtraName, GroupName, PackageName}; use uv_pep440::{Version, VersionSpecifiers}; -use uv_pep508::RequirementOrigin; +use uv_pep508::{MarkerTree, RequirementOrigin}; use uv_pypi_types::{ - ConflictItemRef, Conflicts, ParsedArchiveUrl, ParsedDirectoryUrl, ParsedGitUrl, ParsedPathUrl, - ParsedUrl, VerbatimParsedUrl, + ConflictItem, ConflictItemRef, Conflicts, ParsedArchiveUrl, ParsedDirectoryUrl, ParsedGitUrl, + ParsedPathUrl, ParsedUrl, VerbatimParsedUrl, }; use crate::pubgrub::{PubGrubPackage, PubGrubPackageInner}; +use crate::universal_marker::UniversalMarker; /// The source constraint carried by a single dependency edge. /// /// Most dependency edges are source-agnostic and use [`DependencySource::Unspecified`]. Direct -/// URLs and group-scoped explicit indexes use a concrete source so fork construction can keep +/// URLs and source-scoped explicit indexes use a concrete source so fork construction can keep /// that source information attached to the edge that introduced it. #[derive(Clone, Debug, Default, Eq, PartialEq)] pub(crate) enum DependencySource { @@ -28,7 +29,10 @@ pub(crate) enum DependencySource { /// The dependency was introduced by a direct URL-like requirement. Url(Box), /// The dependency was introduced by a requirement pinned to an explicit index. - ExplicitIndex(IndexMetadata), + ExplicitIndex { + index: IndexMetadata, + conflict: Option, + }, } impl DependencySource { @@ -38,23 +42,43 @@ impl DependencySource { /// explicit index. Direct URL-like requirements always preserve their verbatim URL. pub(crate) fn from_requirement(requirement: &Requirement) -> Self { match &requirement.source { - RequirementSource::Registry { index, .. } + RequirementSource::Registry { .. } if matches!( requirement.origin.as_ref(), Some(RequirementOrigin::Group(_, Some(_), _)) ) => { - index - .clone() - .map(Self::ExplicitIndex) - .unwrap_or(Self::Unspecified) + Self::from_source(&requirement.source) } RequirementSource::Registry { .. } => Self::Unspecified, RequirementSource::Url { .. } | RequirementSource::Git { .. } | RequirementSource::Path { .. } - | RequirementSource::Directory { .. } => requirement - .source + | RequirementSource::Directory { .. } => Self::from_source(&requirement.source), + } + } + + /// Derive the edge-local source constraint directly from a requirement source. + /// + /// This preserves every explicit source carried by `source`, including direct URLs and named + /// indexes. Use [`DependencySource::from_requirement`] for the normal dependency-lowering path, + /// where plain registry requirements remain source-agnostic unless their origin needs an + /// edge-local index. + pub(crate) fn from_source(source: &RequirementSource) -> Self { + match source { + RequirementSource::Registry { + index, conflict, .. + } => index + .clone() + .map(|index| Self::ExplicitIndex { + index, + conflict: conflict.clone(), + }) + .unwrap_or(Self::Unspecified), + RequirementSource::Url { .. } + | RequirementSource::Git { .. } + | RequirementSource::Path { .. } + | RequirementSource::Directory { .. } => source .to_verbatim_parsed_url() .map(Box::new) .map(Self::Url) @@ -66,17 +90,28 @@ impl DependencySource { pub(crate) fn verbatim_url(&self) -> Option<&VerbatimParsedUrl> { match self { Self::Url(url) => Some(url.as_ref()), - Self::Unspecified | Self::ExplicitIndex(_) => None, + Self::Unspecified | Self::ExplicitIndex { .. } => None, } } /// Return the explicit index attached to this source, if any. pub(crate) fn explicit_index(&self) -> Option<&IndexMetadata> { match self { - Self::ExplicitIndex(index) => Some(index), + Self::ExplicitIndex { index, .. } => Some(index), Self::Unspecified | Self::Url(_) => None, } } + + /// Return the conflict item attached to this source, if any. + fn conflicting_item(&self) -> Option> { + match self { + Self::ExplicitIndex { + conflict: Some(conflict), + .. + } => Some(conflict.as_ref()), + Self::Unspecified | Self::Url(_) | Self::ExplicitIndex { conflict: None, .. } => None, + } + } } #[derive(Clone, Debug, Eq, PartialEq)] @@ -166,8 +201,15 @@ impl PubGrubDependency { // Add the package, plus any extra variants. iter.map(move |(extra, group)| { - let pubgrub_requirement = - PubGrubRequirement::from_requirement(&requirement, extra, group); + let marker = Self::group_scoped_source_marker(&requirement, group_name, parent_name); + let preserve_marker = marker.is_some(); + let pubgrub_requirement = PubGrubRequirement::from_requirement_with_marker( + &requirement, + extra, + group, + marker.unwrap_or(requirement.marker), + preserve_marker, + ); let PubGrubRequirement { package, version, @@ -236,7 +278,31 @@ impl PubGrubDependency { /// If this package can't possibly be classified as conflicting, then this /// returns `None`. pub(crate) fn conflicting_item(&self) -> Option> { - self.package.conflicting_item() + self.source + .conflicting_item() + .or_else(|| self.package.conflicting_item()) + } + + /// Returns the group-scoped marker for a sourceful dependency-group edge. + fn group_scoped_source_marker( + requirement: &Requirement, + group_name: Option<&GroupName>, + parent_name: Option<&PackageName>, + ) -> Option { + if matches!( + requirement.source, + RequirementSource::Registry { index: None, .. } + ) { + return None; + } + + let group = group_name?; + let parent_name = parent_name?; + let conflict = ConflictItem::from((parent_name.clone(), group.clone())); + Some( + UniversalMarker::from_marker_and_conflict_item(requirement.marker, &conflict) + .combined(), + ) } } @@ -249,24 +315,25 @@ pub(crate) struct PubGrubRequirement { } impl PubGrubRequirement { - fn package_for_requirement( - requirement: &Requirement, - extra: Option, - group: Option, - ) -> PubGrubPackage { - PubGrubPackage::from_package(requirement.name.clone(), extra, group, requirement.marker) - } - /// Convert a [`Requirement`] to a PubGrub-compatible package and range, while returning the URL /// on the [`Requirement`], if any. - pub(crate) fn from_requirement( + fn from_requirement_with_marker( requirement: &Requirement, extra: Option, group: Option, + marker: MarkerTree, + preserve_marker: bool, ) -> Self { let (verbatim_url, parsed_url) = match &requirement.source { RequirementSource::Registry { specifier, .. } => { - return Self::from_registry_requirement(specifier, extra, group, requirement); + return Self::from_registry_requirement( + specifier, + extra, + group, + requirement, + marker, + preserve_marker, + ); } RequirementSource::Url { subdirectory, @@ -318,8 +385,10 @@ impl PubGrubRequirement { } }; + let package = Self::package(requirement, extra, group, marker, preserve_marker); + Self { - package: Self::package_for_requirement(requirement, extra, group), + package, version: Ranges::full(), source: DependencySource::Url(Box::new(VerbatimParsedUrl { parsed_url, @@ -333,11 +402,29 @@ impl PubGrubRequirement { extra: Option, group: Option, requirement: &Requirement, + marker: MarkerTree, + preserve_marker: bool, ) -> Self { + let package = Self::package(requirement, extra, group, marker, preserve_marker); + Self { - package: Self::package_for_requirement(requirement, extra, group), + package, source: DependencySource::from_requirement(requirement), version: Ranges::from(specifier.clone()), } } + + fn package( + requirement: &Requirement, + extra: Option, + group: Option, + marker: MarkerTree, + preserve_marker: bool, + ) -> PubGrubPackage { + if preserve_marker && extra.is_none() && group.is_none() { + PubGrubPackage::from_base_preserving_marker(requirement.name.clone(), marker) + } else { + PubGrubPackage::from_package(requirement.name.clone(), extra, group, marker) + } + } } diff --git a/crates/uv-resolver/src/pubgrub/package.rs b/crates/uv-resolver/src/pubgrub/package.rs index ecc0c9c1c4..dd294bf566 100644 --- a/crates/uv-resolver/src/pubgrub/package.rs +++ b/crates/uv-resolver/src/pubgrub/package.rs @@ -133,6 +133,23 @@ impl PubGrubPackage { } } + /// Create a base [`PubGrubPackage`] from a package name and marker, + /// preserving the marker expression as-is. + pub(crate) fn from_base_preserving_marker(name: PackageName, marker: MarkerTree) -> Self { + // Unlike `from_package`, preserve extra terms here because complementary + // source requirements encode group/extra conflict markers as extras. + if !marker.is_true() { + Self(Arc::new(PubGrubPackageInner::Marker { name, marker })) + } else { + Self(Arc::new(PubGrubPackageInner::Package { + name, + extra: None, + group: None, + marker, + })) + } + } + /// If this package is a proxy package, return the base package it depends on. /// /// While dependency groups may be attached to a package, we don't consider them here as diff --git a/crates/uv-resolver/src/resolver/dependency_builder.rs b/crates/uv-resolver/src/resolver/dependency_builder.rs new file mode 100644 index 0000000000..21a14fcf90 --- /dev/null +++ b/crates/uv-resolver/src/resolver/dependency_builder.rs @@ -0,0 +1,601 @@ +use std::borrow::Cow; +use std::collections::BTreeMap; +use std::slice; + +use pubgrub::Ranges; + +use uv_distribution_types::{Requirement, RequirementSource}; +use uv_normalize::{ExtraName, GroupName, PackageName}; +use uv_pep440::Version; +use uv_pep508::{ExtraOperator, MarkerExpression, MarkerOperator, MarkerTree, MarkerValueExtra}; +use uv_types::InstalledPackagesProvider; + +use crate::pubgrub::{DependencySource, PubGrubDependency, PubGrubPackage}; +use crate::python_requirement::PythonRequirement; +use crate::resolver::environment::ResolverEnvironment; +use crate::resolver::fork_map::ForkScope; + +use super::ResolverState; + +/// A requirement that should be represented as a complementary source-aware base dependency. +/// +/// This captures both the source that should be attached to the complementary dependency edge and +/// the source identity used to find an already-flattened sibling dependency in the root path. +struct ComplementarySourceRequirement<'a> { + requirement: &'a Requirement, + marker: MarkerTree, + version: Ranges, + attached_source: DependencySource, + flattened_marker: MarkerTree, + flattened_source: DependencySource, +} + +impl ComplementarySourceRequirement<'_> { + /// Returns the package name of the underlying requirement. + fn name(&self) -> &PackageName { + &self.requirement.name + } +} + +/// How a complementary source requirement should be applied to the dependency list. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ComplementarySourceAction { + /// Rewrite an already-flattened source-specific dependency into the complementary base edge. + RewriteFlattenedDependency, + /// Add a new complementary base dependency with the attached source constraint. + AddDependency, +} + +/// Builds the dependency edges emitted for a single package/fork pair. +/// +/// The builder first collects the dependencies produced by the normal flattening path, then +/// adjusts them to preserve source-aware edges that only appear in sibling extra or group forks. +pub(super) struct DependencyBuilder<'a, InstalledPackages: InstalledPackagesProvider> { + state: &'a ResolverState, + package: &'a PubGrubPackage, + env: &'a ResolverEnvironment, + python_requirement: &'a PythonRequirement, + deps: Vec, +} + +impl<'a, InstalledPackages: InstalledPackagesProvider> DependencyBuilder<'a, InstalledPackages> { + /// Creates a builder for the dependency edges emitted while resolving `package` in `env`. + pub(super) fn new( + state: &'a ResolverState, + package: &'a PubGrubPackage, + env: &'a ResolverEnvironment, + python_requirement: &'a PythonRequirement, + ) -> Self { + Self { + state, + package, + env, + python_requirement, + deps: Vec::new(), + } + } + + /// Flattens the given requirements into PubGrub dependencies and appends them to the builder. + pub(super) fn extend_requirements<'req>( + &mut self, + requirements: impl IntoIterator>, + group_name: Option<&'req GroupName>, + ) where + 'a: 'req, + { + self.deps + .extend(requirements.into_iter().flat_map(|requirement| { + PubGrubDependency::from_requirement( + &self.state.conflicts, + requirement, + group_name, + Some(self.package), + ) + })); + } + + /// Appends already-constructed dependencies to the builder. + pub(super) fn extend_dependencies( + &mut self, + deps: impl IntoIterator, + ) { + self.deps.extend(deps); + } + + /// Rewrites root dependencies whose source is only active in a sibling extra or group fork. + /// + /// Unlike non-root packages, root requirements have already been flattened from + /// `ResolverState::requirements`, so this pass mutates the already-added dependencies in place + /// instead of synthesizing new ones from raw metadata. + pub(super) fn rewrite_root_complementary_sources(&mut self) { + let python_marker = self.python_requirement.to_marker_tree(); + + for requirement in self.state.overrides.apply(self.state.requirements.iter()) { + let requirement: &Requirement = requirement.as_ref(); + let marker = ForkScope::from_requirement(requirement).marker(); + + for requirement in + self.complementary_source_requirements(requirement, marker, false, python_marker) + { + self.apply_complementary_source_requirement( + requirement, + ComplementarySourceAction::RewriteFlattenedDependency, + ); + } + } + } + + /// Adds complementary source-aware base dependencies for requirements that are absent from the + /// current fork but present in a sibling extra or dependency-group fork. + /// + /// Unlike the root path, non-root packages still have access to their raw metadata + /// requirements, so this pass inspects that metadata and synthesizes any missing + /// source-aware base dependencies. + pub(super) fn add_complementary_source_dependencies( + &mut self, + requirements: &[Requirement], + dependency_groups: &BTreeMap>, + ) { + let python_marker = self.python_requirement.to_marker_tree(); + + for requirement in self.state.overrides.apply(requirements.iter()) { + let raw_requirement = requirement.into_owned(); + if !self.can_synthesize_non_root_complementary_source(&raw_requirement.source) { + continue; + } + let marker = ForkScope::from_requirement(&raw_requirement).marker(); + let complementary_requirements = self.complementary_source_requirements( + &raw_requirement, + marker, + raw_requirement.evaluate_markers(self.env.marker_environment(), &[]), + python_marker, + ); + + for requirement in complementary_requirements { + let extra = Self::single_positive_extra(raw_requirement.marker); + let constraints = self.constraints_for_complementary_extra_source( + &raw_requirement, + requirement.marker, + extra.as_ref(), + python_marker, + ); + + if self.apply_complementary_source_requirement( + requirement, + ComplementarySourceAction::AddDependency, + ) { + self.extend_requirements(constraints.into_iter().map(Cow::Owned), None); + } + } + } + + let Some(parent_name) = self.package.name_no_root() else { + return; + }; + + for (group, requirements) in dependency_groups { + for requirement in self.state.overrides.apply(requirements.iter()) { + let raw_requirement = requirement.into_owned(); + if !self.can_synthesize_non_root_complementary_source(&raw_requirement.source) { + continue; + } + let marker = + ForkScope::from_group(raw_requirement.marker, parent_name, group).marker(); + + let complementary_requirements = self.complementary_source_requirements( + &raw_requirement, + marker, + false, + python_marker, + ); + + for requirement in complementary_requirements { + let split_requirement = + Self::requirement_with_marker(&raw_requirement, requirement.marker); + let constraints = + self.constraints_for_requirement(&split_requirement, None, python_marker); + + if self.apply_complementary_source_requirement( + requirement, + ComplementarySourceAction::AddDependency, + ) { + self.extend_requirements(constraints.into_iter().map(Cow::Owned), None); + } + } + } + } + } + + /// Applies the complementary-source split for `requirement`. + /// + /// Both root and non-root packages narrow the existing unsourced base dependency by excluding + /// `requirement.marker`. They differ only in whether the complementary dependency already + /// exists in flattened form (`RewriteFlattenedDependency`) or must be synthesized + /// (`AddDependency`). + fn apply_complementary_source_requirement( + &mut self, + requirement: ComplementarySourceRequirement<'_>, + action: ComplementarySourceAction, + ) -> bool { + let name = requirement.name().clone(); + let parent = self.package.name_no_root().cloned(); + + let Some(base_index) = self.find_unsourced_base_index(&name) else { + if action == ComplementarySourceAction::RewriteFlattenedDependency { + return self.add_root_unsourced_complement(requirement, name, parent); + } + return false; + }; + + if action == ComplementarySourceAction::RewriteFlattenedDependency { + let Some(flattened_index) = self.find_source_index( + &name, + &requirement.flattened_source, + requirement.flattened_marker, + ) else { + return false; + }; + + self.deps[flattened_index].package = + PubGrubPackage::from_base_preserving_marker(name.clone(), requirement.marker); + } + + if self.deps[base_index].package.marker().is_false() { + self.deps[base_index].package = PubGrubPackage::from_base_preserving_marker( + name.clone(), + requirement.marker.negate(), + ); + } else { + Self::exclude_marker_from_base(&mut self.deps[base_index], &name, requirement.marker); + } + + if action == ComplementarySourceAction::AddDependency { + self.deps.push(PubGrubDependency { + package: PubGrubPackage::from_base_preserving_marker(name, requirement.marker), + version: requirement.version, + parent, + source: requirement.attached_source, + }); + } + + true + } + + /// Adds the unsourced side of a root complementary-source split when root flattening only + /// emitted the sourced edge. + fn add_root_unsourced_complement( + &mut self, + requirement: ComplementarySourceRequirement<'_>, + name: PackageName, + parent: Option, + ) -> bool { + let Some(flattened_index) = self.find_source_index( + &name, + &requirement.flattened_source, + requirement.flattened_marker, + ) else { + return false; + }; + + let Some((base_marker, base_version)) = + self.root_unsourced_complement(&name, requirement.marker) + else { + return false; + }; + + self.deps[flattened_index].package = + PubGrubPackage::from_base_preserving_marker(name.clone(), requirement.marker); + self.deps.push(PubGrubDependency { + package: PubGrubPackage::from_base_preserving_marker(name, base_marker), + version: base_version, + parent, + source: DependencySource::Unspecified, + }); + + true + } + + /// Returns the normalized complementary-source representations for `requirement`, if needed in + /// the current fork. + fn complementary_source_requirements<'req>( + &self, + requirement: &'req Requirement, + marker: MarkerTree, + included_in_fork: bool, + python_marker: MarkerTree, + ) -> Vec> { + // Already included via `flatten_requirements`. + if included_in_fork { + return Vec::new(); + } + // Only explicit sources (URL or named index) have per-fork source + // state that can leak. + if matches!( + requirement.source, + RequirementSource::Registry { index: None, .. } + ) { + return Vec::new(); + } + // Requirements with requested extras/groups are handled by the + // existing Extra/Group machinery. + if !requirement.extras.is_empty() || !requirement.groups.is_empty() { + return Vec::new(); + } + if self.state.excludes.contains(&requirement.name) { + return Vec::new(); + } + // This path is specifically for extra/group-gated source splits. + if marker.only_extras().is_true() { + return Vec::new(); + } + Self::split_complementary_markers(marker) + .into_iter() + .filter(|marker| !python_marker.is_disjoint(*marker)) + .filter(|marker| self.env.included_by_marker(*marker)) + .map(|marker| ComplementarySourceRequirement { + requirement, + marker, + version: Self::version_for_requirement(requirement), + attached_source: DependencySource::from_source(&requirement.source), + flattened_marker: requirement.marker, + flattened_source: DependencySource::from_requirement(requirement), + }) + .collect() + } + + /// Returns the version range implied by a complementary requirement. + fn version_for_requirement(requirement: &Requirement) -> Ranges { + match &requirement.source { + RequirementSource::Registry { specifier, .. } => Ranges::from(specifier.clone()), + RequirementSource::Url { .. } + | RequirementSource::Git { .. } + | RequirementSource::Path { .. } + | RequirementSource::Directory { .. } => Ranges::full(), + } + } + + /// Returns `true` when a non-root complementary dependency can be synthesized for `source`. + /// + /// Direct URL-like sources are validated against root requirements and constraints. Recreating + /// them from package metadata would turn them into disallowed transitive URL dependencies. + fn can_synthesize_non_root_complementary_source(&self, source: &RequirementSource) -> bool { + if matches!(source, RequirementSource::Registry { index: Some(_), .. }) { + return true; + } + + let Some(package_name) = self.package.name_no_root() else { + return false; + }; + + self.state.project.as_ref() == Some(package_name) + || self.state.workspace_members.contains(package_name) + } + + /// Returns the positive extra referenced by `marker`, if it names exactly one extra. + fn single_positive_extra(marker: MarkerTree) -> Option { + let mut extra = None; + let mut has_negative = false; + let mut has_multiple = false; + + marker.visit_extras(|operator, candidate| match operator { + MarkerOperator::Equal => match &extra { + Some(extra) if extra != candidate => has_multiple = true, + None => extra = Some(candidate.clone()), + Some(_) => {} + }, + MarkerOperator::NotEqual => has_negative = true, + _ => {} + }); + + if has_negative || has_multiple { + return None; + } + + extra + } + + /// Returns the marker split for a complementary dependency. + /// + /// When the source applies to multiple sibling extra or group forks, emit one complementary + /// edge per fork instead of a single marker spanning all of them. + fn split_complementary_markers(marker: MarkerTree) -> Vec { + let mut extras = Vec::new(); + + marker.visit_extras(|operator, candidate| { + if operator == MarkerOperator::Equal && !extras.contains(candidate) { + extras.push(candidate.clone()); + } + }); + + if extras.len() <= 1 { + return vec![marker]; + } + + let mut split_markers = Vec::new(); + for extra in extras { + let mut split_marker = marker; + split_marker.and(Self::extra_marker(&extra)); + if !split_marker.is_false() && !split_markers.contains(&split_marker) { + split_markers.push(split_marker); + } + } + + split_markers + } + + /// Returns a marker that activates only the given extra or encoded group conflict item. + fn extra_marker(extra: &ExtraName) -> MarkerTree { + MarkerTree::expression(MarkerExpression::Extra { + operator: ExtraOperator::Equal, + name: MarkerValueExtra::Extra(extra.clone()), + }) + } + + /// Returns the constraints that must be present in the sibling extra or group fork for + /// `requirement`. + fn constraints_for_requirement( + &self, + requirement: &Requirement, + extra: Option<&ExtraName>, + python_marker: MarkerTree, + ) -> Vec { + self.state + .constraints_for_requirement( + Cow::Borrowed(requirement), + extra, + self.env, + python_marker, + self.python_requirement, + ) + .map(Cow::into_owned) + .collect() + } + + /// Returns constraints for an extra-gated complementary source dependency. + /// + /// Source extra markers are encoded as conflict markers on the synthesized dependency edge. + /// Root constraints, however, are authored against the raw extra name. Select constraints using + /// the raw requirement marker, then emit them under the encoded marker for this fork. + fn constraints_for_complementary_extra_source( + &self, + raw_requirement: &Requirement, + marker: MarkerTree, + extra: Option<&ExtraName>, + python_marker: MarkerTree, + ) -> Vec { + let Some(extra) = extra else { + let split_requirement = Self::requirement_with_marker(raw_requirement, marker); + return self.constraints_for_requirement(&split_requirement, None, python_marker); + }; + + let Some(constraints) = self.state.constraints.get(&raw_requirement.name) else { + return Vec::new(); + }; + + constraints + .iter() + .filter_map(|constraint| { + let mut raw_marker = constraint.marker; + raw_marker.and(raw_requirement.marker); + if raw_marker.is_false() { + return None; + } + + if !constraint + .evaluate_markers(self.env.marker_environment(), slice::from_ref(extra)) + { + return None; + } + + let mut scoped_marker = raw_marker.without_extras(); + scoped_marker.and(marker); + if scoped_marker.is_false() + || python_marker.is_disjoint(scoped_marker) + || !self.env.included_by_marker(scoped_marker) + { + return None; + } + + Some(Self::requirement_with_marker(constraint, scoped_marker)) + }) + .collect() + } + + /// Returns the root source-agnostic requirement that covers the complement of a sourced edge. + fn root_unsourced_complement( + &self, + name: &PackageName, + source_marker: MarkerTree, + ) -> Option<(MarkerTree, Ranges)> { + let complement = source_marker.negate(); + let python_marker = self.python_requirement.to_marker_tree(); + + self.state + .overrides + .apply(self.state.requirements.iter()) + .filter_map(|requirement| { + let requirement: &Requirement = requirement.as_ref(); + + if &requirement.name != name { + return None; + } + if !matches!( + requirement.source, + RequirementSource::Registry { index: None, .. } + ) { + return None; + } + if !requirement.extras.is_empty() || !requirement.groups.is_empty() { + return None; + } + if self.state.excludes.contains(&requirement.name) { + return None; + } + + let mut marker = requirement.marker; + marker.and(complement); + if marker.is_false() + || python_marker.is_disjoint(marker) + || !self.env.included_by_marker(marker) + { + return None; + } + + Some((marker, Self::version_for_requirement(requirement))) + }) + .next() + } + + /// Clones a requirement with a replacement marker. + fn requirement_with_marker(requirement: &Requirement, marker: MarkerTree) -> Requirement { + Requirement { + name: requirement.name.clone(), + extras: requirement.extras.clone(), + groups: requirement.groups.clone(), + source: requirement.source.clone(), + origin: requirement.origin.clone(), + marker, + } + } + + /// Removes `marker` from the existing unsourced base dependency for `name`. + fn exclude_marker_from_base( + base: &mut PubGrubDependency, + name: &PackageName, + marker: MarkerTree, + ) { + let mut base_marker = base.package.marker(); + base_marker.and(marker.negate()); + base.package = PubGrubPackage::from_base_preserving_marker(name.clone(), base_marker); + } + + /// Returns the index of the dependency for `name` whose package marker and edge-local source + /// exactly match. + fn find_source_index( + &self, + name: &PackageName, + source: &DependencySource, + marker: MarkerTree, + ) -> Option { + self.deps.iter().position(|dep| { + dep.package.name() == Some(name) + && dep.package.marker() == marker + && &dep.source == source + }) + } + + /// Returns the index of the plain base dependency for `name`, if it exists. + fn find_unsourced_base_index(&self, name: &PackageName) -> Option { + self.deps.iter().position(|dep| { + dep.package.name() == Some(name) + && dep.package.extra().is_none() + && dep.package.group().is_none() + && matches!(&dep.source, DependencySource::Unspecified) + }) + } + + /// Returns the accumulated dependency edges. + pub(super) fn finish(self) -> Vec { + self.deps + } +} diff --git a/crates/uv-resolver/src/resolver/fork_map.rs b/crates/uv-resolver/src/resolver/fork_map.rs index 9a1d7bf53a..cd320fb51e 100644 --- a/crates/uv-resolver/src/resolver/fork_map.rs +++ b/crates/uv-resolver/src/resolver/fork_map.rs @@ -1,12 +1,12 @@ use rustc_hash::FxHashMap; use uv_distribution_types::{Requirement, RequirementSource}; -use uv_normalize::PackageName; +use uv_normalize::{GroupName, PackageName}; use uv_pep508::{MarkerTree, RequirementOrigin}; -use uv_pypi_types::{ConflictItem, ConflictItemRef, ConflictKind}; +use uv_pypi_types::{ConflictItem, ConflictItemRef}; use crate::ResolverEnvironment; -use crate::universal_marker::{ConflictMarker, UniversalMarker}; +use crate::universal_marker::UniversalMarker; /// A set of package names associated with a given fork. pub(crate) type ForkSet = ForkMap<()>; @@ -24,31 +24,45 @@ struct Entry { /// The fork visibility of an entry. #[derive(Debug, Clone, Eq, PartialEq)] -struct ForkScope { +pub(super) struct ForkScope { marker: MarkerTree, conflict: Option, } impl ForkScope { - /// Derive the scope under which a requirement should be visible in forked resolution. - /// - /// Group conflicts are folded into the marker so group-scoped entries only appear in forks - /// where that group is active. - fn from_requirement(requirement: &Requirement) -> Self { + /// Derives the fork scope implied by a requirement's marker and conflict state. + pub(super) fn from_requirement(requirement: &Requirement) -> Self { let conflict = Self::conflict_for_requirement(requirement); - let marker = conflict - .as_ref() - .filter(|conflict_item| matches!(conflict_item.kind(), ConflictKind::Group(_))) - .map_or(requirement.marker, |conflict_item| { - UniversalMarker::new( - requirement.marker.without_extras(), - ConflictMarker::from_conflict_item(conflict_item), - ) - .combined() - }); + let marker = conflict.as_ref().map_or(requirement.marker, |conflict| { + Self::marker_with_conflict(requirement.marker, conflict) + }); Self { marker, conflict } } + /// Derives a fork scope for a dependency-group requirement. + pub(super) fn from_group( + marker: MarkerTree, + project_name: &PackageName, + group: &GroupName, + ) -> Self { + let conflict = ConflictItem::from((project_name.clone(), group.clone())); + let marker = Self::marker_with_conflict(marker, &conflict); + Self { + marker, + conflict: Some(conflict), + } + } + + /// Returns the marker under which the entry is visible. + pub(super) fn marker(&self) -> MarkerTree { + self.marker + } + + /// Returns the conflict item that must remain enabled for this scope to match, if any. + fn conflict(&self) -> Option> { + self.conflict.as_ref().map(ConflictItem::as_ref) + } + fn conflict_for_requirement(requirement: &Requirement) -> Option { let conflict = match &requirement.source { RequirementSource::Registry { conflict, .. } => conflict.clone(), @@ -65,9 +79,8 @@ impl ForkScope { }) } - /// Return the conflict item that further restricts this scope, if any. - fn conflict(&self) -> Option> { - self.conflict.as_ref().map(ConflictItem::as_ref) + fn marker_with_conflict(marker: MarkerTree, conflict: &ConflictItem) -> MarkerTree { + UniversalMarker::from_marker_and_conflict_item(marker, conflict).combined() } fn matches(&self, env: &ResolverEnvironment) -> bool { diff --git a/crates/uv-resolver/src/resolver/mod.rs b/crates/uv-resolver/src/resolver/mod.rs index fd69b71aba..c9403b4f41 100644 --- a/crates/uv-resolver/src/resolver/mod.rs +++ b/crates/uv-resolver/src/resolver/mod.rs @@ -62,6 +62,7 @@ pub(crate) use crate::resolver::availability::{ UnavailableVersion, }; use crate::resolver::batch_prefetch::BatchPrefetcher; +use crate::resolver::dependency_builder::DependencyBuilder; pub use crate::resolver::derivation::DerivationChainBuilder; pub use crate::resolver::environment::ResolverEnvironment; use crate::resolver::environment::{ @@ -84,6 +85,7 @@ pub(crate) use provider::MetadataUnavailable; mod availability; mod batch_prefetch; +mod dependency_builder; mod derivation; mod environment; mod fork_map; @@ -1789,16 +1791,10 @@ impl ResolverState ResolverState return Ok(Dependencies::Unforkable(Vec::default())), diff --git a/crates/uv-resolver/src/universal_marker.rs b/crates/uv-resolver/src/universal_marker.rs index cf26e4e185..c235bed091 100644 --- a/crates/uv-resolver/src/universal_marker.rs +++ b/crates/uv-resolver/src/universal_marker.rs @@ -100,6 +100,20 @@ impl UniversalMarker { Self::from_combined(pep508_marker) } + /// Creates a new universal marker from a PEP 508 marker and a conflict item. + /// + /// Any `extra` terms in `pep508_marker` are treated as source extras and are replaced by the + /// encoded conflict item. + pub(crate) fn from_marker_and_conflict_item( + pep508_marker: MarkerTree, + conflict: &ConflictItem, + ) -> Self { + Self::new( + pep508_marker.without_extras(), + ConflictMarker::from_conflict_item(conflict), + ) + } + /// Creates a new universal marker from a marker that has already been /// combined from a PEP 508 and conflict marker. pub(crate) fn from_combined(marker: MarkerTree) -> Self { diff --git a/crates/uv/tests/it/export.rs b/crates/uv/tests/it/export.rs index c463bc17e7..e79f829d05 100644 --- a/crates/uv/tests/it/export.rs +++ b/crates/uv/tests/it/export.rs @@ -3427,7 +3427,7 @@ fn requirements_txt_complex_conflict_markers() -> Result<()> { # via torch ----- stderr ----- - Resolved 33 packages in [TIME] + Resolved 34 packages in [TIME] "); uv_snapshot!(context.filters(), context.export().arg("--extra").arg("cpu"), @r" @@ -3581,7 +3581,7 @@ fn requirements_txt_complex_conflict_markers() -> Result<()> { # via torch ----- stderr ----- - Resolved 33 packages in [TIME] + Resolved 34 packages in [TIME] "); Ok(()) diff --git a/crates/uv/tests/it/lock.rs b/crates/uv/tests/it/lock.rs index 895b29c825..22b9c5545a 100644 --- a/crates/uv/tests/it/lock.rs +++ b/crates/uv/tests/it/lock.rs @@ -35,7 +35,7 @@ fn lock_wheel_registry() -> Result<()> { ----- stdout ----- ----- stderr ----- - Resolved 4 packages in [TIME] + Resolved 3 packages in [TIME] "); let lock = context.read("uv.lock"); @@ -104,7 +104,7 @@ fn lock_wheel_registry() -> Result<()> { ----- stdout ----- ----- stderr ----- - Resolved 4 packages in [TIME] + Resolved 3 packages in [TIME] "); // Re-run with `--offline`. We shouldn't need a network connection to validate an @@ -115,7 +115,8 @@ fn lock_wheel_registry() -> Result<()> { ----- stdout ----- ----- stderr ----- - Resolved 4 packages in [TIME] + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Resolved 3 packages in [TIME] "); // Install from the lockfile. @@ -167,7 +168,7 @@ fn lock_sdist_registry() -> Result<()> { ----- stdout ----- ----- stderr ----- - Resolved 2 packages in [TIME] + Resolved 3 packages in [TIME] "); let lock = context.read("uv.lock"); @@ -211,7 +212,7 @@ fn lock_sdist_registry() -> Result<()> { ----- stdout ----- ----- stderr ----- - Resolved 2 packages in [TIME] + Resolved 3 packages in [TIME] "); // Install from the lockfile. @@ -265,7 +266,7 @@ fn lock_sdist_git() -> Result<()> { ----- stdout ----- ----- stderr ----- - Resolved 2 packages in [TIME] + Resolved 3 packages in [TIME] "); let lock = context.read("uv.lock"); @@ -308,7 +309,7 @@ fn lock_sdist_git() -> Result<()> { ----- stdout ----- ----- stderr ----- - Resolved 2 packages in [TIME] + Resolved 3 packages in [TIME] "); // Re-run with `--offline`. We shouldn't need a network connection to validate an @@ -319,7 +320,7 @@ fn lock_sdist_git() -> Result<()> { ----- stdout ----- ----- stderr ----- - Resolved 2 packages in [TIME] + Resolved 3 packages in [TIME] "); // Install from the lockfile. @@ -355,7 +356,7 @@ fn lock_sdist_git() -> Result<()> { ----- stdout ----- ----- stderr ----- - Resolved 2 packages in [TIME] + Resolved 3 packages in [TIME] "); let lock = context.read("uv.lock"); @@ -412,7 +413,7 @@ fn lock_sdist_git() -> Result<()> { ----- stdout ----- ----- stderr ----- - Resolved 2 packages in [TIME] + Resolved 3 packages in [TIME] Updated uv-public-pypackage v0.1.0 (0dacfd66) -> v0.1.0 (b270df1a) "); @@ -470,7 +471,7 @@ fn lock_sdist_git() -> Result<()> { ----- stdout ----- ----- stderr ----- - Resolved 2 packages in [TIME] + Resolved 3 packages in [TIME] "); let lock = context.read("uv.lock"); @@ -968,7 +969,8 @@ fn lock_wheel_url() -> Result<()> { ----- stdout ----- ----- stderr ----- - Resolved 4 packages in [TIME] + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Resolved 3 packages in [TIME] "); let lock = context.read("uv.lock"); @@ -1266,6 +1268,7 @@ fn lock_sdist_url_subdirectory() -> Result<()> { ----- stdout ----- ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] Resolved 5 packages in [TIME] "); @@ -1347,6 +1350,7 @@ fn lock_sdist_url_subdirectory() -> Result<()> { ----- stdout ----- ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] Resolved 5 packages in [TIME] "); @@ -2195,6 +2199,7 @@ fn lock_project_with_build_constraint_sources() -> Result<()> { ----- stdout ----- ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] Resolved 2 packages in [TIME] "); @@ -2205,6 +2210,7 @@ fn lock_project_with_build_constraint_sources() -> Result<()> { ----- stdout ----- ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] Resolved 2 packages in [TIME] "); @@ -22690,6 +22696,15 @@ fn lock_strip_fragment() -> Result<()> { [options] exclude-newer = "2024-03-25T00:00:00Z" + [[package]] + name = "iniconfig" + version = "2.0.0" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646, upload-time = "2023-01-07T11:08:11.254Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892, upload-time = "2023-01-07T11:08:09.864Z" }, + ] + [[package]] name = "iniconfig" version = "2.0.0" @@ -24534,6 +24549,1117 @@ fn lock_multiple_sources_extra() -> Result<()> { exit_code: 0 ----- stdout ----- + ----- stderr ----- + Resolved 3 packages in [TIME] + "); + + let lock = context.read("uv.lock"); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r#" + version = 1 + revision = 3 + requires-python = ">=3.12" + + [options] + exclude-newer = "2024-03-25T00:00:00Z" + + [[package]] + name = "iniconfig" + version = "2.0.0" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646, upload-time = "2023-01-07T11:08:11.254Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892, upload-time = "2023-01-07T11:08:09.864Z" }, + ] + + [[package]] + name = "iniconfig" + version = "2.0.0" + source = { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374" }, + ] + + [[package]] + name = "project" + version = "0.1.0" + source = { virtual = "." } + dependencies = [ + { name = "iniconfig", version = "2.0.0", source = { registry = "https://pypi.org/simple" }, marker = "extra != 'cpu'" }, + { name = "iniconfig", version = "2.0.0", source = { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl" }, marker = "extra == 'cpu'" }, + ] + + [package.optional-dependencies] + cpu = [ + { name = "iniconfig", version = "2.0.0", source = { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl" }, marker = "extra == 'cpu'" }, + ] + + [package.metadata] + requires-dist = [ + { name = "iniconfig", marker = "extra == 'cpu'", url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl" }, + { name = "iniconfig", marker = "extra != 'cpu'" }, + ] + provides-extras = ["cpu"] + "# + ); + }); + + // Re-run with `--locked`. + uv_snapshot!(context.filters(), context.lock().arg("--locked"), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 3 packages in [TIME] + "); + + Ok(()) +} + +/// When a dependency appears in both `dependencies` and `optional-dependencies`, +/// and a source has `extra = "X"`, the source should only be used when the extra +/// is enabled. Without the extra, the dependency should resolve from the registry. +/// +/// See: +#[test] +fn lock_multiple_sources_extra_base_and_optional() -> Result<()> { + let context = uv_test::test_context!("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.12" + dependencies = ["iniconfig>=2"] + + [project.optional-dependencies] + alt = ["iniconfig"] + + [tool.uv.sources] + iniconfig = [ + { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", extra = "alt" }, + ] + "#, + )?; + + uv_snapshot!(context.filters(), context.lock(), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 3 packages in [TIME] + "); + + let lock = context.read("uv.lock"); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r#" + version = 1 + revision = 3 + requires-python = ">=3.12" + + [options] + exclude-newer = "2024-03-25T00:00:00Z" + + [[package]] + name = "iniconfig" + version = "2.0.0" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646, upload-time = "2023-01-07T11:08:11.254Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892, upload-time = "2023-01-07T11:08:09.864Z" }, + ] + + [[package]] + name = "iniconfig" + version = "2.0.0" + source = { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374" }, + ] + + [[package]] + name = "project" + version = "0.1.0" + source = { virtual = "." } + dependencies = [ + { name = "iniconfig", version = "2.0.0", source = { registry = "https://pypi.org/simple" }, marker = "extra != 'alt'" }, + { name = "iniconfig", version = "2.0.0", source = { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl" }, marker = "extra == 'alt'" }, + ] + + [package.optional-dependencies] + alt = [ + { name = "iniconfig", version = "2.0.0", source = { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl" }, marker = "extra == 'alt'" }, + ] + + [package.metadata] + requires-dist = [ + { name = "iniconfig", specifier = ">=2" }, + { name = "iniconfig", marker = "extra == 'alt'", url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl" }, + ] + provides-extras = ["alt"] + "# + ); + }); + + // Re-run with `--locked`. + uv_snapshot!(context.filters(), context.lock().arg("--locked"), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 3 packages in [TIME] + "); + + Ok(()) +} + +/// Like `lock_multiple_sources_extra_base_and_optional`, but with an explicit +/// index instead of a URL. The same `ForkIndexes` leaking issue applies. +/// +/// See: +#[test] +fn lock_multiple_sources_index_extra_base_and_optional() -> Result<()> { + let context = uv_test::test_context!("3.12").with_exclude_newer("2025-01-30T00:00:00Z"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["jinja2>=3"] + + [project.optional-dependencies] + cu118 = ["jinja2"] + + [tool.uv.sources] + jinja2 = [ + { index = "torch-cu118", extra = "cu118" }, + ] + + [[tool.uv.index]] + name = "torch-cu118" + url = "https://astral-sh.github.io/pytorch-mirror/whl/cu118" + explicit = true + "#, + )?; + + uv_snapshot!(context.filters(), context.lock(), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 4 packages in [TIME] + "); + + let lock = context.read("uv.lock"); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r#" + version = 1 + revision = 3 + requires-python = ">=3.12" + + [options] + exclude-newer = "2025-01-30T00:00:00Z" + + [[package]] + name = "jinja2" + version = "3.1.4" + source = { registry = "https://astral-sh.github.io/pytorch-mirror/whl/cu118" } + dependencies = [ + { name = "markupsafe", marker = "extra == 'extra-7-project-cu118'" }, + ] + wheels = [ + { url = "https://download.pytorch.org/whl/Jinja2-3.1.4-py3-none-any.whl", upload-time = "2025-01-29T22:50:57.275Z" }, + ] + + [[package]] + name = "jinja2" + version = "3.1.5" + source = { registry = "https://pypi.org/simple" } + dependencies = [ + { name = "markupsafe", marker = "extra != 'extra-7-project-cu118'" }, + ] + sdist = { url = "https://files.pythonhosted.org/packages/af/92/b3130cbbf5591acf9ade8708c365f3238046ac7cb8ccba6e81abccb0ccff/jinja2-3.1.5.tar.gz", hash = "sha256:8fefff8dc3034e27bb80d67c671eb8a9bc424c0ef4c0826edbff304cceff43bb", size = 244674, upload-time = "2024-12-21T18:30:22.828Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/0f/2ba5fbcd631e3e88689309dbe978c5769e883e4b84ebfe7da30b43275c5a/jinja2-3.1.5-py3-none-any.whl", hash = "sha256:aba0f4dc9ed8013c424088f68a5c226f7d6097ed89b246d7749c2ec4175c6adb", size = 134596, upload-time = "2024-12-21T18:30:19.133Z" }, + ] + + [[package]] + name = "markupsafe" + version = "3.0.2" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537, upload-time = "2024-10-18T15:21:54.129Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/22/09/d1f21434c97fc42f09d290cbb6350d44eb12f09cc62c9476effdb33a18aa/MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", size = 14274, upload-time = "2024-10-18T15:21:13.777Z" }, + { url = "https://files.pythonhosted.org/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", size = 12348, upload-time = "2024-10-18T15:21:14.822Z" }, + { url = "https://files.pythonhosted.org/packages/e0/25/dd5c0f6ac1311e9b40f4af06c78efde0f3b5cbf02502f8ef9501294c425b/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", size = 24149, upload-time = "2024-10-18T15:21:15.642Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f0/89e7aadfb3749d0f52234a0c8c7867877876e0a20b60e2188e9850794c17/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", size = 23118, upload-time = "2024-10-18T15:21:17.133Z" }, + { url = "https://files.pythonhosted.org/packages/d5/da/f2eeb64c723f5e3777bc081da884b414671982008c47dcc1873d81f625b6/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", size = 22993, upload-time = "2024-10-18T15:21:18.064Z" }, + { url = "https://files.pythonhosted.org/packages/da/0e/1f32af846df486dce7c227fe0f2398dc7e2e51d4a370508281f3c1c5cddc/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", size = 24178, upload-time = "2024-10-18T15:21:18.859Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f6/bb3ca0532de8086cbff5f06d137064c8410d10779c4c127e0e47d17c0b71/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", size = 23319, upload-time = "2024-10-18T15:21:19.671Z" }, + { url = "https://files.pythonhosted.org/packages/a2/82/8be4c96ffee03c5b4a034e60a31294daf481e12c7c43ab8e34a1453ee48b/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", size = 23352, upload-time = "2024-10-18T15:21:20.971Z" }, + { url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", size = 15097, upload-time = "2024-10-18T15:21:22.646Z" }, + { url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", size = 15601, upload-time = "2024-10-18T15:21:23.499Z" }, + { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274, upload-time = "2024-10-18T15:21:24.577Z" }, + { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352, upload-time = "2024-10-18T15:21:25.382Z" }, + { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122, upload-time = "2024-10-18T15:21:26.199Z" }, + { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085, upload-time = "2024-10-18T15:21:27.029Z" }, + { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978, upload-time = "2024-10-18T15:21:27.846Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208, upload-time = "2024-10-18T15:21:28.744Z" }, + { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357, upload-time = "2024-10-18T15:21:29.545Z" }, + { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344, upload-time = "2024-10-18T15:21:30.366Z" }, + { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101, upload-time = "2024-10-18T15:21:31.207Z" }, + { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603, upload-time = "2024-10-18T15:21:32.032Z" }, + { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510, upload-time = "2024-10-18T15:21:33.625Z" }, + { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486, upload-time = "2024-10-18T15:21:34.611Z" }, + { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480, upload-time = "2024-10-18T15:21:35.398Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914, upload-time = "2024-10-18T15:21:36.231Z" }, + { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796, upload-time = "2024-10-18T15:21:37.073Z" }, + { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473, upload-time = "2024-10-18T15:21:37.932Z" }, + { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114, upload-time = "2024-10-18T15:21:39.799Z" }, + { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098, upload-time = "2024-10-18T15:21:40.813Z" }, + { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208, upload-time = "2024-10-18T15:21:41.814Z" }, + { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739, upload-time = "2024-10-18T15:21:42.784Z" }, + ] + + [[package]] + name = "project" + version = "0.1.0" + source = { virtual = "." } + dependencies = [ + { name = "jinja2", version = "3.1.4", source = { registry = "https://astral-sh.github.io/pytorch-mirror/whl/cu118" }, marker = "extra == 'extra-7-project-cu118'" }, + { name = "jinja2", version = "3.1.5", source = { registry = "https://pypi.org/simple" }, marker = "extra != 'extra-7-project-cu118'" }, + ] + + [package.optional-dependencies] + cu118 = [ + { name = "jinja2", version = "3.1.4", source = { registry = "https://astral-sh.github.io/pytorch-mirror/whl/cu118" }, marker = "extra == 'extra-7-project-cu118'" }, + { name = "jinja2", version = "3.1.5", source = { registry = "https://pypi.org/simple" }, marker = "extra != 'extra-7-project-cu118'" }, + ] + + [package.metadata] + requires-dist = [ + { name = "jinja2", specifier = ">=3" }, + { name = "jinja2", marker = "extra == 'cu118'", index = "https://astral-sh.github.io/pytorch-mirror/whl/cu118", conflict = { package = "project", extra = "cu118" } }, + ] + provides-extras = ["cu118"] + "# + ); + }); + + // Re-run with `--locked`. + uv_snapshot!(context.filters(), context.lock().arg("--locked"), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 4 packages in [TIME] + "); + + Ok(()) +} + +/// Like `lock_multiple_sources_extra_base_and_optional`, but for dependency +/// groups via `group = "X"` sources. +/// +/// The base dependency should resolve from the default index, while the group +/// dependency should use the source-gated URL. +#[test] +fn lock_multiple_sources_group_base_and_group() -> Result<()> { + let context = uv_test::test_context!("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.12" + dependencies = ["iniconfig>=2"] + + [dependency-groups] + alt = ["iniconfig"] + + [tool.uv.sources] + iniconfig = [ + { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", group = "alt" }, + ] + "#, + )?; + + uv_snapshot!(context.filters(), context.lock(), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 3 packages in [TIME] + "); + + let lock = context.read("uv.lock"); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r#" + version = 1 + revision = 3 + requires-python = ">=3.12" + + [options] + exclude-newer = "2024-03-25T00:00:00Z" + + [[package]] + name = "iniconfig" + version = "2.0.0" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646, upload-time = "2023-01-07T11:08:11.254Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892, upload-time = "2023-01-07T11:08:09.864Z" }, + ] + + [[package]] + name = "iniconfig" + version = "2.0.0" + source = { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374" }, + ] + + [[package]] + name = "project" + version = "0.1.0" + source = { virtual = "." } + dependencies = [ + { name = "iniconfig", version = "2.0.0", source = { registry = "https://pypi.org/simple" }, marker = "extra != 'group-7-project-alt'" }, + { name = "iniconfig", version = "2.0.0", source = { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl" }, marker = "extra == 'group-7-project-alt'" }, + ] + + [package.dev-dependencies] + alt = [ + { name = "iniconfig", version = "2.0.0", source = { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl" }, marker = "extra == 'group-7-project-alt'" }, + ] + + [package.metadata] + requires-dist = [{ name = "iniconfig", specifier = ">=2" }] + + [package.metadata.requires-dev] + alt = [{ name = "iniconfig", url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl" }] + "# + ); + }); + + // Re-run with `--locked`. + uv_snapshot!(context.filters(), context.lock().arg("--locked"), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 3 packages in [TIME] + "); + + Ok(()) +} + +/// Like `lock_multiple_sources_index_extra_base_and_optional`, but for +/// dependency groups via `group = "X"` sources. +/// +/// The base dependency should resolve from the default index, while the group +/// dependency should use the group-scoped explicit index. +#[test] +fn lock_multiple_sources_index_group_base_and_group() -> Result<()> { + let context = uv_test::test_context!("3.12").with_exclude_newer("2025-01-30T00:00:00Z"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["jinja2>=3"] + + [dependency-groups] + cu118 = ["jinja2"] + + [tool.uv.sources] + jinja2 = [ + { index = "torch-cu118", group = "cu118" }, + ] + + [[tool.uv.index]] + name = "torch-cu118" + url = "https://astral-sh.github.io/pytorch-mirror/whl/cu118" + explicit = true + "#, + )?; + + uv_snapshot!(context.filters(), context.lock(), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 4 packages in [TIME] + "); + + let lock = context.read("uv.lock"); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r#" + version = 1 + revision = 3 + requires-python = ">=3.12" + + [options] + exclude-newer = "2025-01-30T00:00:00Z" + + [[package]] + name = "jinja2" + version = "3.1.4" + source = { registry = "https://astral-sh.github.io/pytorch-mirror/whl/cu118" } + dependencies = [ + { name = "markupsafe", marker = "extra == 'group-7-project-cu118'" }, + ] + wheels = [ + { url = "https://download.pytorch.org/whl/Jinja2-3.1.4-py3-none-any.whl", upload-time = "2025-01-29T22:50:57.275Z" }, + ] + + [[package]] + name = "jinja2" + version = "3.1.5" + source = { registry = "https://pypi.org/simple" } + dependencies = [ + { name = "markupsafe", marker = "extra != 'group-7-project-cu118'" }, + ] + sdist = { url = "https://files.pythonhosted.org/packages/af/92/b3130cbbf5591acf9ade8708c365f3238046ac7cb8ccba6e81abccb0ccff/jinja2-3.1.5.tar.gz", hash = "sha256:8fefff8dc3034e27bb80d67c671eb8a9bc424c0ef4c0826edbff304cceff43bb", size = 244674, upload-time = "2024-12-21T18:30:22.828Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/0f/2ba5fbcd631e3e88689309dbe978c5769e883e4b84ebfe7da30b43275c5a/jinja2-3.1.5-py3-none-any.whl", hash = "sha256:aba0f4dc9ed8013c424088f68a5c226f7d6097ed89b246d7749c2ec4175c6adb", size = 134596, upload-time = "2024-12-21T18:30:19.133Z" }, + ] + + [[package]] + name = "markupsafe" + version = "3.0.2" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537, upload-time = "2024-10-18T15:21:54.129Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/22/09/d1f21434c97fc42f09d290cbb6350d44eb12f09cc62c9476effdb33a18aa/MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", size = 14274, upload-time = "2024-10-18T15:21:13.777Z" }, + { url = "https://files.pythonhosted.org/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", size = 12348, upload-time = "2024-10-18T15:21:14.822Z" }, + { url = "https://files.pythonhosted.org/packages/e0/25/dd5c0f6ac1311e9b40f4af06c78efde0f3b5cbf02502f8ef9501294c425b/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", size = 24149, upload-time = "2024-10-18T15:21:15.642Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f0/89e7aadfb3749d0f52234a0c8c7867877876e0a20b60e2188e9850794c17/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", size = 23118, upload-time = "2024-10-18T15:21:17.133Z" }, + { url = "https://files.pythonhosted.org/packages/d5/da/f2eeb64c723f5e3777bc081da884b414671982008c47dcc1873d81f625b6/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", size = 22993, upload-time = "2024-10-18T15:21:18.064Z" }, + { url = "https://files.pythonhosted.org/packages/da/0e/1f32af846df486dce7c227fe0f2398dc7e2e51d4a370508281f3c1c5cddc/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", size = 24178, upload-time = "2024-10-18T15:21:18.859Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f6/bb3ca0532de8086cbff5f06d137064c8410d10779c4c127e0e47d17c0b71/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", size = 23319, upload-time = "2024-10-18T15:21:19.671Z" }, + { url = "https://files.pythonhosted.org/packages/a2/82/8be4c96ffee03c5b4a034e60a31294daf481e12c7c43ab8e34a1453ee48b/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", size = 23352, upload-time = "2024-10-18T15:21:20.971Z" }, + { url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", size = 15097, upload-time = "2024-10-18T15:21:22.646Z" }, + { url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", size = 15601, upload-time = "2024-10-18T15:21:23.499Z" }, + { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274, upload-time = "2024-10-18T15:21:24.577Z" }, + { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352, upload-time = "2024-10-18T15:21:25.382Z" }, + { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122, upload-time = "2024-10-18T15:21:26.199Z" }, + { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085, upload-time = "2024-10-18T15:21:27.029Z" }, + { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978, upload-time = "2024-10-18T15:21:27.846Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208, upload-time = "2024-10-18T15:21:28.744Z" }, + { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357, upload-time = "2024-10-18T15:21:29.545Z" }, + { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344, upload-time = "2024-10-18T15:21:30.366Z" }, + { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101, upload-time = "2024-10-18T15:21:31.207Z" }, + { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603, upload-time = "2024-10-18T15:21:32.032Z" }, + { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510, upload-time = "2024-10-18T15:21:33.625Z" }, + { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486, upload-time = "2024-10-18T15:21:34.611Z" }, + { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480, upload-time = "2024-10-18T15:21:35.398Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914, upload-time = "2024-10-18T15:21:36.231Z" }, + { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796, upload-time = "2024-10-18T15:21:37.073Z" }, + { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473, upload-time = "2024-10-18T15:21:37.932Z" }, + { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114, upload-time = "2024-10-18T15:21:39.799Z" }, + { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098, upload-time = "2024-10-18T15:21:40.813Z" }, + { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208, upload-time = "2024-10-18T15:21:41.814Z" }, + { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739, upload-time = "2024-10-18T15:21:42.784Z" }, + ] + + [[package]] + name = "project" + version = "0.1.0" + source = { virtual = "." } + dependencies = [ + { name = "jinja2", version = "3.1.4", source = { registry = "https://astral-sh.github.io/pytorch-mirror/whl/cu118" }, marker = "extra == 'group-7-project-cu118'" }, + { name = "jinja2", version = "3.1.5", source = { registry = "https://pypi.org/simple" }, marker = "extra != 'group-7-project-cu118'" }, + ] + + [package.dev-dependencies] + cu118 = [ + { name = "jinja2", version = "3.1.4", source = { registry = "https://astral-sh.github.io/pytorch-mirror/whl/cu118" }, marker = "extra == 'group-7-project-cu118'" }, + ] + + [package.metadata] + requires-dist = [{ name = "jinja2", specifier = ">=3" }] + + [package.metadata.requires-dev] + cu118 = [{ name = "jinja2", index = "https://astral-sh.github.io/pytorch-mirror/whl/cu118", conflict = { package = "project", group = "cu118" } }] + "# + ); + }); + + // Re-run with `--locked`. + uv_snapshot!(context.filters(), context.lock().arg("--locked"), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 4 packages in [TIME] + "); + + Ok(()) +} + +/// Like `lock_multiple_sources_index_group_base_and_group`, but with two +/// dependency groups that point at the same explicit index. +/// +/// The root rewrite must keep both group conflict markers attached to the +/// source-specific edge instead of reusing the first rewritten dependency for +/// the second group. +#[test] +fn lock_multiple_sources_index_group_repeated_root_groups() -> Result<()> { + let context = uv_test::test_context!("3.12").with_exclude_newer("2025-01-30T00:00:00Z"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["jinja2>=3"] + + [dependency-groups] + cu118a = ["jinja2"] + cu118b = ["jinja2"] + + [tool.uv.sources] + jinja2 = [ + { index = "torch-cu118", group = "cu118a" }, + { index = "torch-cu118", group = "cu118b" }, + ] + + [[tool.uv.index]] + name = "torch-cu118" + url = "https://astral-sh.github.io/pytorch-mirror/whl/cu118" + explicit = true + "#, + )?; + + uv_snapshot!(context.filters(), context.lock(), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 4 packages in [TIME] + "); + + let lock = context.read("uv.lock"); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r#" + version = 1 + revision = 3 + requires-python = ">=3.12" + + [options] + exclude-newer = "2025-01-30T00:00:00Z" + + [[package]] + name = "jinja2" + version = "3.1.4" + source = { registry = "https://astral-sh.github.io/pytorch-mirror/whl/cu118" } + dependencies = [ + { name = "markupsafe", marker = "extra == 'group-7-project-cu118a' or extra == 'group-7-project-cu118b'" }, + ] + wheels = [ + { url = "https://download.pytorch.org/whl/Jinja2-3.1.4-py3-none-any.whl", upload-time = "2025-01-29T22:50:57.275Z" }, + ] + + [[package]] + name = "jinja2" + version = "3.1.5" + source = { registry = "https://pypi.org/simple" } + dependencies = [ + { name = "markupsafe", marker = "extra != 'group-7-project-cu118a' and extra != 'group-7-project-cu118b'" }, + ] + sdist = { url = "https://files.pythonhosted.org/packages/af/92/b3130cbbf5591acf9ade8708c365f3238046ac7cb8ccba6e81abccb0ccff/jinja2-3.1.5.tar.gz", hash = "sha256:8fefff8dc3034e27bb80d67c671eb8a9bc424c0ef4c0826edbff304cceff43bb", size = 244674, upload-time = "2024-12-21T18:30:22.828Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/0f/2ba5fbcd631e3e88689309dbe978c5769e883e4b84ebfe7da30b43275c5a/jinja2-3.1.5-py3-none-any.whl", hash = "sha256:aba0f4dc9ed8013c424088f68a5c226f7d6097ed89b246d7749c2ec4175c6adb", size = 134596, upload-time = "2024-12-21T18:30:19.133Z" }, + ] + + [[package]] + name = "markupsafe" + version = "3.0.2" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537, upload-time = "2024-10-18T15:21:54.129Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/22/09/d1f21434c97fc42f09d290cbb6350d44eb12f09cc62c9476effdb33a18aa/MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", size = 14274, upload-time = "2024-10-18T15:21:13.777Z" }, + { url = "https://files.pythonhosted.org/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", size = 12348, upload-time = "2024-10-18T15:21:14.822Z" }, + { url = "https://files.pythonhosted.org/packages/e0/25/dd5c0f6ac1311e9b40f4af06c78efde0f3b5cbf02502f8ef9501294c425b/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", size = 24149, upload-time = "2024-10-18T15:21:15.642Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f0/89e7aadfb3749d0f52234a0c8c7867877876e0a20b60e2188e9850794c17/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", size = 23118, upload-time = "2024-10-18T15:21:17.133Z" }, + { url = "https://files.pythonhosted.org/packages/d5/da/f2eeb64c723f5e3777bc081da884b414671982008c47dcc1873d81f625b6/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", size = 22993, upload-time = "2024-10-18T15:21:18.064Z" }, + { url = "https://files.pythonhosted.org/packages/da/0e/1f32af846df486dce7c227fe0f2398dc7e2e51d4a370508281f3c1c5cddc/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", size = 24178, upload-time = "2024-10-18T15:21:18.859Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f6/bb3ca0532de8086cbff5f06d137064c8410d10779c4c127e0e47d17c0b71/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", size = 23319, upload-time = "2024-10-18T15:21:19.671Z" }, + { url = "https://files.pythonhosted.org/packages/a2/82/8be4c96ffee03c5b4a034e60a31294daf481e12c7c43ab8e34a1453ee48b/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", size = 23352, upload-time = "2024-10-18T15:21:20.971Z" }, + { url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", size = 15097, upload-time = "2024-10-18T15:21:22.646Z" }, + { url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", size = 15601, upload-time = "2024-10-18T15:21:23.499Z" }, + { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274, upload-time = "2024-10-18T15:21:24.577Z" }, + { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352, upload-time = "2024-10-18T15:21:25.382Z" }, + { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122, upload-time = "2024-10-18T15:21:26.199Z" }, + { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085, upload-time = "2024-10-18T15:21:27.029Z" }, + { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978, upload-time = "2024-10-18T15:21:27.846Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208, upload-time = "2024-10-18T15:21:28.744Z" }, + { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357, upload-time = "2024-10-18T15:21:29.545Z" }, + { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344, upload-time = "2024-10-18T15:21:30.366Z" }, + { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101, upload-time = "2024-10-18T15:21:31.207Z" }, + { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603, upload-time = "2024-10-18T15:21:32.032Z" }, + { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510, upload-time = "2024-10-18T15:21:33.625Z" }, + { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486, upload-time = "2024-10-18T15:21:34.611Z" }, + { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480, upload-time = "2024-10-18T15:21:35.398Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914, upload-time = "2024-10-18T15:21:36.231Z" }, + { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796, upload-time = "2024-10-18T15:21:37.073Z" }, + { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473, upload-time = "2024-10-18T15:21:37.932Z" }, + { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114, upload-time = "2024-10-18T15:21:39.799Z" }, + { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098, upload-time = "2024-10-18T15:21:40.813Z" }, + { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208, upload-time = "2024-10-18T15:21:41.814Z" }, + { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739, upload-time = "2024-10-18T15:21:42.784Z" }, + ] + + [[package]] + name = "project" + version = "0.1.0" + source = { virtual = "." } + dependencies = [ + { name = "jinja2", version = "3.1.4", source = { registry = "https://astral-sh.github.io/pytorch-mirror/whl/cu118" }, marker = "extra == 'group-7-project-cu118a' or extra == 'group-7-project-cu118b'" }, + { name = "jinja2", version = "3.1.5", source = { registry = "https://pypi.org/simple" }, marker = "extra != 'group-7-project-cu118a' and extra != 'group-7-project-cu118b'" }, + ] + + [package.dev-dependencies] + cu118a = [ + { name = "jinja2", version = "3.1.4", source = { registry = "https://astral-sh.github.io/pytorch-mirror/whl/cu118" }, marker = "extra == 'group-7-project-cu118a'" }, + ] + cu118b = [ + { name = "jinja2", version = "3.1.4", source = { registry = "https://astral-sh.github.io/pytorch-mirror/whl/cu118" }, marker = "extra == 'group-7-project-cu118b'" }, + ] + + [package.metadata] + requires-dist = [{ name = "jinja2", specifier = ">=3" }] + + [package.metadata.requires-dev] + cu118a = [{ name = "jinja2", index = "https://astral-sh.github.io/pytorch-mirror/whl/cu118", conflict = { package = "project", group = "cu118a" } }] + cu118b = [{ name = "jinja2", index = "https://astral-sh.github.io/pytorch-mirror/whl/cu118", conflict = { package = "project", group = "cu118b" } }] + "# + ); + }); + + Ok(()) +} + +/// When a dependency group applies an explicit index to a conflicting specifier, +/// the complementary base dependency must preserve the group conflict marker. +/// +/// Regression test for the complementary-source split on group-scoped indexes. +#[tokio::test] +async fn lock_multiple_sources_index_group_conflicting_specifiers() -> Result<()> { + let context = uv_test::test_context!("3.12"); + let proxy = crate::pypi_proxy::start().await; + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(&format!( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["iniconfig>=2"] + + [dependency-groups] + alt = ["iniconfig<2"] + + [tool.uv.sources] + iniconfig = [ + {{ index = "alt-index", group = "alt" }}, + ] + + [[tool.uv.index]] + name = "alt-index" + url = "{proxy_uri}/simple" + explicit = true + "#, + proxy_uri = proxy.uri() + ))?; + + uv_snapshot!(context.filters(), context.lock(), @" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + × No solution found when resolving dependencies for split (markers: extra == 'group-7-project-alt'): + ╰─▶ Because only iniconfig{extra == 'group-7-project-alt'}==2.0.0 is available and your project depends on iniconfig{extra == 'group-7-project-alt'}<2, we can conclude that your project's requirements are unsatisfiable. + + hint: The resolution failed for an environment that is not the current one, consider limiting the environments with `tool.uv.environments`. + "); + + Ok(()) +} + +/// A workspace member with a group-scoped explicit index must preserve both the +/// explicit index and the original version range on the synthesized +/// complementary dependency. +#[test] +fn lock_multiple_sources_index_group_workspace_member_preserves_version_and_index() -> Result<()> { + let context = uv_test::test_context!("3.12").with_exclude_newer("2025-01-30T00:00:00Z"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["child"] + + [tool.uv.workspace] + members = ["child"] + + [tool.uv.sources] + child = { workspace = true } + "#, + )?; + + let child = context.temp_dir.child("child"); + fs_err::create_dir_all(&child)?; + let child_pyproject_toml = child.child("pyproject.toml"); + child_pyproject_toml.write_str( + r#" + [project] + name = "child" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["jinja2>=3.1.5"] + + [dependency-groups] + cu118 = ["jinja2>=3.1.5"] + + [tool.uv.sources] + jinja2 = [ + { index = "torch-cu118", group = "cu118" }, + ] + + [[tool.uv.index]] + name = "torch-cu118" + url = "https://astral-sh.github.io/pytorch-mirror/whl/cu118" + explicit = true + "#, + )?; + + uv_snapshot!(context.filters(), context.lock(), @" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + × No solution found when resolving dependencies for split (markers: extra == 'group-5-child-cu118'): + ╰─▶ Because only jinja2{extra == 'group-5-child-cu118'}<=3.1.4 is available and child depends on jinja2{extra == 'group-5-child-cu118'}>=3.1.5, we can conclude that child's requirements are unsatisfiable. + And because your workspace requires child, we can conclude that your workspace's requirements are unsatisfiable. + + hint: The resolution failed for an environment that is not the current one, consider limiting the environments with `tool.uv.environments`. + "); + + Ok(()) +} + +/// When a source marker includes both an extra and a platform condition, the +/// complementary dependency should still be created (with the negated marker). +#[test] +fn lock_multiple_sources_extra_and_platform_marker() -> Result<()> { + let context = uv_test::test_context!("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.12" + dependencies = ["iniconfig>=2"] + + [project.optional-dependencies] + alt = ["iniconfig"] + + [tool.uv.sources] + iniconfig = [ + { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", marker = "extra == 'alt' and sys_platform == 'linux'" }, + ] + "#, + )?; + + uv_snapshot!(context.filters(), context.lock(), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 3 packages in [TIME] + "); + + let lock = context.read("uv.lock"); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r#" + version = 1 + revision = 3 + requires-python = ">=3.12" + resolution-markers = [ + "sys_platform != 'linux'", + "sys_platform == 'linux'", + ] + + [options] + exclude-newer = "2024-03-25T00:00:00Z" + + [[package]] + name = "iniconfig" + version = "2.0.0" + source = { registry = "https://pypi.org/simple" } + resolution-markers = [ + "sys_platform != 'linux'", + ] + sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646, upload-time = "2023-01-07T11:08:11.254Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892, upload-time = "2023-01-07T11:08:09.864Z" }, + ] + + [[package]] + name = "iniconfig" + version = "2.0.0" + source = { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl" } + resolution-markers = [ + "sys_platform == 'linux'", + ] + wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374" }, + ] + + [[package]] + name = "project" + version = "0.1.0" + source = { virtual = "." } + dependencies = [ + { name = "iniconfig", version = "2.0.0", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform != 'linux'" }, + { name = "iniconfig", version = "2.0.0", source = { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl" }, marker = "sys_platform == 'linux' and extra == 'alt'" }, + ] + + [package.optional-dependencies] + alt = [ + { name = "iniconfig", version = "2.0.0", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform != 'linux'" }, + { name = "iniconfig", version = "2.0.0", source = { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl" }, marker = "sys_platform == 'linux' and extra == 'alt'" }, + ] + + [package.metadata] + requires-dist = [ + { name = "iniconfig", marker = "sys_platform != 'linux' or extra != 'alt'", specifier = ">=2" }, + { name = "iniconfig", marker = "sys_platform == 'linux' and extra == 'alt'", url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl" }, + { name = "iniconfig", marker = "sys_platform != 'linux' and extra == 'alt'" }, + ] + provides-extras = ["alt"] + "# + ); + }); + + // Re-run with `--locked`. + uv_snapshot!(context.filters(), context.lock().arg("--locked"), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 3 packages in [TIME] + "); + + Ok(()) +} + +/// When a dependency appears in `dependencies` and two different extras each +/// gate a different URL source, the base dep marker should narrow to +/// `extra != 'a' and extra != 'b'`, with two URL deps alongside. +/// +/// See: +#[test] +fn lock_multiple_sources_extra_multiple_urls() -> Result<()> { + let context = uv_test::test_context!("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.12" + dependencies = ["iniconfig>=2"] + + [project.optional-dependencies] + alt = ["iniconfig"] + dev = ["iniconfig"] + + [tool.uv.sources] + iniconfig = [ + { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", extra = "alt" }, + { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", extra = "dev" }, + ] + "#, + )?; + + uv_snapshot!(context.filters(), context.lock(), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 3 packages in [TIME] + "); + + let lock = context.read("uv.lock"); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r#" + version = 1 + revision = 3 + requires-python = ">=3.12" + + [options] + exclude-newer = "2024-03-25T00:00:00Z" + + [[package]] + name = "iniconfig" + version = "2.0.0" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646, upload-time = "2023-01-07T11:08:11.254Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892, upload-time = "2023-01-07T11:08:09.864Z" }, + ] + + [[package]] + name = "iniconfig" + version = "2.0.0" + source = { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374" }, + ] + + [[package]] + name = "project" + version = "0.1.0" + source = { virtual = "." } + dependencies = [ + { name = "iniconfig", version = "2.0.0", source = { registry = "https://pypi.org/simple" }, marker = "extra != 'alt' and extra != 'dev'" }, + { name = "iniconfig", version = "2.0.0", source = { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl" }, marker = "extra == 'alt' or extra == 'dev'" }, + ] + + [package.optional-dependencies] + alt = [ + { name = "iniconfig", version = "2.0.0", source = { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl" }, marker = "extra == 'alt'" }, + ] + dev = [ + { name = "iniconfig", version = "2.0.0", source = { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl" }, marker = "extra == 'dev'" }, + ] + + [package.metadata] + requires-dist = [ + { name = "iniconfig", specifier = ">=2" }, + { name = "iniconfig", marker = "extra == 'alt'", url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl" }, + { name = "iniconfig", marker = "extra == 'dev'", url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl" }, + ] + provides-extras = ["alt", "dev"] + "# + ); + }); + + // Re-run with `--locked`. + uv_snapshot!(context.filters(), context.lock().arg("--locked"), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 3 packages in [TIME] + "); + + Ok(()) +} + +/// When a dependency only appears in `optional-dependencies` (not in +/// `dependencies`), and a URL source is gated on its extra, the code should +/// NOT create a complementary dep — the `find` guard requires a base dep. +/// +/// See: +#[test] +fn lock_multiple_sources_extra_only_optional() -> Result<()> { + let context = uv_test::test_context!("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.12" + dependencies = [] + + [project.optional-dependencies] + alt = ["iniconfig"] + + [tool.uv.sources] + iniconfig = [ + { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", extra = "alt" }, + ] + "#, + )?; + + uv_snapshot!(context.filters(), context.lock(), @r" + success: true + exit_code: 0 + ----- stdout ----- + ----- stderr ----- Resolved 2 packages in [TIME] "); @@ -24566,22 +25692,19 @@ fn lock_multiple_sources_extra() -> Result<()> { source = { virtual = "." } [package.optional-dependencies] - cpu = [ + alt = [ { name = "iniconfig" }, ] [package.metadata] - requires-dist = [ - { name = "iniconfig", marker = "extra == 'cpu'", url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl" }, - { name = "iniconfig", marker = "extra != 'cpu'" }, - ] - provides-extras = ["cpu"] + requires-dist = [{ name = "iniconfig", marker = "extra == 'alt'", url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl" }] + provides-extras = ["alt"] "# ); }); // Re-run with `--locked`. - uv_snapshot!(context.filters(), context.lock().arg("--locked"), @" + uv_snapshot!(context.filters(), context.lock().arg("--locked"), @r" success: true exit_code: 0 ----- stdout ----- @@ -34196,6 +35319,569 @@ async fn lock_path_dependency_explicit_index_workspace_member() -> Result<()> { Ok(()) } +/// Test that complementary dependencies synthesized from a path dependency's +/// optional extras preserve explicit indexes. +#[test] +fn lock_path_dependency_explicit_index_optional_extra_source() -> Result<()> { + let context = uv_test::test_context!("3.12").with_exclude_newer("2025-01-30T00:00:00Z"); + + // Create the path dependency with an optional extra pinned to an explicit + // index. + let pkg_a = context.temp_dir.child("pkg_a"); + fs_err::create_dir_all(&pkg_a)?; + + let pyproject_toml = pkg_a.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "pkg-a" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["jinja2>=3.1.5"] + + [project.optional-dependencies] + cu118 = ["jinja2>=3.1.5"] + + [tool.uv.sources] + jinja2 = [ + { index = "torch-cu118", extra = "cu118" }, + ] + + [[tool.uv.index]] + name = "torch-cu118" + url = "https://astral-sh.github.io/pytorch-mirror/whl/cu118" + explicit = true + "#, + )?; + + // Create a project that depends on pkg_a. + let pkg_b = context.temp_dir.child("pkg_b"); + fs_err::create_dir_all(&pkg_b)?; + + let pyproject_toml = pkg_b.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "pkg-b" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["pkg-a"] + + [tool.uv.sources] + pkg-a = { path = "../pkg_a/", editable = true } + "#, + )?; + + uv_snapshot!(context.filters(), context.lock().current_dir(&pkg_b), @" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + × No solution found when resolving dependencies for split (markers: extra == 'extra-5-pkg-a-cu118'): + ╰─▶ Because only jinja2{extra == 'extra-5-pkg-a-cu118'}<=3.1.4 is available and pkg-a==0.1.0 depends on jinja2{extra == 'extra-5-pkg-a-cu118'}>=3.1.5, we can conclude that pkg-a==0.1.0 cannot be used. + And because only pkg-a==0.1.0 is available and your project depends on pkg-a, we can conclude that your project's requirements are unsatisfiable. + + hint: The resolution failed for an environment that is not the current one, consider limiting the environments with `tool.uv.environments`. + "); + + Ok(()) +} + +/// Test that complementary dependencies synthesized from a path dependency's +/// optional extras retain applicable root constraints. +#[test] +fn lock_path_dependency_explicit_index_optional_extra_source_constraint() -> Result<()> { + let context = uv_test::test_context!("3.12").with_exclude_newer("2025-01-30T00:00:00Z"); + + // Create the path dependency with an optional extra pinned to an explicit + // index. + let pkg_a = context.temp_dir.child("pkg_a"); + fs_err::create_dir_all(&pkg_a)?; + + let pyproject_toml = pkg_a.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "pkg-a" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["jinja2>=3"] + + [project.optional-dependencies] + cu118 = ["jinja2"] + + [tool.uv.sources] + jinja2 = [ + { index = "torch-cu118", extra = "cu118" }, + ] + + [[tool.uv.index]] + name = "torch-cu118" + url = "https://astral-sh.github.io/pytorch-mirror/whl/cu118" + explicit = true + "#, + )?; + + // Create a project that depends on pkg_a and constrains the extra fork. + let pkg_b = context.temp_dir.child("pkg_b"); + fs_err::create_dir_all(&pkg_b)?; + + let pyproject_toml = pkg_b.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "pkg-b" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["pkg-a"] + + [tool.uv] + constraint-dependencies = ["jinja2>3.1.4 ; extra == 'cu118'"] + + [tool.uv.sources] + pkg-a = { path = "../pkg_a/", editable = true } + "#, + )?; + + uv_snapshot!(context.filters(), context.lock().current_dir(&pkg_b), @" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + × No solution found when resolving dependencies for split (markers: extra == 'extra-5-pkg-a-cu118'): + ╰─▶ Because only the following versions of jinja2{extra == 'extra-5-pkg-a-cu118'} are available: + jinja2{extra == 'extra-5-pkg-a-cu118'}==3.1.2 + jinja2{extra == 'extra-5-pkg-a-cu118'}==3.1.3 + jinja2{extra == 'extra-5-pkg-a-cu118'}==3.1.4 + and pkg-a==0.1.0 depends on jinja2{extra == 'extra-5-pkg-a-cu118'}, we can conclude that pkg-a==0.1.0 depends on jinja2. + And because pkg-a==0.1.0 depends on jinja2>3.1.4, we can conclude that pkg-a==0.1.0 cannot be used. + And because only pkg-a==0.1.0 is available and your project depends on pkg-a, we can conclude that your project's requirements are unsatisfiable. + + hint: The resolution failed for an environment that is not the current one, consider limiting the environments with `tool.uv.environments`. + "); + + Ok(()) +} + +/// Test that complementary source splits for path dependencies are emitted per +/// extra when the same source applies to multiple sibling extras. +#[test] +fn lock_path_dependency_optional_extra_source_multiple_extras() -> Result<()> { + let context = uv_test::test_context!("3.12"); + + let pkg_a = context.temp_dir.child("pkg_a"); + fs_err::create_dir_all(&pkg_a)?; + + let pyproject_toml = pkg_a.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "pkg-a" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["iniconfig>=2"] + + [project.optional-dependencies] + alt = ["iniconfig"] + dev = ["iniconfig"] + + [tool.uv.sources] + iniconfig = [ + { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", extra = "alt" }, + { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", extra = "dev" }, + ] + "#, + )?; + + let pkg_b = context.temp_dir.child("pkg_b"); + fs_err::create_dir_all(&pkg_b)?; + + let pyproject_toml = pkg_b.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "pkg-b" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["pkg-a"] + + [tool.uv.sources] + pkg-a = { path = "../pkg_a/", editable = true } + "#, + )?; + + uv_snapshot!(context.filters(), context.lock().current_dir(&pkg_b), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Resolved 3 packages in [TIME] + "); + + let lock = context.read("pkg_b/uv.lock"); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r#" + version = 1 + revision = 3 + requires-python = ">=3.12" + + [options] + exclude-newer = "2024-03-25T00:00:00Z" + + [[package]] + name = "iniconfig" + version = "2.0.0" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646, upload-time = "2023-01-07T11:08:11.254Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892, upload-time = "2023-01-07T11:08:09.864Z" }, + ] + + [[package]] + name = "pkg-a" + version = "0.1.0" + source = { editable = "../pkg_a" } + dependencies = [ + { name = "iniconfig" }, + ] + + [package.metadata] + requires-dist = [ + { name = "iniconfig", specifier = ">=2" }, + { name = "iniconfig", marker = "extra == 'alt'", url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl" }, + { name = "iniconfig", marker = "extra == 'dev'", url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl" }, + ] + provides-extras = ["alt", "dev"] + + [[package]] + name = "pkg-b" + version = "0.1.0" + source = { virtual = "." } + dependencies = [ + { name = "pkg-a" }, + ] + + [package.metadata] + requires-dist = [{ name = "pkg-a", editable = "../pkg_a" }] + "# + ); + }); + + uv_snapshot!(context.filters(), context.lock().arg("--locked").current_dir(&pkg_b), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Resolved 3 packages in [TIME] + "); + + Ok(()) +} + +/// Test that complementary explicit-index splits for path dependencies are +/// emitted per extra when the same source applies to multiple sibling extras. +#[test] +fn lock_path_dependency_explicit_index_optional_extra_source_multiple_extras() -> Result<()> { + let context = uv_test::test_context!("3.12").with_exclude_newer("2025-01-30T00:00:00Z"); + + let pkg_a = context.temp_dir.child("pkg_a"); + fs_err::create_dir_all(&pkg_a)?; + + let pyproject_toml = pkg_a.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "pkg-a" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["jinja2>=3"] + + [project.optional-dependencies] + cu118 = ["jinja2"] + cu124 = ["jinja2"] + + [tool.uv.sources] + jinja2 = [ + { index = "torch-cu118", extra = "cu118" }, + { index = "torch-cu118", extra = "cu124" }, + ] + + [[tool.uv.index]] + name = "torch-cu118" + url = "https://astral-sh.github.io/pytorch-mirror/whl/cu118" + explicit = true + "#, + )?; + + let pkg_b = context.temp_dir.child("pkg_b"); + fs_err::create_dir_all(&pkg_b)?; + + let pyproject_toml = pkg_b.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "pkg-b" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["pkg-a"] + + [tool.uv.sources] + pkg-a = { path = "../pkg_a/", editable = true } + "#, + )?; + + uv_snapshot!(context.filters(), context.lock().current_dir(&pkg_b), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Resolved 5 packages in [TIME] + "); + + let lock = context.read("pkg_b/uv.lock"); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r#" + version = 1 + revision = 3 + requires-python = ">=3.12" + + [options] + exclude-newer = "2025-01-30T00:00:00Z" + + [[package]] + name = "jinja2" + version = "3.1.4" + source = { registry = "https://astral-sh.github.io/pytorch-mirror/whl/cu118" } + dependencies = [ + { name = "markupsafe", marker = "extra == 'extra-5-pkg-a-cu118' or extra == 'extra-5-pkg-a-cu124'" }, + ] + wheels = [ + { url = "https://download.pytorch.org/whl/Jinja2-3.1.4-py3-none-any.whl", upload-time = "2025-01-29T22:50:57.275Z" }, + ] + + [[package]] + name = "jinja2" + version = "3.1.5" + source = { registry = "https://pypi.org/simple" } + dependencies = [ + { name = "markupsafe", marker = "extra != 'extra-5-pkg-a-cu118' and extra != 'extra-5-pkg-a-cu124'" }, + ] + sdist = { url = "https://files.pythonhosted.org/packages/af/92/b3130cbbf5591acf9ade8708c365f3238046ac7cb8ccba6e81abccb0ccff/jinja2-3.1.5.tar.gz", hash = "sha256:8fefff8dc3034e27bb80d67c671eb8a9bc424c0ef4c0826edbff304cceff43bb", size = 244674, upload-time = "2024-12-21T18:30:22.828Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/0f/2ba5fbcd631e3e88689309dbe978c5769e883e4b84ebfe7da30b43275c5a/jinja2-3.1.5-py3-none-any.whl", hash = "sha256:aba0f4dc9ed8013c424088f68a5c226f7d6097ed89b246d7749c2ec4175c6adb", size = 134596, upload-time = "2024-12-21T18:30:19.133Z" }, + ] + + [[package]] + name = "markupsafe" + version = "3.0.2" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537, upload-time = "2024-10-18T15:21:54.129Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/22/09/d1f21434c97fc42f09d290cbb6350d44eb12f09cc62c9476effdb33a18aa/MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", size = 14274, upload-time = "2024-10-18T15:21:13.777Z" }, + { url = "https://files.pythonhosted.org/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", size = 12348, upload-time = "2024-10-18T15:21:14.822Z" }, + { url = "https://files.pythonhosted.org/packages/e0/25/dd5c0f6ac1311e9b40f4af06c78efde0f3b5cbf02502f8ef9501294c425b/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", size = 24149, upload-time = "2024-10-18T15:21:15.642Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f0/89e7aadfb3749d0f52234a0c8c7867877876e0a20b60e2188e9850794c17/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", size = 23118, upload-time = "2024-10-18T15:21:17.133Z" }, + { url = "https://files.pythonhosted.org/packages/d5/da/f2eeb64c723f5e3777bc081da884b414671982008c47dcc1873d81f625b6/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", size = 22993, upload-time = "2024-10-18T15:21:18.064Z" }, + { url = "https://files.pythonhosted.org/packages/da/0e/1f32af846df486dce7c227fe0f2398dc7e2e51d4a370508281f3c1c5cddc/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", size = 24178, upload-time = "2024-10-18T15:21:18.859Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f6/bb3ca0532de8086cbff5f06d137064c8410d10779c4c127e0e47d17c0b71/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", size = 23319, upload-time = "2024-10-18T15:21:19.671Z" }, + { url = "https://files.pythonhosted.org/packages/a2/82/8be4c96ffee03c5b4a034e60a31294daf481e12c7c43ab8e34a1453ee48b/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", size = 23352, upload-time = "2024-10-18T15:21:20.971Z" }, + { url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", size = 15097, upload-time = "2024-10-18T15:21:22.646Z" }, + { url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", size = 15601, upload-time = "2024-10-18T15:21:23.499Z" }, + { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274, upload-time = "2024-10-18T15:21:24.577Z" }, + { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352, upload-time = "2024-10-18T15:21:25.382Z" }, + { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122, upload-time = "2024-10-18T15:21:26.199Z" }, + { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085, upload-time = "2024-10-18T15:21:27.029Z" }, + { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978, upload-time = "2024-10-18T15:21:27.846Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208, upload-time = "2024-10-18T15:21:28.744Z" }, + { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357, upload-time = "2024-10-18T15:21:29.545Z" }, + { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344, upload-time = "2024-10-18T15:21:30.366Z" }, + { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101, upload-time = "2024-10-18T15:21:31.207Z" }, + { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603, upload-time = "2024-10-18T15:21:32.032Z" }, + { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510, upload-time = "2024-10-18T15:21:33.625Z" }, + { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486, upload-time = "2024-10-18T15:21:34.611Z" }, + { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480, upload-time = "2024-10-18T15:21:35.398Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914, upload-time = "2024-10-18T15:21:36.231Z" }, + { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796, upload-time = "2024-10-18T15:21:37.073Z" }, + { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473, upload-time = "2024-10-18T15:21:37.932Z" }, + { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114, upload-time = "2024-10-18T15:21:39.799Z" }, + { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098, upload-time = "2024-10-18T15:21:40.813Z" }, + { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208, upload-time = "2024-10-18T15:21:41.814Z" }, + { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739, upload-time = "2024-10-18T15:21:42.784Z" }, + ] + + [[package]] + name = "pkg-a" + version = "0.1.0" + source = { editable = "../pkg_a" } + dependencies = [ + { name = "jinja2", version = "3.1.4", source = { registry = "https://astral-sh.github.io/pytorch-mirror/whl/cu118" }, marker = "extra == 'extra-5-pkg-a-cu118' or extra == 'extra-5-pkg-a-cu124'" }, + { name = "jinja2", version = "3.1.5", source = { registry = "https://pypi.org/simple" }, marker = "extra != 'extra-5-pkg-a-cu118' and extra != 'extra-5-pkg-a-cu124'" }, + ] + + [package.metadata] + requires-dist = [ + { name = "jinja2", specifier = ">=3" }, + { name = "jinja2", marker = "extra == 'cu118'", index = "https://astral-sh.github.io/pytorch-mirror/whl/cu118", conflict = { package = "pkg-a", extra = "cu118" } }, + { name = "jinja2", marker = "extra == 'cu124'", index = "https://astral-sh.github.io/pytorch-mirror/whl/cu118", conflict = { package = "pkg-a", extra = "cu124" } }, + ] + provides-extras = ["cu118", "cu124"] + + [[package]] + name = "pkg-b" + version = "0.1.0" + source = { virtual = "." } + dependencies = [ + { name = "pkg-a" }, + ] + + [package.metadata] + requires-dist = [{ name = "pkg-a", editable = "../pkg_a" }] + "# + ); + }); + + uv_snapshot!(context.filters(), context.lock().arg("--locked").current_dir(&pkg_b), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Resolved 5 packages in [TIME] + "); + + Ok(()) +} + +/// Test that optional sourced dependencies in a path dependency do not leak +/// sibling extra constraints into the base fork when the extra is unrequested. +#[test] +fn lock_path_dependency_optional_extra_only_source_ignores_unrequested_constraints() -> Result<()> { + let context = uv_test::test_context!("3.12"); + + let pkg_a = context.temp_dir.child("pkg_a"); + fs_err::create_dir_all(&pkg_a)?; + + let pyproject_toml = pkg_a.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "pkg-a" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [] + + [project.optional-dependencies] + alt = ["iniconfig"] + + [tool.uv.sources] + iniconfig = [ + { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", extra = "alt" }, + ] + "#, + )?; + + let pkg_b = context.temp_dir.child("pkg_b"); + fs_err::create_dir_all(&pkg_b)?; + + let pyproject_toml = pkg_b.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "pkg-b" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["pkg-a"] + + [tool.uv] + constraint-dependencies = ["iniconfig>999 ; extra == 'alt'"] + + [tool.uv.sources] + pkg-a = { path = "../pkg_a/", editable = true } + "#, + )?; + + uv_snapshot!(context.filters(), context.lock().current_dir(&pkg_b), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Resolved 2 packages in [TIME] + "); + + let lock = context.read("pkg_b/uv.lock"); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r#" + version = 1 + revision = 3 + requires-python = ">=3.12" + + [options] + exclude-newer = "2024-03-25T00:00:00Z" + + [manifest] + constraints = [{ name = "iniconfig", marker = "extra == 'alt'", specifier = ">999" }] + + [[package]] + name = "pkg-a" + version = "0.1.0" + source = { editable = "../pkg_a" } + + [package.metadata] + requires-dist = [{ name = "iniconfig", marker = "extra == 'alt'", url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl" }] + provides-extras = ["alt"] + + [[package]] + name = "pkg-b" + version = "0.1.0" + source = { virtual = "." } + dependencies = [ + { name = "pkg-a" }, + ] + + [package.metadata] + requires-dist = [{ name = "pkg-a", editable = "../pkg_a" }] + "# + ); + }); + + uv_snapshot!(context.filters(), context.lock().arg("--locked").current_dir(&pkg_b), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Resolved 2 packages in [TIME] + "); + + Ok(()) +} + /// Test that lockfile validation works correctly when path dependency has /// both explicit and non-explicit indexes. #[tokio::test] diff --git a/crates/uv/tests/it/pip_install.rs b/crates/uv/tests/it/pip_install.rs index 1fad6fde9e..b0d1334e47 100644 --- a/crates/uv/tests/it/pip_install.rs +++ b/crates/uv/tests/it/pip_install.rs @@ -8484,6 +8484,55 @@ fn tool_uv_sources_is_in_preview() -> Result<()> { Ok(()) } +/// Installing a local package should not reintroduce unrequested dependency +/// groups while resolving its sourced dependencies. +#[test] +fn tool_uv_sources_ignore_unrequested_dependency_groups_for_path_install() -> Result<()> { + let context = uv_test::test_context!("3.12").with_exclude_newer("2025-01-30T00:00:00Z"); + + let package = context.temp_dir.child("pkg"); + package.create_dir_all()?; + let pyproject_toml = package.child("pyproject.toml"); + pyproject_toml.write_str(indoc! {r#" + [project] + name = "pkg" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["jinja2>=3.1.5"] + + [dependency-groups] + cu118 = ["jinja2>=3.1.5"] + + [tool.uv.sources] + jinja2 = [ + { index = "torch-cu118", group = "cu118" }, + ] + + [[tool.uv.index]] + name = "torch-cu118" + url = "https://astral-sh.github.io/pytorch-mirror/whl/cu118" + explicit = true + "#})?; + + uv_snapshot!(context.filters(), context.pip_install() + .arg(package.path()), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 3 packages in [TIME] + Prepared 3 packages in [TIME] + Installed 3 packages in [TIME] + + jinja2==3.1.5 + + markupsafe==3.0.2 + + pkg==0.1.0 (from file://[TEMP_DIR]/pkg) + " + ); + + Ok(()) +} + /// Allow transitive URLs via recursive extras. #[test] fn recursive_extra_transitive_url() -> Result<()> {