diff --git a/crates/uv-distribution-types/src/resolution.rs b/crates/uv-distribution-types/src/resolution.rs index 8950e991c..793e0db74 100644 --- a/crates/uv-distribution-types/src/resolution.rs +++ b/crates/uv-distribution-types/src/resolution.rs @@ -216,6 +216,7 @@ impl From<&ResolvedDist> for RequirementSource { uv_pep440::VersionSpecifier::equals_version(version.clone()), ), index: Some(wheels.best_wheel().index.url().clone()), + conflict: None, }, Dist::Built(BuiltDist::DirectUrl(wheel)) => { let mut location = wheel.url.to_url(); @@ -237,6 +238,7 @@ impl From<&ResolvedDist> for RequirementSource { uv_pep440::VersionSpecifier::equals_version(sdist.version.clone()), ), index: Some(sdist.index.url().clone()), + conflict: None, }, Dist::Source(SourceDist::DirectUrl(sdist)) => { let mut location = sdist.url.to_url(); @@ -272,6 +274,7 @@ impl From<&ResolvedDist> for RequirementSource { uv_pep440::VersionSpecifier::equals_version(dist.version().clone()), ), index: None, + conflict: None, }, } } diff --git a/crates/uv-distribution/src/metadata/lowering.rs b/crates/uv-distribution/src/metadata/lowering.rs index 71a093f9f..1c77cf749 100644 --- a/crates/uv-distribution/src/metadata/lowering.rs +++ b/crates/uv-distribution/src/metadata/lowering.rs @@ -10,10 +10,12 @@ use uv_configuration::LowerBound; use uv_distribution_filename::DistExtension; use uv_distribution_types::{Index, IndexLocations, IndexName, Origin}; use uv_git::GitReference; -use uv_normalize::PackageName; +use uv_normalize::{ExtraName, GroupName, PackageName}; use uv_pep440::VersionSpecifiers; use uv_pep508::{MarkerTree, VerbatimUrl, VersionOrUrl}; -use uv_pypi_types::{ParsedUrlError, Requirement, RequirementSource, VerbatimParsedUrl}; +use uv_pypi_types::{ + ConflictItem, ParsedUrlError, Requirement, RequirementSource, VerbatimParsedUrl, +}; use uv_warnings::warn_user_once; use uv_workspace::pyproject::{PyProjectToml, Source, Sources}; use uv_workspace::Workspace; @@ -39,11 +41,14 @@ impl LoweredRequirement { project_dir: &'data Path, project_sources: &'data BTreeMap, project_indexes: &'data [Index], + extra: Option<&ExtraName>, + group: Option<&GroupName>, locations: &'data IndexLocations, workspace: &'data Workspace, lower_bound: LowerBound, git_member: Option<&'data GitWorkspaceMember<'data>>, ) -> impl Iterator> + 'data { + // Identify the source from the `tool.uv.sources` table. let (source, origin) = if let Some(source) = project_sources.get(&requirement.name) { (Some(source), RequirementOrigin::Project) } else if let Some(source) = workspace.sources().get(&requirement.name) { @@ -51,7 +56,29 @@ impl LoweredRequirement { } else { (None, RequirementOrigin::Project) }; - let source = source.cloned(); + + // If the source only applies to a given extra or dependency group, filter it out. + let source = source.map(|source| { + source + .iter() + .filter(|source| { + if let Some(target) = source.extra() { + if extra != Some(target) { + return false; + } + } + + if let Some(target) = source.group() { + if group != Some(target) { + return false; + } + } + + true + }) + .cloned() + .collect::() + }); let workspace_package_declared = // We require that when you use a package that's part of the workspace, ... @@ -92,7 +119,7 @@ impl LoweredRequirement { // Determine the space covered by the sources. let mut total = MarkerTree::FALSE; for source in source.iter() { - total.or(source.marker()); + total.or(source.marker().clone()); } // Determine the space covered by the requirement. @@ -117,6 +144,7 @@ impl LoweredRequirement { tag, branch, marker, + .. } => { if matches!(requirement.version_or_url, Some(VersionOrUrl::Url(_))) { return Err(LoweringError::ConflictingUrls); @@ -134,6 +162,7 @@ impl LoweredRequirement { url, subdirectory, marker, + .. } => { if matches!(requirement.version_or_url, Some(VersionOrUrl::Url(_))) { return Err(LoweringError::ConflictingUrls); @@ -145,6 +174,7 @@ impl LoweredRequirement { path, editable, marker, + .. } => { if matches!(requirement.version_or_url, Some(VersionOrUrl::Url(_))) { return Err(LoweringError::ConflictingUrls); @@ -158,7 +188,12 @@ impl LoweredRequirement { )?; (source, marker) } - Source::Registry { index, marker } => { + Source::Registry { + index, + marker, + extra, + group, + } => { // Identify the named index from either the project indexes or the workspace indexes, // in that order. let Some(index) = locations @@ -176,13 +211,23 @@ impl LoweredRequirement { index, )); }; - let source = - registry_source(&requirement, index.into_url(), lower_bound)?; + let conflict = if let Some(extra) = extra { + Some(ConflictItem::from((project_name.clone(), extra))) + } else { + group.map(|group| ConflictItem::from((project_name.clone(), group))) + }; + let source = registry_source( + &requirement, + index.into_url(), + conflict, + lower_bound, + )?; (source, marker) } Source::Workspace { workspace: is_workspace, marker, + .. } => { if !is_workspace { return Err(LoweringError::WorkspaceFalse); @@ -291,13 +336,27 @@ impl LoweredRequirement { return Either::Left(std::iter::once(Ok(Self(Requirement::from(requirement))))); }; + // If the source only applies to a given extra, filter it out. + let source = source + .iter() + .filter(|source| { + source.extra().map_or(true, |target| { + requirement + .marker + .top_level_extra_name() + .is_some_and(|extra| extra == *target) + }) + }) + .cloned() + .collect::(); + // Determine whether the markers cover the full space for the requirement. If not, fill the // remaining space with the negation of the sources. let remaining = { // Determine the space covered by the sources. let mut total = MarkerTree::FALSE; for source in source.iter() { - total.or(source.marker()); + total.or(source.marker().clone()); } // Determine the space covered by the requirement. @@ -322,6 +381,7 @@ impl LoweredRequirement { tag, branch, marker, + .. } => { if matches!(requirement.version_or_url, Some(VersionOrUrl::Url(_))) { return Err(LoweringError::ConflictingUrls); @@ -339,6 +399,7 @@ impl LoweredRequirement { url, subdirectory, marker, + .. } => { if matches!(requirement.version_or_url, Some(VersionOrUrl::Url(_))) { return Err(LoweringError::ConflictingUrls); @@ -350,6 +411,7 @@ impl LoweredRequirement { path, editable, marker, + .. } => { if matches!(requirement.version_or_url, Some(VersionOrUrl::Url(_))) { return Err(LoweringError::ConflictingUrls); @@ -363,7 +425,7 @@ impl LoweredRequirement { )?; (source, marker) } - Source::Registry { index, marker } => { + Source::Registry { index, marker, .. } => { let Some(index) = locations .indexes() .filter(|index| matches!(index.origin, Some(Origin::Cli))) @@ -378,8 +440,13 @@ impl LoweredRequirement { index, )); }; - let source = - registry_source(&requirement, index.into_url(), lower_bound)?; + let conflict = None; + let source = registry_source( + &requirement, + index.into_url(), + conflict, + lower_bound, + )?; (source, marker) } Source::Workspace { .. } => { @@ -512,6 +579,7 @@ fn url_source(url: Url, subdirectory: Option) -> Result, index: Url, + conflict: Option, bounds: LowerBound, ) -> Result { match &requirement.version_or_url { @@ -525,11 +593,13 @@ fn registry_source( Ok(RequirementSource::Registry { specifier: VersionSpecifiers::empty(), index: Some(index), + conflict, }) } Some(VersionOrUrl::VersionSpecifier(version)) => Ok(RequirementSource::Registry { specifier: version.clone(), index: Some(index), + conflict, }), Some(VersionOrUrl::Url(_)) => Err(LoweringError::ConflictingUrls), } diff --git a/crates/uv-distribution/src/metadata/mod.rs b/crates/uv-distribution/src/metadata/mod.rs index 05ed1ec6a..339db25f9 100644 --- a/crates/uv-distribution/src/metadata/mod.rs +++ b/crates/uv-distribution/src/metadata/mod.rs @@ -28,6 +28,14 @@ pub enum MetadataError { LoweringError(PackageName, #[source] Box), #[error("Failed to parse entry in group `{0}`: `{1}`")] GroupLoweringError(GroupName, PackageName, #[source] Box), + #[error("Source entry for `{0}` only applies to extra `{1}`, but the `{1}` extra does not exist. When an extra is present on a source (e.g., `extra = \"{1}\"`), the relevant package must be included in the `project.optional-dependencies` section for that extra (e.g., `project.optional-dependencies = {{ \"{1}\" = [\"{0}\"] }}`).")] + MissingSourceExtra(PackageName, ExtraName), + #[error("Source entry for `{0}` only applies to extra `{1}`, but `{0}` was not found under the `project.optional-dependencies` section for that extra. When an extra is present on a source (e.g., `extra = \"{1}\"`), the relevant package must be included in the `project.optional-dependencies` section for that extra (e.g., `project.optional-dependencies = {{ \"{1}\" = [\"{0}\"] }}`).")] + IncompleteSourceExtra(PackageName, ExtraName), + #[error("Source entry for `{0}` only applies to dependency group `{1}`, but the `{1}` group does not exist. When a group is present on a source (e.g., `group = \"{1}\"`), the relevant package must be included in the `dependency-groups` section for that extra (e.g., `dependency-groups = {{ \"{1}\" = [\"{0}\"] }}`).")] + MissingSourceGroup(PackageName, GroupName), + #[error("Source entry for `{0}` only applies to dependency group `{1}`, but `{0}` was not found under the `dependency-groups` section for that group. When a group is present on a source (e.g., `group = \"{1}\"`), the relevant package must be included in the `dependency-groups` section for that extra (e.g., `dependency-groups = {{ \"{1}\" = [\"{0}\"] }}`).")] + IncompleteSourceGroup(PackageName, GroupName), } #[derive(Debug, Clone)] diff --git a/crates/uv-distribution/src/metadata/requires_dist.rs b/crates/uv-distribution/src/metadata/requires_dist.rs index 9ad36d08b..dc8b59442 100644 --- a/crates/uv-distribution/src/metadata/requires_dist.rs +++ b/crates/uv-distribution/src/metadata/requires_dist.rs @@ -7,7 +7,7 @@ use uv_configuration::{LowerBound, SourceStrategy}; use uv_distribution_types::IndexLocations; use uv_normalize::{ExtraName, GroupName, PackageName, DEV_DEPENDENCIES}; use uv_workspace::dependency_groups::FlatDependencyGroups; -use uv_workspace::pyproject::ToolUvSources; +use uv_workspace::pyproject::{Sources, ToolUvSources}; use uv_workspace::{DiscoveryOptions, ProjectWorkspace}; #[derive(Debug, Clone)] @@ -111,6 +111,7 @@ impl RequiresDist { SourceStrategy::Disabled => &empty, }; + // Collect the dependency groups. let dependency_groups = { // First, collect `tool.uv.dev_dependencies` let dev_dependencies = project_workspace @@ -130,85 +131,90 @@ impl RequiresDist { .flatten() .collect::>(); - // Resolve any `include-group` entries in `dependency-groups`. - let dependency_groups = + // Flatten the dependency groups. + let mut dependency_groups = FlatDependencyGroups::from_dependency_groups(&dependency_groups) - .map_err(|err| err.with_dev_dependencies(dev_dependencies))? - .into_iter() - .chain( - // Only add the `dev` group if `dev-dependencies` is defined. - dev_dependencies - .into_iter() - .map(|requirements| (DEV_DEPENDENCIES.clone(), requirements.clone())), - ) - .map(|(name, requirements)| { - let requirements = match source_strategy { - SourceStrategy::Enabled => requirements - .into_iter() - .flat_map(|requirement| { - let group_name = name.clone(); - let requirement_name = requirement.name.clone(); - LoweredRequirement::from_requirement( - requirement, - &metadata.name, - project_workspace.project_root(), - project_sources, - project_indexes, - locations, - project_workspace.workspace(), - lower_bound, - git_member, - ) - .map(move |requirement| { - match requirement { - Ok(requirement) => Ok(requirement.into_inner()), - Err(err) => Err(MetadataError::GroupLoweringError( - group_name.clone(), - requirement_name.clone(), - Box::new(err), - )), - } - }) - }) - .collect::, _>>(), - SourceStrategy::Disabled => Ok(requirements - .into_iter() - .map(uv_pypi_types::Requirement::from) - .collect()), - }?; - Ok::<(GroupName, Vec), MetadataError>(( - name, - requirements, - )) - }) - .collect::, _>>()?; + .map_err(|err| err.with_dev_dependencies(dev_dependencies))?; - // Merge any overlapping groups. - let mut map = BTreeMap::new(); - for (name, dependencies) in dependency_groups { - match map.entry(name) { - std::collections::btree_map::Entry::Vacant(entry) => { - entry.insert(dependencies); - } - std::collections::btree_map::Entry::Occupied(mut entry) => { - entry.get_mut().extend(dependencies); - } - } + // Add the `dev` group, if `dev-dependencies` is defined. + if let Some(dev_dependencies) = dev_dependencies { + dependency_groups + .entry(DEV_DEPENDENCIES.clone()) + .or_insert_with(Vec::new) + .extend(dev_dependencies.clone()); } - map + + dependency_groups }; + // Now that we've resolved the dependency groups, we can validate that each source references + // a valid extra or group, if present. + Self::validate_sources(project_sources, &metadata, &dependency_groups)?; + + // Lower the dependency groups. + let dependency_groups = dependency_groups + .into_iter() + .map(|(name, requirements)| { + let requirements = match source_strategy { + SourceStrategy::Enabled => requirements + .into_iter() + .flat_map(|requirement| { + let requirement_name = requirement.name.clone(); + let group = name.clone(); + let extra = None; + LoweredRequirement::from_requirement( + requirement, + &metadata.name, + project_workspace.project_root(), + project_sources, + project_indexes, + extra, + Some(&group), + locations, + project_workspace.workspace(), + lower_bound, + git_member, + ) + .map( + move |requirement| match requirement { + Ok(requirement) => Ok(requirement.into_inner()), + Err(err) => Err(MetadataError::GroupLoweringError( + group.clone(), + requirement_name.clone(), + Box::new(err), + )), + }, + ) + }) + .collect::, _>>(), + SourceStrategy::Disabled => Ok(requirements + .into_iter() + .map(uv_pypi_types::Requirement::from) + .collect()), + }?; + Ok::<(GroupName, Vec), MetadataError>(( + name, + requirements, + )) + }) + .collect::, _>>()?; + + // Lower the requirements. let requires_dist = metadata.requires_dist.into_iter(); let requires_dist = match source_strategy { SourceStrategy::Enabled => requires_dist .flat_map(|requirement| { let requirement_name = requirement.name.clone(); + let extra = requirement.marker.top_level_extra_name(); + let group = None; LoweredRequirement::from_requirement( requirement, &metadata.name, project_workspace.project_root(), project_sources, project_indexes, + extra.as_ref(), + group, locations, project_workspace.workspace(), lower_bound, @@ -236,6 +242,64 @@ impl RequiresDist { provides_extras: metadata.provides_extras, }) } + + /// Validate the sources for a given [`uv_pypi_types::RequiresDist`]. + /// + /// If a source is requested with an `extra` or `group`, ensure that the relevant dependency is + /// present in the relevant `project.optional-dependencies` or `dependency-groups` section. + fn validate_sources( + sources: &BTreeMap, + metadata: &uv_pypi_types::RequiresDist, + dependency_groups: &FlatDependencyGroups, + ) -> Result<(), MetadataError> { + for (name, sources) in sources { + for source in sources.iter() { + if let Some(extra) = source.extra() { + // If the extra doesn't exist at all, error. + if !metadata.provides_extras.contains(extra) { + return Err(MetadataError::MissingSourceExtra( + name.clone(), + extra.clone(), + )); + } + + // If there is no such requirement with the extra, error. + if !metadata.requires_dist.iter().any(|requirement| { + requirement.name == *name + && requirement.marker.top_level_extra_name().as_ref() == Some(extra) + }) { + return Err(MetadataError::IncompleteSourceExtra( + name.clone(), + extra.clone(), + )); + } + } + + if let Some(group) = source.group() { + // If the group doesn't exist at all, error. + let Some(dependencies) = dependency_groups.get(group) else { + return Err(MetadataError::MissingSourceGroup( + name.clone(), + group.clone(), + )); + }; + + // If there is no such requirement with the group, error. + if !dependencies + .iter() + .any(|requirement| requirement.name == *name) + { + return Err(MetadataError::IncompleteSourceGroup( + name.clone(), + group.clone(), + )); + } + } + } + } + + Ok(()) + } } impl From for RequiresDist { @@ -383,7 +447,28 @@ mod test { | 8 | tqdm = { git = "https://github.com/tqdm/tqdm", ref = "baaaaaab" } | ^^^ - unknown field `ref`, expected one of `git`, `subdirectory`, `rev`, `tag`, `branch`, `url`, `path`, `editable`, `index`, `workspace`, `marker` + unknown field `ref`, expected one of `git`, `subdirectory`, `rev`, `tag`, `branch`, `url`, `path`, `editable`, `index`, `workspace`, `marker`, `extra`, `group` + "###); + } + + #[tokio::test] + async fn extra_and_group() { + let input = indoc! {r#" + [project] + name = "foo" + version = "0.0.0" + dependencies = [] + + [tool.uv.sources] + tqdm = { git = "https://github.com/tqdm/tqdm", extra = "torch", group = "dev" } + "#}; + + assert_snapshot!(format_err(input).await, @r###" + error: TOML parse error at line 7, column 8 + | + 7 | tqdm = { git = "https://github.com/tqdm/tqdm", extra = "torch", group = "dev" } + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + cannot specify both `extra` and `group` "###); } diff --git a/crates/uv-pep508/src/marker/tree.rs b/crates/uv-pep508/src/marker/tree.rs index 5959de032..5dffb7da5 100644 --- a/crates/uv-pep508/src/marker/tree.rs +++ b/crates/uv-pep508/src/marker/tree.rs @@ -426,7 +426,16 @@ pub enum MarkerValueExtra { } impl MarkerValueExtra { - fn as_extra(&self) -> Option<&ExtraName> { + /// Returns the [`ExtraName`] for this value, if it is a valid extra. + pub fn as_extra(&self) -> Option<&ExtraName> { + match self { + Self::Extra(extra) => Some(extra), + Self::Arbitrary(_) => None, + } + } + + /// Convert the [`MarkerValueExtra`] to an [`ExtraName`], if possible. + fn into_extra(self) -> Option { match self { Self::Extra(extra) => Some(extra), Self::Arbitrary(_) => None, @@ -1113,6 +1122,19 @@ impl MarkerTree { extra_expression } + /// Find a top level `extra == "..."` name. + /// + /// ASSUMPTION: There is one `extra = "..."`, and it's either the only marker or part of the + /// main conjunction. + pub fn top_level_extra_name(&self) -> Option { + let extra_expression = self.top_level_extra()?; + + match extra_expression { + MarkerExpression::Extra { name, .. } => name.into_extra(), + _ => unreachable!(), + } + } + /// Simplify this marker by *assuming* that the Python version range /// provided is true and that the complement of it is false. /// diff --git a/crates/uv-pypi-types/src/requirement.rs b/crates/uv-pypi-types/src/requirement.rs index 2cfd65ac5..3121d0af5 100644 --- a/crates/uv-pypi-types/src/requirement.rs +++ b/crates/uv-pypi-types/src/requirement.rs @@ -16,8 +16,8 @@ use uv_pep508::{ }; use crate::{ - Hashes, ParsedArchiveUrl, ParsedDirectoryUrl, ParsedGitUrl, ParsedPathUrl, ParsedUrl, - ParsedUrlError, VerbatimParsedUrl, + ConflictItem, Hashes, ParsedArchiveUrl, ParsedDirectoryUrl, ParsedGitUrl, ParsedPathUrl, + ParsedUrl, ParsedUrlError, VerbatimParsedUrl, }; #[derive(Debug, Error)] @@ -192,11 +192,13 @@ impl From> for Requirement { None => RequirementSource::Registry { specifier: VersionSpecifiers::empty(), index: None, + conflict: None, }, // The most popular case: just a name, a version range and maybe extras. Some(VersionOrUrl::VersionSpecifier(specifier)) => RequirementSource::Registry { specifier, index: None, + conflict: None, }, Some(VersionOrUrl::Url(url)) => { RequirementSource::from_parsed_url(url.parsed_url, url.verbatim) @@ -229,7 +231,9 @@ impl Display for Requirement { )?; } match &self.source { - RequirementSource::Registry { specifier, index } => { + RequirementSource::Registry { + specifier, index, .. + } => { write!(f, "{specifier}")?; if let Some(index) = index { write!(f, " (index: {index})")?; @@ -283,6 +287,8 @@ pub enum RequirementSource { specifier: VersionSpecifiers, /// Choose a version from the index at the given URL. index: Option, + /// The conflict item associated with the source, if any. + conflict: Option, }, // TODO(konsti): Track and verify version specifier from `project.dependencies` matches the // version in remote location. @@ -513,7 +519,9 @@ impl Display for RequirementSource { /// rather than for inclusion in a `requirements.txt` file. fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { - Self::Registry { specifier, index } => { + Self::Registry { + specifier, index, .. + } => { write!(f, "{specifier}")?; if let Some(index) = index { write!(f, " (index: {index})")?; @@ -571,6 +579,7 @@ enum RequirementSourceWire { #[serde(skip_serializing_if = "VersionSpecifiers::is_empty", default)] specifier: VersionSpecifiers, index: Option, + conflict: Option, }, } @@ -580,11 +589,16 @@ impl From for RequirementSourceWire { RequirementSource::Registry { specifier, mut index, + conflict, } => { if let Some(index) = index.as_mut() { redact_credentials(index); } - Self::Registry { specifier, index } + Self::Registry { + specifier, + index, + conflict, + } } RequirementSource::Url { subdirectory, @@ -686,9 +700,15 @@ impl TryFrom for RequirementSource { fn try_from(wire: RequirementSourceWire) -> Result { match wire { - RequirementSourceWire::Registry { specifier, index } => { - Ok(Self::Registry { specifier, index }) - } + RequirementSourceWire::Registry { + specifier, + index, + conflict, + } => Ok(Self::Registry { + specifier, + index, + conflict, + }), RequirementSourceWire::Git { git } => { let mut repository = Url::parse(&git)?; @@ -814,6 +834,7 @@ mod tests { source: RequirementSource::Registry { specifier: ">1,<2".parse().unwrap(), index: None, + conflict: None, }, origin: None, }; diff --git a/crates/uv-resolver/src/lock/mod.rs b/crates/uv-resolver/src/lock/mod.rs index 069e26877..66538c1bf 100644 --- a/crates/uv-resolver/src/lock/mod.rs +++ b/crates/uv-resolver/src/lock/mod.rs @@ -3757,6 +3757,7 @@ fn normalize_requirement( RequirementSource::Registry { specifier, mut index, + conflict, } => { if let Some(index) = index.as_mut() { redact_credentials(index); @@ -3765,7 +3766,11 @@ fn normalize_requirement( name: requirement.name, extras: requirement.extras, marker: requirement.marker, - source: RequirementSource::Registry { specifier, index }, + source: RequirementSource::Registry { + specifier, + index, + conflict, + }, origin: None, }) } diff --git a/crates/uv-resolver/src/resolver/indexes.rs b/crates/uv-resolver/src/resolver/indexes.rs index 74a27d681..411b9b6f0 100644 --- a/crates/uv-resolver/src/resolver/indexes.rs +++ b/crates/uv-resolver/src/resolver/indexes.rs @@ -1,7 +1,7 @@ use uv_distribution_types::IndexUrl; use uv_normalize::PackageName; use uv_pep508::VerbatimUrl; -use uv_pypi_types::RequirementSource; +use uv_pypi_types::{ConflictItem, RequirementSource}; use crate::resolver::ForkMap; use crate::{DependencyMode, Manifest, ResolverEnvironment}; @@ -20,7 +20,13 @@ use crate::{DependencyMode, Manifest, ResolverEnvironment}; /// /// [`Indexes`] would contain a single entry mapping `torch` to `https://download.pytorch.org/whl/cu121`. #[derive(Debug, Default, Clone)] -pub(crate) struct Indexes(ForkMap); +pub(crate) struct Indexes(ForkMap); + +#[derive(Debug, Clone)] +struct Entry { + index: IndexUrl, + conflict: Option, +} impl Indexes { /// Determine the set of explicit, pinned indexes in the [`Manifest`]. @@ -33,13 +39,16 @@ impl Indexes { for requirement in manifest.requirements(env, dependencies) { let RequirementSource::Registry { - index: Some(index), .. + index: Some(index), + conflict, + .. } = &requirement.source else { continue; }; let index = IndexUrl::from(VerbatimUrl::from_url(index.clone())); - indexes.add(&requirement, index); + let conflict = conflict.clone(); + indexes.add(&requirement, Entry { index, conflict }); } Self(indexes) @@ -51,11 +60,17 @@ impl Indexes { } /// Return the explicit index used for a package in the given fork. - pub(crate) fn get( - &self, - package_name: &PackageName, - env: &ResolverEnvironment, - ) -> Vec<&IndexUrl> { - self.0.get(package_name, env) + pub(crate) fn get(&self, name: &PackageName, env: &ResolverEnvironment) -> Vec<&IndexUrl> { + let entries = self.0.get(name, env); + entries + .iter() + .filter(|entry| { + entry + .conflict + .as_ref() + .map_or(true, |conflict| env.included_by_group(conflict.as_ref())) + }) + .map(|entry| &entry.index) + .collect() } } diff --git a/crates/uv-resolver/src/resolver/mod.rs b/crates/uv-resolver/src/resolver/mod.rs index abbf67f34..9371dec9d 100644 --- a/crates/uv-resolver/src/resolver/mod.rs +++ b/crates/uv-resolver/src/resolver/mod.rs @@ -512,11 +512,6 @@ impl ResolverState ResolverState { state.add_package_version_dependencies( - for_package.as_deref(), &version, &self.urls, &self.indexes, @@ -578,7 +572,6 @@ impl ResolverState ResolverState, request_sink: &'a Sender, - for_package: Option<&'a str>, diverging_packages: &'a [PackageName], ) -> impl Iterator> + 'a { debug!( @@ -709,7 +701,6 @@ impl ResolverState, - version: &Version, + for_version: &Version, urls: &Urls, indexes: &Indexes, mut dependencies: Vec, @@ -2271,8 +2261,10 @@ impl ForkState { } } - if let Some(for_package) = for_package { - debug!("Adding transitive dependency for {for_package}: {package}{version}"); + if let Some(name) = self.next.name_no_root() { + debug!( + "Adding transitive dependency for {name}=={for_version}: {package}{version}" + ); } else { // A dependency from the root package or requirements.txt. debug!("Adding direct dependency: {package}{version}"); @@ -2301,7 +2293,7 @@ impl ForkState { self.pubgrub.add_package_version_dependencies( self.next.clone(), - version.clone(), + for_version.clone(), dependencies.into_iter().map(|dependency| { let PubGrubDependency { package, diff --git a/crates/uv-workspace/src/dependency_groups.rs b/crates/uv-workspace/src/dependency_groups.rs index f24667771..225bdcd55 100644 --- a/crates/uv-workspace/src/dependency_groups.rs +++ b/crates/uv-workspace/src/dependency_groups.rs @@ -1,3 +1,4 @@ +use std::collections::btree_map::Entry; use std::collections::BTreeMap; use std::str::FromStr; @@ -102,6 +103,13 @@ impl FlatDependencyGroups { ) -> Option<&Vec>> { self.0.get(group) } + + pub fn entry( + &mut self, + group: GroupName, + ) -> Entry>> { + self.0.entry(group) + } } impl IntoIterator for FlatDependencyGroups { diff --git a/crates/uv-workspace/src/pyproject.rs b/crates/uv-workspace/src/pyproject.rs index 9c5072647..e47a480d9 100644 --- a/crates/uv-workspace/src/pyproject.rs +++ b/crates/uv-workspace/src/pyproject.rs @@ -729,6 +729,12 @@ impl Sources { } } +impl FromIterator for Sources { + fn from_iter>(iter: T) -> Self { + Self(iter.into_iter().collect()) + } +} + impl IntoIterator for Sources { type Item = Source; type IntoIter = std::vec::IntoIter; @@ -792,13 +798,17 @@ impl TryFrom for Sources { match wire { SourcesWire::One(source) => Ok(Self(vec![source])), SourcesWire::Many(sources) => { - // Ensure that the markers are disjoint. - for (lhs, rhs) in sources - .iter() - .map(Source::marker) - .zip(sources.iter().skip(1).map(Source::marker)) - { - if !lhs.is_disjoint(&rhs) { + for (lhs, rhs) in sources.iter().zip(sources.iter().skip(1)) { + if lhs.extra() != rhs.extra() { + continue; + }; + if lhs.group() != rhs.group() { + continue; + }; + + let lhs = lhs.marker(); + let rhs = rhs.marker(); + if !lhs.is_disjoint(rhs) { let Some(left) = lhs.contents().map(|contents| contents.to_string()) else { return Err(SourceError::MissingMarkers); }; @@ -856,6 +866,8 @@ pub enum Source { default )] marker: MarkerTree, + extra: Option, + group: Option, }, /// A remote `http://` or `https://` URL, either a wheel (`.whl`) or a source distribution /// (`.zip`, `.tar.gz`). @@ -875,6 +887,8 @@ pub enum Source { default )] marker: MarkerTree, + extra: Option, + group: Option, }, /// The path to a dependency, either a wheel (a `.whl` file), source distribution (a `.zip` or /// `.tar.gz` file), or source tree (i.e., a directory containing a `pyproject.toml` or @@ -889,6 +903,8 @@ pub enum Source { default )] marker: MarkerTree, + extra: Option, + group: Option, }, /// A dependency pinned to a specific index, e.g., `torch` after setting `torch` to `https://download.pytorch.org/whl/cu118`. Registry { @@ -899,6 +915,8 @@ pub enum Source { default )] marker: MarkerTree, + extra: Option, + group: Option, }, /// A dependency on another package in the workspace. Workspace { @@ -911,6 +929,8 @@ pub enum Source { default )] marker: MarkerTree, + extra: Option, + group: Option, }, } @@ -940,6 +960,8 @@ impl<'de> Deserialize<'de> for Source { default )] marker: MarkerTree, + extra: Option, + group: Option, } // Attempt to deserialize as `CatchAll`. @@ -955,8 +977,17 @@ impl<'de> Deserialize<'de> for Source { index, workspace, marker, + extra, + group, } = CatchAll::deserialize(deserializer)?; + // If both `extra` and `group` are set, return an error. + if extra.is_some() && group.is_some() { + return Err(serde::de::Error::custom( + "cannot specify both `extra` and `group`", + )); + } + // If the `git` field is set, we're dealing with a Git source. if let Some(git) = git { if index.is_some() { @@ -1012,6 +1043,8 @@ impl<'de> Deserialize<'de> for Source { tag, branch, marker, + extra, + group, }); } @@ -1062,6 +1095,8 @@ impl<'de> Deserialize<'de> for Source { url, subdirectory, marker, + extra, + group, }); } @@ -1107,6 +1142,8 @@ impl<'de> Deserialize<'de> for Source { path, editable, marker, + extra, + group, }); } @@ -1153,7 +1190,12 @@ impl<'de> Deserialize<'de> for Source { )); } - return Ok(Self::Registry { index, marker }); + return Ok(Self::Registry { + index, + marker, + extra, + group, + }); } // If the `workspace` field is set, we're dealing with a workspace source. @@ -1199,7 +1241,12 @@ impl<'de> Deserialize<'de> for Source { )); } - return Ok(Self::Workspace { workspace, marker }); + return Ok(Self::Workspace { + workspace, + marker, + extra, + group, + }); } // If none of the fields are set, we're dealing with an error. @@ -1269,6 +1316,8 @@ impl Source { Ok(Some(Source::Workspace { workspace: true, marker: MarkerTree::TRUE, + extra: None, + group: None, })) } RequirementSource::Url { .. } => { @@ -1292,6 +1341,8 @@ impl Source { Source::Registry { index, marker: MarkerTree::TRUE, + extra: None, + group: None, } } else { return Ok(None); @@ -1306,6 +1357,8 @@ impl Source { .map_err(SourceError::Absolute)?, ), marker: MarkerTree::TRUE, + extra: None, + group: None, }, RequirementSource::Url { subdirectory, url, .. @@ -1313,6 +1366,8 @@ impl Source { url: url.to_url(), subdirectory: subdirectory.map(PortablePathBuf::from), marker: MarkerTree::TRUE, + extra: None, + group: None, }, RequirementSource::Git { repository, @@ -1338,6 +1393,8 @@ impl Source { git: repository, subdirectory: subdirectory.map(PortablePathBuf::from), marker: MarkerTree::TRUE, + extra: None, + group: None, } } else { Source::Git { @@ -1347,6 +1404,8 @@ impl Source { git: repository, subdirectory: subdirectory.map(PortablePathBuf::from), marker: MarkerTree::TRUE, + extra: None, + group: None, } } } @@ -1356,13 +1415,35 @@ impl Source { } /// Return the [`MarkerTree`] for the source. - pub fn marker(&self) -> MarkerTree { + pub fn marker(&self) -> &MarkerTree { match self { - Source::Git { marker, .. } => marker.clone(), - Source::Url { marker, .. } => marker.clone(), - Source::Path { marker, .. } => marker.clone(), - Source::Registry { marker, .. } => marker.clone(), - Source::Workspace { marker, .. } => marker.clone(), + Source::Git { marker, .. } => marker, + Source::Url { marker, .. } => marker, + Source::Path { marker, .. } => marker, + Source::Registry { marker, .. } => marker, + Source::Workspace { marker, .. } => marker, + } + } + + /// Return the extra name for the source. + pub fn extra(&self) -> Option<&ExtraName> { + match self { + Source::Git { extra, .. } => extra.as_ref(), + Source::Url { extra, .. } => extra.as_ref(), + Source::Path { extra, .. } => extra.as_ref(), + Source::Registry { extra, .. } => extra.as_ref(), + Source::Workspace { extra, .. } => extra.as_ref(), + } + } + + /// Return the dependency group name for the source. + pub fn group(&self) -> Option<&GroupName> { + match self { + Source::Git { group, .. } => group.as_ref(), + Source::Url { group, .. } => group.as_ref(), + Source::Path { group, .. } => group.as_ref(), + Source::Registry { group, .. } => group.as_ref(), + Source::Workspace { group, .. } => group.as_ref(), } } } diff --git a/crates/uv-workspace/src/workspace.rs b/crates/uv-workspace/src/workspace.rs index 9ea28cc35..9d11eeae1 100644 --- a/crates/uv-workspace/src/workspace.rs +++ b/crates/uv-workspace/src/workspace.rs @@ -1607,104 +1607,108 @@ mod tests { ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]" }, @r###" - { - "project_root": "[ROOT]/albatross-root-workspace", - "project_name": "albatross", - "workspace": { - "install_path": "[ROOT]/albatross-root-workspace", - "packages": { - "albatross": { - "root": "[ROOT]/albatross-root-workspace", - "project": { - "name": "albatross", - "version": "0.1.0", - "requires-python": ">=3.12", - "dependencies": [ - "bird-feeder", - "tqdm>=4,<5" - ], - "optional-dependencies": null - }, - "pyproject_toml": "[PYPROJECT_TOML]" - }, - "bird-feeder": { - "root": "[ROOT]/albatross-root-workspace/packages/bird-feeder", - "project": { - "name": "bird-feeder", - "version": "1.0.0", - "requires-python": ">=3.8", - "dependencies": [ - "anyio>=4.3.0,<5", - "seeds" - ], - "optional-dependencies": null - }, - "pyproject_toml": "[PYPROJECT_TOML]" - }, - "seeds": { - "root": "[ROOT]/albatross-root-workspace/packages/seeds", - "project": { - "name": "seeds", - "version": "1.0.0", - "requires-python": ">=3.12", - "dependencies": [ - "idna==3.6" - ], - "optional-dependencies": null - }, - "pyproject_toml": "[PYPROJECT_TOML]" - } - }, - "sources": { - "bird-feeder": [ - { - "workspace": true - } - ] - }, - "indexes": [], - "pyproject_toml": { - "project": { - "name": "albatross", - "version": "0.1.0", - "requires-python": ">=3.12", - "dependencies": [ - "bird-feeder", - "tqdm>=4,<5" - ], - "optional-dependencies": null - }, - "tool": { - "uv": { - "sources": { - "bird-feeder": [ - { - "workspace": true - } - ] + { + "project_root": "[ROOT]/albatross-root-workspace", + "project_name": "albatross", + "workspace": { + "install_path": "[ROOT]/albatross-root-workspace", + "packages": { + "albatross": { + "root": "[ROOT]/albatross-root-workspace", + "project": { + "name": "albatross", + "version": "0.1.0", + "requires-python": ">=3.12", + "dependencies": [ + "bird-feeder", + "tqdm>=4,<5" + ], + "optional-dependencies": null + }, + "pyproject_toml": "[PYPROJECT_TOML]" }, - "index": null, - "workspace": { - "members": [ - "packages/*" + "bird-feeder": { + "root": "[ROOT]/albatross-root-workspace/packages/bird-feeder", + "project": { + "name": "bird-feeder", + "version": "1.0.0", + "requires-python": ">=3.8", + "dependencies": [ + "anyio>=4.3.0,<5", + "seeds" + ], + "optional-dependencies": null + }, + "pyproject_toml": "[PYPROJECT_TOML]" + }, + "seeds": { + "root": "[ROOT]/albatross-root-workspace/packages/seeds", + "project": { + "name": "seeds", + "version": "1.0.0", + "requires-python": ">=3.12", + "dependencies": [ + "idna==3.6" + ], + "optional-dependencies": null + }, + "pyproject_toml": "[PYPROJECT_TOML]" + } + }, + "sources": { + "bird-feeder": [ + { + "workspace": true, + "extra": null, + "group": null + } + ] + }, + "indexes": [], + "pyproject_toml": { + "project": { + "name": "albatross", + "version": "0.1.0", + "requires-python": ">=3.12", + "dependencies": [ + "bird-feeder", + "tqdm>=4,<5" ], - "exclude": null + "optional-dependencies": null }, - "managed": null, - "package": null, - "default-groups": null, - "dev-dependencies": null, - "override-dependencies": null, - "constraint-dependencies": null, - "environments": null, - "conflicts": null + "tool": { + "uv": { + "sources": { + "bird-feeder": [ + { + "workspace": true, + "extra": null, + "group": null + } + ] + }, + "index": null, + "workspace": { + "members": [ + "packages/*" + ], + "exclude": null + }, + "managed": null, + "package": null, + "default-groups": null, + "dev-dependencies": null, + "override-dependencies": null, + "constraint-dependencies": null, + "environments": null, + "conflicts": null + } + }, + "dependency-groups": null } - }, - "dependency-groups": null + } } - } - } - "###); + "###); }); } diff --git a/crates/uv/src/commands/project/add.rs b/crates/uv/src/commands/project/add.rs index 3c9e87236..40fd20143 100644 --- a/crates/uv/src/commands/project/add.rs +++ b/crates/uv/src/commands/project/add.rs @@ -475,6 +475,8 @@ pub(crate) async fn add( tag, branch, marker, + extra, + group, }) => { let credentials = uv_auth::Credentials::from_url(&git); if let Some(credentials) = credentials { @@ -491,6 +493,8 @@ pub(crate) async fn add( tag, branch, marker, + extra, + group, }) } _ => source, diff --git a/crates/uv/src/commands/tool/install.rs b/crates/uv/src/commands/tool/install.rs index 63472986e..59bf93db8 100644 --- a/crates/uv/src/commands/tool/install.rs +++ b/crates/uv/src/commands/tool/install.rs @@ -142,6 +142,7 @@ pub(crate) async fn install( version.clone(), )), index: None, + conflict: None, }, origin: None, } @@ -159,6 +160,7 @@ pub(crate) async fn install( source: RequirementSource::Registry { specifier: VersionSpecifiers::empty(), index: None, + conflict: None, }, origin: None, } diff --git a/crates/uv/src/commands/tool/run.rs b/crates/uv/src/commands/tool/run.rs index a41c4713e..297fea8b5 100644 --- a/crates/uv/src/commands/tool/run.rs +++ b/crates/uv/src/commands/tool/run.rs @@ -478,6 +478,7 @@ async fn get_or_create_environment( source: RequirementSource::Registry { specifier: VersionSpecifiers::empty(), index: None, + conflict: None, }, origin: None, }, @@ -491,6 +492,7 @@ async fn get_or_create_environment( version.clone(), )), index: None, + conflict: None, }, origin: None, }, @@ -502,6 +504,7 @@ async fn get_or_create_environment( source: RequirementSource::Registry { specifier: VersionSpecifiers::empty(), index: None, + conflict: None, }, origin: None, }, diff --git a/crates/uv/tests/it/lock.rs b/crates/uv/tests/it/lock.rs index 8ce1f4050..e0079929f 100644 --- a/crates/uv/tests/it/lock.rs +++ b/crates/uv/tests/it/lock.rs @@ -15111,8 +15111,8 @@ fn lock_explicit_default_index() -> Result<()> { DEBUG Found static `pyproject.toml` for: project @ file://[TEMP_DIR]/ DEBUG No workspace root found, using project root DEBUG Ignoring existing lockfile due to mismatched `requires-dist` for: `project==0.1.0` - Expected: {Requirement { name: PackageName("anyio"), extras: [], marker: true, source: Registry { specifier: VersionSpecifiers([]), index: None }, origin: None }} - Actual: {Requirement { name: PackageName("iniconfig"), extras: [], marker: true, source: Registry { specifier: VersionSpecifiers([VersionSpecifier { operator: Equal, version: "2.0.0" }]), index: Some(Url { scheme: "https", cannot_be_a_base: false, username: "", password: None, host: Some(Domain("test.pypi.org")), port: None, path: "/simple", query: None, fragment: None }) }, origin: None }} + Expected: {Requirement { name: PackageName("anyio"), extras: [], marker: true, source: Registry { specifier: VersionSpecifiers([]), index: None, conflict: None }, origin: None }} + Actual: {Requirement { name: PackageName("iniconfig"), extras: [], marker: true, source: Registry { specifier: VersionSpecifiers([VersionSpecifier { operator: Equal, version: "2.0.0" }]), index: Some(Url { scheme: "https", cannot_be_a_base: false, username: "", password: None, host: Some(Domain("test.pypi.org")), port: None, path: "/simple", query: None, fragment: None }), conflict: None }, origin: None }} DEBUG Solving with installed Python version: 3.12.[X] DEBUG Solving with target Python version: >=3.12 DEBUG Adding direct dependency: project* @@ -18075,7 +18075,7 @@ fn lock_multiple_sources_no_marker() -> Result<()> { } #[test] -fn lock_multiple_sources_index() -> Result<()> { +fn lock_multiple_sources_index_disjoint_markers() -> Result<()> { let context = TestContext::new("3.12"); let pyproject_toml = context.temp_dir.child("pyproject.toml"); @@ -18789,6 +18789,867 @@ fn lock_multiple_sources_extra() -> Result<()> { Ok(()) } +#[test] +fn lock_multiple_sources_index_disjoint_extras() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [] + + [project.optional-dependencies] + cu118 = ["jinja2==3.1.2"] + cu124 = ["jinja2==3.1.3"] + + [tool.uv] + constraint-dependencies = ["markupsafe<3"] + conflicts = [ + [ + { extra = "cu118" }, + { extra = "cu124" }, + ], + ] + + [tool.uv.sources] + jinja2 = [ + { index = "torch-cu118", extra = "cu118" }, + { index = "torch-cu124", extra = "cu124" }, + ] + + [[tool.uv.index]] + name = "torch-cu118" + url = "https://download.pytorch.org/whl/cu118" + explicit = true + + [[tool.uv.index]] + name = "torch-cu124" + url = "https://download.pytorch.org/whl/cu124" + explicit = true + "#, + )?; + + uv_snapshot!(context.filters(), context.lock().env_remove(EnvVars::UV_EXCLUDE_NEWER), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 4 packages in [TIME] + "###); + + let lock = fs_err::read_to_string(context.temp_dir.join("uv.lock")).unwrap(); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r###" + version = 1 + requires-python = ">=3.12" + resolution-markers = [ + ] + conflicts = [[ + { package = "project", extra = "cu118" }, + { package = "project", extra = "cu124" }, + ]] + + [manifest] + constraints = [{ name = "markupsafe", specifier = "<3" }] + + [[package]] + name = "jinja2" + version = "3.1.2" + source = { registry = "https://download.pytorch.org/whl/cu118" } + resolution-markers = [ + ] + dependencies = [ + { name = "markupsafe" }, + ] + wheels = [ + { url = "https://download.pytorch.org/whl/Jinja2-3.1.2-py3-none-any.whl", hash = "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61" }, + ] + + [[package]] + name = "jinja2" + version = "3.1.3" + source = { registry = "https://download.pytorch.org/whl/cu124" } + resolution-markers = [ + ] + dependencies = [ + { name = "markupsafe" }, + ] + wheels = [ + { url = "https://download.pytorch.org/whl/Jinja2-3.1.3-py3-none-any.whl", hash = "sha256:7d6d50dd97d52cbc355597bd845fabfbac3f551e1f99619e39a35ce8c370b5fa" }, + ] + + [[package]] + name = "markupsafe" + version = "2.1.5" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/87/5b/aae44c6655f3801e81aa3eef09dbbf012431987ba564d7231722f68df02d/MarkupSafe-2.1.5.tar.gz", hash = "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b", size = 19384 } + wheels = [ + { url = "https://files.pythonhosted.org/packages/53/bd/583bf3e4c8d6a321938c13f49d44024dbe5ed63e0a7ba127e454a66da974/MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1", size = 18215 }, + { url = "https://files.pythonhosted.org/packages/48/d6/e7cd795fc710292c3af3a06d80868ce4b02bfbbf370b7cee11d282815a2a/MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4", size = 14069 }, + { url = "https://files.pythonhosted.org/packages/51/b5/5d8ec796e2a08fc814a2c7d2584b55f889a55cf17dd1a90f2beb70744e5c/MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee", size = 29452 }, + { url = "https://files.pythonhosted.org/packages/0a/0d/2454f072fae3b5a137c119abf15465d1771319dfe9e4acbb31722a0fff91/MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5", size = 28462 }, + { url = "https://files.pythonhosted.org/packages/2d/75/fd6cb2e68780f72d47e6671840ca517bda5ef663d30ada7616b0462ad1e3/MarkupSafe-2.1.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b", size = 27869 }, + { url = "https://files.pythonhosted.org/packages/b0/81/147c477391c2750e8fc7705829f7351cf1cd3be64406edcf900dc633feb2/MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a", size = 33906 }, + { url = "https://files.pythonhosted.org/packages/8b/ff/9a52b71839d7a256b563e85d11050e307121000dcebc97df120176b3ad93/MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f", size = 32296 }, + { url = "https://files.pythonhosted.org/packages/88/07/2dc76aa51b481eb96a4c3198894f38b480490e834479611a4053fbf08623/MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169", size = 33038 }, + { url = "https://files.pythonhosted.org/packages/96/0c/620c1fb3661858c0e37eb3cbffd8c6f732a67cd97296f725789679801b31/MarkupSafe-2.1.5-cp312-cp312-win32.whl", hash = "sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad", size = 16572 }, + { url = "https://files.pythonhosted.org/packages/3f/14/c3554d512d5f9100a95e737502f4a2323a1959f6d0d01e0d0997b35f7b10/MarkupSafe-2.1.5-cp312-cp312-win_amd64.whl", hash = "sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb", size = 17127 }, + ] + + [[package]] + name = "project" + version = "0.1.0" + source = { virtual = "." } + + [package.optional-dependencies] + cu118 = [ + { name = "jinja2", version = "3.1.2", source = { registry = "https://download.pytorch.org/whl/cu118" } }, + ] + cu124 = [ + { name = "jinja2", version = "3.1.3", source = { registry = "https://download.pytorch.org/whl/cu124" } }, + ] + + [package.metadata] + requires-dist = [ + { name = "jinja2", marker = "extra == 'cu118'", specifier = "==3.1.2", index = "https://download.pytorch.org/whl/cu118", conflict = { package = "project", extra = "cu118" } }, + { name = "jinja2", marker = "extra == 'cu124'", specifier = "==3.1.3", index = "https://download.pytorch.org/whl/cu124", conflict = { package = "project", extra = "cu124" } }, + ] + "### + ); + }); + + // Re-run with `--locked`. + uv_snapshot!(context.filters(), context.lock().arg("--locked").env_remove(EnvVars::UV_EXCLUDE_NEWER), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 4 packages in [TIME] + "###); + + Ok(()) +} + +#[test] +fn lock_multiple_sources_index_disjoint_groups() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [] + + [dependency-groups] + cu118 = ["jinja2==3.1.2"] + cu124 = ["jinja2==3.1.3"] + + [tool.uv] + constraint-dependencies = ["markupsafe<3"] + conflicts = [ + [ + { group = "cu118" }, + { group = "cu124" }, + ], + ] + + [tool.uv.sources] + jinja2 = [ + { index = "torch-cu118", group = "cu118" }, + { index = "torch-cu124", group = "cu124" }, + ] + + [[tool.uv.index]] + name = "torch-cu118" + url = "https://download.pytorch.org/whl/cu118" + explicit = true + + [[tool.uv.index]] + name = "torch-cu124" + url = "https://download.pytorch.org/whl/cu124" + explicit = true + "#, + )?; + + uv_snapshot!(context.filters(), context.lock().env_remove(EnvVars::UV_EXCLUDE_NEWER), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 4 packages in [TIME] + "###); + + let lock = fs_err::read_to_string(context.temp_dir.join("uv.lock")).unwrap(); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r###" + version = 1 + requires-python = ">=3.12" + resolution-markers = [ + ] + conflicts = [[ + { package = "project", group = "cu118" }, + { package = "project", group = "cu124" }, + ]] + + [manifest] + constraints = [{ name = "markupsafe", specifier = "<3" }] + + [[package]] + name = "jinja2" + version = "3.1.2" + source = { registry = "https://download.pytorch.org/whl/cu118" } + resolution-markers = [ + ] + dependencies = [ + { name = "markupsafe" }, + ] + wheels = [ + { url = "https://download.pytorch.org/whl/Jinja2-3.1.2-py3-none-any.whl", hash = "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61" }, + ] + + [[package]] + name = "jinja2" + version = "3.1.3" + source = { registry = "https://download.pytorch.org/whl/cu124" } + resolution-markers = [ + ] + dependencies = [ + { name = "markupsafe" }, + ] + wheels = [ + { url = "https://download.pytorch.org/whl/Jinja2-3.1.3-py3-none-any.whl", hash = "sha256:7d6d50dd97d52cbc355597bd845fabfbac3f551e1f99619e39a35ce8c370b5fa" }, + ] + + [[package]] + name = "markupsafe" + version = "2.1.5" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/87/5b/aae44c6655f3801e81aa3eef09dbbf012431987ba564d7231722f68df02d/MarkupSafe-2.1.5.tar.gz", hash = "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b", size = 19384 } + wheels = [ + { url = "https://files.pythonhosted.org/packages/53/bd/583bf3e4c8d6a321938c13f49d44024dbe5ed63e0a7ba127e454a66da974/MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1", size = 18215 }, + { url = "https://files.pythonhosted.org/packages/48/d6/e7cd795fc710292c3af3a06d80868ce4b02bfbbf370b7cee11d282815a2a/MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4", size = 14069 }, + { url = "https://files.pythonhosted.org/packages/51/b5/5d8ec796e2a08fc814a2c7d2584b55f889a55cf17dd1a90f2beb70744e5c/MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee", size = 29452 }, + { url = "https://files.pythonhosted.org/packages/0a/0d/2454f072fae3b5a137c119abf15465d1771319dfe9e4acbb31722a0fff91/MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5", size = 28462 }, + { url = "https://files.pythonhosted.org/packages/2d/75/fd6cb2e68780f72d47e6671840ca517bda5ef663d30ada7616b0462ad1e3/MarkupSafe-2.1.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b", size = 27869 }, + { url = "https://files.pythonhosted.org/packages/b0/81/147c477391c2750e8fc7705829f7351cf1cd3be64406edcf900dc633feb2/MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a", size = 33906 }, + { url = "https://files.pythonhosted.org/packages/8b/ff/9a52b71839d7a256b563e85d11050e307121000dcebc97df120176b3ad93/MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f", size = 32296 }, + { url = "https://files.pythonhosted.org/packages/88/07/2dc76aa51b481eb96a4c3198894f38b480490e834479611a4053fbf08623/MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169", size = 33038 }, + { url = "https://files.pythonhosted.org/packages/96/0c/620c1fb3661858c0e37eb3cbffd8c6f732a67cd97296f725789679801b31/MarkupSafe-2.1.5-cp312-cp312-win32.whl", hash = "sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad", size = 16572 }, + { url = "https://files.pythonhosted.org/packages/3f/14/c3554d512d5f9100a95e737502f4a2323a1959f6d0d01e0d0997b35f7b10/MarkupSafe-2.1.5-cp312-cp312-win_amd64.whl", hash = "sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb", size = 17127 }, + ] + + [[package]] + name = "project" + version = "0.1.0" + source = { virtual = "." } + + [package.dev-dependencies] + cu118 = [ + { name = "jinja2", version = "3.1.2", source = { registry = "https://download.pytorch.org/whl/cu118" } }, + ] + cu124 = [ + { name = "jinja2", version = "3.1.3", source = { registry = "https://download.pytorch.org/whl/cu124" } }, + ] + + [package.metadata] + + [package.metadata.requires-dev] + cu118 = [{ name = "jinja2", specifier = "==3.1.2", index = "https://download.pytorch.org/whl/cu118", conflict = { package = "project", group = "cu118" } }] + cu124 = [{ name = "jinja2", specifier = "==3.1.3", index = "https://download.pytorch.org/whl/cu124", conflict = { package = "project", group = "cu124" } }] + "### + ); + }); + + // Re-run with `--locked`. + uv_snapshot!(context.filters(), context.lock().arg("--locked").env_remove(EnvVars::UV_EXCLUDE_NEWER), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 4 packages in [TIME] + "###); + + Ok(()) +} + +#[test] +fn lock_multiple_sources_index_disjoint_extras_with_extra() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [] + + [project.optional-dependencies] + cu118 = ["jinja2[i18n]==3.1.2"] + cu124 = ["jinja2[i18n]==3.1.3"] + + [tool.uv] + constraint-dependencies = ["markupsafe<3"] + conflicts = [ + [ + { extra = "cu118" }, + { extra = "cu124" }, + ], + ] + + [tool.uv.sources] + jinja2 = [ + { index = "torch-cu118", extra = "cu118" }, + { index = "torch-cu124", extra = "cu124" }, + ] + + [[tool.uv.index]] + name = "torch-cu118" + url = "https://download.pytorch.org/whl/cu118" + explicit = true + + [[tool.uv.index]] + name = "torch-cu124" + url = "https://download.pytorch.org/whl/cu124" + explicit = true + "#, + )?; + + uv_snapshot!(context.filters(), context.lock().env_remove(EnvVars::UV_EXCLUDE_NEWER), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 5 packages in [TIME] + "###); + + let lock = fs_err::read_to_string(context.temp_dir.join("uv.lock")).unwrap(); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r###" + version = 1 + requires-python = ">=3.12" + resolution-markers = [ + ] + conflicts = [[ + { package = "project", extra = "cu118" }, + { package = "project", extra = "cu124" }, + ]] + + [manifest] + constraints = [{ name = "markupsafe", specifier = "<3" }] + + [[package]] + name = "babel" + version = "2.16.0" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/2a/74/f1bc80f23eeba13393b7222b11d95ca3af2c1e28edca18af487137eefed9/babel-2.16.0.tar.gz", hash = "sha256:d1f3554ca26605fe173f3de0c65f750f5a42f924499bf134de6423582298e316", size = 9348104 } + wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/20/bc79bc575ba2e2a7f70e8a1155618bb1301eaa5132a8271373a6903f73f8/babel-2.16.0-py3-none-any.whl", hash = "sha256:368b5b98b37c06b7daf6696391c3240c938b37767d4584413e8438c5c435fa8b", size = 9587599 }, + ] + + [[package]] + name = "jinja2" + version = "3.1.2" + source = { registry = "https://download.pytorch.org/whl/cu118" } + resolution-markers = [ + ] + dependencies = [ + { name = "markupsafe" }, + ] + wheels = [ + { url = "https://download.pytorch.org/whl/Jinja2-3.1.2-py3-none-any.whl", hash = "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61" }, + ] + + [package.optional-dependencies] + i18n = [ + { name = "babel" }, + ] + + [[package]] + name = "jinja2" + version = "3.1.3" + source = { registry = "https://download.pytorch.org/whl/cu124" } + resolution-markers = [ + ] + dependencies = [ + { name = "markupsafe" }, + ] + wheels = [ + { url = "https://download.pytorch.org/whl/Jinja2-3.1.3-py3-none-any.whl", hash = "sha256:7d6d50dd97d52cbc355597bd845fabfbac3f551e1f99619e39a35ce8c370b5fa" }, + ] + + [package.optional-dependencies] + i18n = [ + { name = "babel" }, + ] + + [[package]] + name = "markupsafe" + version = "2.1.5" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/87/5b/aae44c6655f3801e81aa3eef09dbbf012431987ba564d7231722f68df02d/MarkupSafe-2.1.5.tar.gz", hash = "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b", size = 19384 } + wheels = [ + { url = "https://files.pythonhosted.org/packages/53/bd/583bf3e4c8d6a321938c13f49d44024dbe5ed63e0a7ba127e454a66da974/MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1", size = 18215 }, + { url = "https://files.pythonhosted.org/packages/48/d6/e7cd795fc710292c3af3a06d80868ce4b02bfbbf370b7cee11d282815a2a/MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4", size = 14069 }, + { url = "https://files.pythonhosted.org/packages/51/b5/5d8ec796e2a08fc814a2c7d2584b55f889a55cf17dd1a90f2beb70744e5c/MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee", size = 29452 }, + { url = "https://files.pythonhosted.org/packages/0a/0d/2454f072fae3b5a137c119abf15465d1771319dfe9e4acbb31722a0fff91/MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5", size = 28462 }, + { url = "https://files.pythonhosted.org/packages/2d/75/fd6cb2e68780f72d47e6671840ca517bda5ef663d30ada7616b0462ad1e3/MarkupSafe-2.1.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b", size = 27869 }, + { url = "https://files.pythonhosted.org/packages/b0/81/147c477391c2750e8fc7705829f7351cf1cd3be64406edcf900dc633feb2/MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a", size = 33906 }, + { url = "https://files.pythonhosted.org/packages/8b/ff/9a52b71839d7a256b563e85d11050e307121000dcebc97df120176b3ad93/MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f", size = 32296 }, + { url = "https://files.pythonhosted.org/packages/88/07/2dc76aa51b481eb96a4c3198894f38b480490e834479611a4053fbf08623/MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169", size = 33038 }, + { url = "https://files.pythonhosted.org/packages/96/0c/620c1fb3661858c0e37eb3cbffd8c6f732a67cd97296f725789679801b31/MarkupSafe-2.1.5-cp312-cp312-win32.whl", hash = "sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad", size = 16572 }, + { url = "https://files.pythonhosted.org/packages/3f/14/c3554d512d5f9100a95e737502f4a2323a1959f6d0d01e0d0997b35f7b10/MarkupSafe-2.1.5-cp312-cp312-win_amd64.whl", hash = "sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb", size = 17127 }, + ] + + [[package]] + name = "project" + version = "0.1.0" + source = { virtual = "." } + + [package.optional-dependencies] + cu118 = [ + { name = "jinja2", version = "3.1.2", source = { registry = "https://download.pytorch.org/whl/cu118" }, extra = ["i18n"] }, + ] + cu124 = [ + { name = "jinja2", version = "3.1.3", source = { registry = "https://download.pytorch.org/whl/cu124" }, extra = ["i18n"] }, + ] + + [package.metadata] + requires-dist = [ + { name = "jinja2", extras = ["i18n"], marker = "extra == 'cu118'", specifier = "==3.1.2", index = "https://download.pytorch.org/whl/cu118", conflict = { package = "project", extra = "cu118" } }, + { name = "jinja2", extras = ["i18n"], marker = "extra == 'cu124'", specifier = "==3.1.3", index = "https://download.pytorch.org/whl/cu124", conflict = { package = "project", extra = "cu124" } }, + ] + "### + ); + }); + + // Re-run with `--locked`. + uv_snapshot!(context.filters(), context.lock().arg("--locked").env_remove(EnvVars::UV_EXCLUDE_NEWER), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 5 packages in [TIME] + "###); + + Ok(()) +} + +#[test] +fn lock_multiple_sources_index_overlapping_extras() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [] + + [project.optional-dependencies] + cu118 = ["jinja2==3.1.2"] + cu124 = ["jinja2==3.1.3"] + + [tool.uv] + constraint-dependencies = ["markupsafe<3"] + + [tool.uv.sources] + jinja2 = [ + { index = "torch-cu118", extra = "cu118" }, + { index = "torch-cu124", extra = "cu124" }, + ] + + [[tool.uv.index]] + name = "torch-cu118" + url = "https://download.pytorch.org/whl/cu118" + explicit = true + + [[tool.uv.index]] + name = "torch-cu124" + url = "https://download.pytorch.org/whl/cu124" + explicit = true + "#, + )?; + + uv_snapshot!(context.filters(), context.lock().env_remove(EnvVars::UV_EXCLUDE_NEWER), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Requirements contain conflicting indexes for package `jinja2` in all marker environments: + - https://download.pytorch.org/whl/cu118 + - https://download.pytorch.org/whl/cu124 + "###); + + Ok(()) +} + +#[test] +fn lock_multiple_sources_index_disjoint_extras_with_marker() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [] + + [project.optional-dependencies] + cu118 = ["jinja2==3.1.2"] + cu124 = ["jinja2==3.1.3"] + + [tool.uv] + constraint-dependencies = ["markupsafe<3"] + conflicts = [ + [ + { extra = "cu118" }, + { extra = "cu124" }, + ], + ] + + [tool.uv.sources] + jinja2 = [ + { index = "torch-cu118", extra = "cu118", marker = "sys_platform == 'darwin'" }, + { index = "torch-cu124", extra = "cu124" }, + ] + + [[tool.uv.index]] + name = "torch-cu118" + url = "https://download.pytorch.org/whl/cu118" + explicit = true + + [[tool.uv.index]] + name = "torch-cu124" + url = "https://download.pytorch.org/whl/cu124" + explicit = true + "#, + )?; + + uv_snapshot!(context.filters(), context.lock().env_remove(EnvVars::UV_EXCLUDE_NEWER), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 5 packages in [TIME] + "###); + + let lock = fs_err::read_to_string(context.temp_dir.join("uv.lock")).unwrap(); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r###" + version = 1 + requires-python = ">=3.12" + resolution-markers = [ + "sys_platform == 'darwin'", + "sys_platform != 'darwin'", + ] + conflicts = [[ + { package = "project", extra = "cu118" }, + { package = "project", extra = "cu124" }, + ]] + + [manifest] + constraints = [{ name = "markupsafe", specifier = "<3" }] + + [[package]] + name = "jinja2" + version = "3.1.2" + source = { registry = "https://download.pytorch.org/whl/cu118" } + resolution-markers = [ + "sys_platform == 'darwin'", + ] + dependencies = [ + { name = "markupsafe", marker = "sys_platform == 'darwin'" }, + ] + wheels = [ + { url = "https://download.pytorch.org/whl/Jinja2-3.1.2-py3-none-any.whl", hash = "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61" }, + ] + + [[package]] + name = "jinja2" + version = "3.1.2" + source = { registry = "https://pypi.org/simple" } + resolution-markers = [ + "sys_platform != 'darwin'", + ] + dependencies = [ + { name = "markupsafe", marker = "sys_platform != 'darwin'" }, + ] + sdist = { url = "https://files.pythonhosted.org/packages/7a/ff/75c28576a1d900e87eb6335b063fab47a8ef3c8b4d88524c4bf78f670cce/Jinja2-3.1.2.tar.gz", hash = "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852", size = 268239 } + wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/c3/f068337a370801f372f2f8f6bad74a5c140f6fda3d9de154052708dd3c65/Jinja2-3.1.2-py3-none-any.whl", hash = "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61", size = 133101 }, + ] + + [[package]] + name = "jinja2" + version = "3.1.3" + source = { registry = "https://download.pytorch.org/whl/cu124" } + resolution-markers = [ + "sys_platform == 'darwin'", + "sys_platform != 'darwin'", + ] + dependencies = [ + { name = "markupsafe" }, + ] + wheels = [ + { url = "https://download.pytorch.org/whl/Jinja2-3.1.3-py3-none-any.whl", hash = "sha256:7d6d50dd97d52cbc355597bd845fabfbac3f551e1f99619e39a35ce8c370b5fa" }, + ] + + [[package]] + name = "markupsafe" + version = "2.1.5" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/87/5b/aae44c6655f3801e81aa3eef09dbbf012431987ba564d7231722f68df02d/MarkupSafe-2.1.5.tar.gz", hash = "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b", size = 19384 } + wheels = [ + { url = "https://files.pythonhosted.org/packages/53/bd/583bf3e4c8d6a321938c13f49d44024dbe5ed63e0a7ba127e454a66da974/MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1", size = 18215 }, + { url = "https://files.pythonhosted.org/packages/48/d6/e7cd795fc710292c3af3a06d80868ce4b02bfbbf370b7cee11d282815a2a/MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4", size = 14069 }, + { url = "https://files.pythonhosted.org/packages/51/b5/5d8ec796e2a08fc814a2c7d2584b55f889a55cf17dd1a90f2beb70744e5c/MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee", size = 29452 }, + { url = "https://files.pythonhosted.org/packages/0a/0d/2454f072fae3b5a137c119abf15465d1771319dfe9e4acbb31722a0fff91/MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5", size = 28462 }, + { url = "https://files.pythonhosted.org/packages/2d/75/fd6cb2e68780f72d47e6671840ca517bda5ef663d30ada7616b0462ad1e3/MarkupSafe-2.1.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b", size = 27869 }, + { url = "https://files.pythonhosted.org/packages/b0/81/147c477391c2750e8fc7705829f7351cf1cd3be64406edcf900dc633feb2/MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a", size = 33906 }, + { url = "https://files.pythonhosted.org/packages/8b/ff/9a52b71839d7a256b563e85d11050e307121000dcebc97df120176b3ad93/MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f", size = 32296 }, + { url = "https://files.pythonhosted.org/packages/88/07/2dc76aa51b481eb96a4c3198894f38b480490e834479611a4053fbf08623/MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169", size = 33038 }, + { url = "https://files.pythonhosted.org/packages/96/0c/620c1fb3661858c0e37eb3cbffd8c6f732a67cd97296f725789679801b31/MarkupSafe-2.1.5-cp312-cp312-win32.whl", hash = "sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad", size = 16572 }, + { url = "https://files.pythonhosted.org/packages/3f/14/c3554d512d5f9100a95e737502f4a2323a1959f6d0d01e0d0997b35f7b10/MarkupSafe-2.1.5-cp312-cp312-win_amd64.whl", hash = "sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb", size = 17127 }, + ] + + [[package]] + name = "project" + version = "0.1.0" + source = { virtual = "." } + + [package.optional-dependencies] + cu118 = [ + { name = "jinja2", version = "3.1.2", source = { registry = "https://download.pytorch.org/whl/cu118" }, marker = "sys_platform == 'darwin'" }, + { name = "jinja2", version = "3.1.2", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform != 'darwin'" }, + ] + cu124 = [ + { name = "jinja2", version = "3.1.3", source = { registry = "https://download.pytorch.org/whl/cu124" } }, + ] + + [package.metadata] + requires-dist = [ + { name = "jinja2", marker = "sys_platform == 'darwin' and extra == 'cu118'", specifier = "==3.1.2", index = "https://download.pytorch.org/whl/cu118", conflict = { package = "project", extra = "cu118" } }, + { name = "jinja2", marker = "sys_platform != 'darwin' and extra == 'cu118'", specifier = "==3.1.2" }, + { name = "jinja2", marker = "extra == 'cu124'", specifier = "==3.1.3", index = "https://download.pytorch.org/whl/cu124", conflict = { package = "project", extra = "cu124" } }, + ] + "### + ); + }); + + // Re-run with `--locked`. + uv_snapshot!(context.filters(), context.lock().arg("--locked").env_remove(EnvVars::UV_EXCLUDE_NEWER), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 5 packages in [TIME] + "###); + + Ok(()) +} + +/// Sources will be ignored when an `extra` is applied, but references a non-existent extra. +#[test] +fn lock_multiple_index_with_missing_extra() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["jinja2"] + + [tool.uv.sources] + jinja2 = [ + { index = "torch-cu118", extra = "cu118" }, + ] + + [[tool.uv.index]] + name = "torch-cu118" + url = "https://download.pytorch.org/whl/cu118" + explicit = true + "#, + )?; + + uv_snapshot!(context.filters(), context.lock().env_remove(EnvVars::UV_EXCLUDE_NEWER), @r###" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + × Failed to build `project @ file://[TEMP_DIR]/` + ╰─▶ Source entry for `jinja2` only applies to extra `cu118`, but the `cu118` extra does not exist. When an extra is present on a source (e.g., `extra = "cu118"`), the relevant package must be included in the `project.optional-dependencies` section for that extra (e.g., `project.optional-dependencies = { "cu118" = ["jinja2"] }`). + "###); + + Ok(()) +} + +/// Sources will be ignored when an `extra` is applied, but the dependency isn't in an optional +/// group. +#[test] +fn lock_multiple_index_with_absent_extra() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["jinja2>=3"] + + [project.optional-dependencies] + cu118 = [] + + [tool.uv.sources] + jinja2 = [ + { index = "torch-cu118", extra = "cu118" }, + ] + + [[tool.uv.index]] + name = "torch-cu118" + url = "https://download.pytorch.org/whl/cu118" + explicit = true + "#, + )?; + + uv_snapshot!(context.filters(), context.lock().env_remove(EnvVars::UV_EXCLUDE_NEWER), @r###" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + × Failed to build `project @ file://[TEMP_DIR]/` + ╰─▶ Source entry for `jinja2` only applies to extra `cu118`, but `jinja2` was not found under the `project.optional-dependencies` section for that extra. When an extra is present on a source (e.g., `extra = "cu118"`), the relevant package must be included in the `project.optional-dependencies` section for that extra (e.g., `project.optional-dependencies = { "cu118" = ["jinja2"] }`). + "###); + + Ok(()) +} + +/// Sources will be ignored when a `group` is applied, but references a non-existent group. +#[test] +fn lock_multiple_index_with_missing_group() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["jinja2"] + + [tool.uv.sources] + jinja2 = [ + { index = "torch-cu118", group = "cu118" }, + ] + + [[tool.uv.index]] + name = "torch-cu118" + url = "https://download.pytorch.org/whl/cu118" + explicit = true + "#, + )?; + + uv_snapshot!(context.filters(), context.lock().env_remove(EnvVars::UV_EXCLUDE_NEWER), @r###" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + × Failed to build `project @ file://[TEMP_DIR]/` + ╰─▶ Source entry for `jinja2` only applies to dependency group `cu118`, but the `cu118` group does not exist. When a group is present on a source (e.g., `group = "cu118"`), the relevant package must be included in the `dependency-groups` section for that extra (e.g., `dependency-groups = { "cu118" = ["jinja2"] }`). + "###); + + Ok(()) +} + +/// Sources will be ignored when a `group` is applied, but the dependency isn't in a dependency +/// group. +#[test] +fn lock_multiple_index_with_absent_group() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["jinja2>=3"] + + [dependency-groups] + cu118 = [] + + [tool.uv.sources] + jinja2 = [ + { index = "torch-cu118", group = "cu118" }, + ] + + [[tool.uv.index]] + name = "torch-cu118" + url = "https://download.pytorch.org/whl/cu118" + explicit = true + "#, + )?; + + uv_snapshot!(context.filters(), context.lock().env_remove(EnvVars::UV_EXCLUDE_NEWER), @r###" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + × Failed to build `project @ file://[TEMP_DIR]/` + ╰─▶ Source entry for `jinja2` only applies to dependency group `cu118`, but `jinja2` was not found under the `dependency-groups` section for that group. When a group is present on a source (e.g., `group = "cu118"`), the relevant package must be included in the `dependency-groups` section for that extra (e.g., `dependency-groups = { "cu118" = ["jinja2"] }`). + "###); + + Ok(()) +} + #[test] fn lock_dry_run() -> Result<()> { let context = TestContext::new("3.12"); diff --git a/crates/uv/tests/it/sync.rs b/crates/uv/tests/it/sync.rs index 03c40d7d2..df51d0969 100644 --- a/crates/uv/tests/it/sync.rs +++ b/crates/uv/tests/it/sync.rs @@ -4277,6 +4277,73 @@ fn sync_all_groups() -> Result<()> { Ok(()) } +#[test] +fn sync_multiple_sources_index_disjoint_extras() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [] + + [project.optional-dependencies] + cu118 = ["jinja2==3.1.2"] + cu124 = ["jinja2==3.1.3"] + + [tool.uv] + constraint-dependencies = ["markupsafe<3"] + conflicts = [ + [ + { extra = "cu118" }, + { extra = "cu124" }, + ], + ] + + [tool.uv.sources] + jinja2 = [ + { index = "torch-cu118", extra = "cu118" }, + { index = "torch-cu124", extra = "cu124" }, + ] + + [[tool.uv.index]] + name = "torch-cu118" + url = "https://download.pytorch.org/whl/cu118" + explicit = true + + [[tool.uv.index]] + name = "torch-cu124" + url = "https://download.pytorch.org/whl/cu124" + explicit = true + "#, + )?; + + // Generate a lockfile. + context + .lock() + .env_remove(EnvVars::UV_EXCLUDE_NEWER) + .assert() + .success(); + + uv_snapshot!(context.filters(), context.sync().arg("--extra").arg("cu124").env_remove(EnvVars::UV_EXCLUDE_NEWER), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 4 packages in [TIME] + Prepared 2 packages in [TIME] + Installed 2 packages in [TIME] + + jinja2==3.1.3 + + markupsafe==2.1.5 + "###); + + Ok(()) +} + #[test] fn sync_derivation_chain() -> Result<()> { let context = TestContext::new("3.12"); diff --git a/docs/concepts/dependencies.md b/docs/concepts/dependencies.md index 5b3495b1b..278ad5f0d 100644 --- a/docs/concepts/dependencies.md +++ b/docs/concepts/dependencies.md @@ -335,18 +335,17 @@ dependencies = ["torch"] [tool.uv.sources] torch = [ - { index = "torch-cu118", marker = "sys_platform == 'darwin'"}, - { index = "torch-cu124", marker = "sys_platform != 'darwin'"}, + { index = "torch-cpu", marker = "platform_system == 'Darwin'"}, + { index = "torch-gpu", marker = "platform_system == 'Linux'"}, ] [[tool.uv.index]] -name = "torch-cu118" -url = "https://download.pytorch.org/whl/cu118" +name = "torch-cpu" +url = "https://download.pytorch.org/whl/cpu" [[tool.uv.index]] -name = "torch-cu124" +name = "torch-gpu" url = "https://download.pytorch.org/whl/cu124" - ``` ## Optional dependencies @@ -394,6 +393,36 @@ $ uv add httpx --optional network If you have optional dependencies that conflict with one another, resolution will fail unless you explicitly [declare them as conflicting](./projects.md#optional-dependencies). +Sources can also be declared as applying only to a specific optional dependency. For example, to +pull `torch` from different PyTorch indexes based on an optional `cpu` or `gpu` extra: + +```toml title="pyproject.toml" +[project] +dependencies = [] + +[project.optional-dependencies] +cpu = [ + "torch", +] +gpu = [ + "torch", +] + +[tool.uv.sources] +torch = [ + { index = "torch-cpu", extra = "cpu" }, + { index = "torch-gpu", extra = "gpu" }, +] + +[[tool.uv.index]] +name = "torch-cpu" +url = "https://download.pytorch.org/whl/cpu" + +[[tool.uv.index]] +name = "torch-gpu" +url = "https://download.pytorch.org/whl/cu124" +``` + ## Development dependencies Unlike optional dependencies, development dependencies are local-only and will _not_ be included in diff --git a/uv.schema.json b/uv.schema.json index a58e60345..cffbc643e 100644 --- a/uv.schema.json +++ b/uv.schema.json @@ -1409,11 +1409,31 @@ "null" ] }, + "extra": { + "anyOf": [ + { + "$ref": "#/definitions/ExtraName" + }, + { + "type": "null" + } + ] + }, "git": { "description": "The repository URL (without the `git+` prefix).", "type": "string", "format": "uri" }, + "group": { + "anyOf": [ + { + "$ref": "#/definitions/GroupName" + }, + { + "type": "null" + } + ] + }, "marker": { "$ref": "#/definitions/MarkerTree" }, @@ -1450,6 +1470,26 @@ "url" ], "properties": { + "extra": { + "anyOf": [ + { + "$ref": "#/definitions/ExtraName" + }, + { + "type": "null" + } + ] + }, + "group": { + "anyOf": [ + { + "$ref": "#/definitions/GroupName" + }, + { + "type": "null" + } + ] + }, "marker": { "$ref": "#/definitions/MarkerTree" }, @@ -1485,6 +1525,26 @@ "null" ] }, + "extra": { + "anyOf": [ + { + "$ref": "#/definitions/ExtraName" + }, + { + "type": "null" + } + ] + }, + "group": { + "anyOf": [ + { + "$ref": "#/definitions/GroupName" + }, + { + "type": "null" + } + ] + }, "marker": { "$ref": "#/definitions/MarkerTree" }, @@ -1501,6 +1561,26 @@ "index" ], "properties": { + "extra": { + "anyOf": [ + { + "$ref": "#/definitions/ExtraName" + }, + { + "type": "null" + } + ] + }, + "group": { + "anyOf": [ + { + "$ref": "#/definitions/GroupName" + }, + { + "type": "null" + } + ] + }, "index": { "$ref": "#/definitions/IndexName" }, @@ -1517,6 +1597,26 @@ "workspace" ], "properties": { + "extra": { + "anyOf": [ + { + "$ref": "#/definitions/ExtraName" + }, + { + "type": "null" + } + ] + }, + "group": { + "anyOf": [ + { + "$ref": "#/definitions/GroupName" + }, + { + "type": "null" + } + ] + }, "marker": { "$ref": "#/definitions/MarkerTree" },