Allow conflicting extras in explicit index assignments (#9160)

## Summary

This PR enables something like the "final boss" of PyTorch setups --
explicit support for CPU vs. GPU-enabled variants via extras:

```toml
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.13.0"
dependencies = []

[project.optional-dependencies]
cpu = [
    "torch==2.5.1+cpu",
]
gpu = [
    "torch==2.5.1",
]

[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"
explicit = true

[[tool.uv.index]]
name = "torch-gpu"
url = "https://download.pytorch.org/whl/cu124"
explicit = true

[tool.uv]
conflicts = [
    [
        { extra = "cpu" },
        { extra = "gpu" },
    ],
]
```

It builds atop the conflicting extras work to allow sources to be marked
as specific to a dedicated extra being enabled or disabled.

As part of this work, sources now have an `extra` field. If a source has
an `extra`, it means that the source is only applied to the requirement
when defined within that optional group. For example, `{ index =
"torch-cpu", extra = "cpu" }` above only applies to
`"torch==2.5.1+cpu"`.

The `extra` field does _not_ mean that the source is "enabled" when the
extra is activated. For example, this wouldn't work:

```toml
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.13.0"
dependencies = ["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"
explicit = true

[[tool.uv.index]]
name = "torch-gpu"
url = "https://download.pytorch.org/whl/cu124"
explicit = true
```

In this case, the sources would effectively be ignored. Extras are
really confusing... but I think this is correct? We don't want enabling
or disabling extras to affect resolution information that's _outside_ of
the relevant optional group.
This commit is contained in:
Charlie Marsh 2024-11-18 20:06:25 -05:00 committed by GitHub
parent a88a3e5eba
commit e4fc875afa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 1607 additions and 227 deletions

View file

@ -216,6 +216,7 @@ impl From<&ResolvedDist> for RequirementSource {
uv_pep440::VersionSpecifier::equals_version(version.clone()), uv_pep440::VersionSpecifier::equals_version(version.clone()),
), ),
index: Some(wheels.best_wheel().index.url().clone()), index: Some(wheels.best_wheel().index.url().clone()),
conflict: None,
}, },
Dist::Built(BuiltDist::DirectUrl(wheel)) => { Dist::Built(BuiltDist::DirectUrl(wheel)) => {
let mut location = wheel.url.to_url(); let mut location = wheel.url.to_url();
@ -237,6 +238,7 @@ impl From<&ResolvedDist> for RequirementSource {
uv_pep440::VersionSpecifier::equals_version(sdist.version.clone()), uv_pep440::VersionSpecifier::equals_version(sdist.version.clone()),
), ),
index: Some(sdist.index.url().clone()), index: Some(sdist.index.url().clone()),
conflict: None,
}, },
Dist::Source(SourceDist::DirectUrl(sdist)) => { Dist::Source(SourceDist::DirectUrl(sdist)) => {
let mut location = sdist.url.to_url(); let mut location = sdist.url.to_url();
@ -272,6 +274,7 @@ impl From<&ResolvedDist> for RequirementSource {
uv_pep440::VersionSpecifier::equals_version(dist.version().clone()), uv_pep440::VersionSpecifier::equals_version(dist.version().clone()),
), ),
index: None, index: None,
conflict: None,
}, },
} }
} }

View file

@ -10,10 +10,12 @@ use uv_configuration::LowerBound;
use uv_distribution_filename::DistExtension; use uv_distribution_filename::DistExtension;
use uv_distribution_types::{Index, IndexLocations, IndexName, Origin}; use uv_distribution_types::{Index, IndexLocations, IndexName, Origin};
use uv_git::GitReference; use uv_git::GitReference;
use uv_normalize::PackageName; use uv_normalize::{ExtraName, GroupName, PackageName};
use uv_pep440::VersionSpecifiers; use uv_pep440::VersionSpecifiers;
use uv_pep508::{MarkerTree, VerbatimUrl, VersionOrUrl}; 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_warnings::warn_user_once;
use uv_workspace::pyproject::{PyProjectToml, Source, Sources}; use uv_workspace::pyproject::{PyProjectToml, Source, Sources};
use uv_workspace::Workspace; use uv_workspace::Workspace;
@ -39,11 +41,14 @@ impl LoweredRequirement {
project_dir: &'data Path, project_dir: &'data Path,
project_sources: &'data BTreeMap<PackageName, Sources>, project_sources: &'data BTreeMap<PackageName, Sources>,
project_indexes: &'data [Index], project_indexes: &'data [Index],
extra: Option<&ExtraName>,
group: Option<&GroupName>,
locations: &'data IndexLocations, locations: &'data IndexLocations,
workspace: &'data Workspace, workspace: &'data Workspace,
lower_bound: LowerBound, lower_bound: LowerBound,
git_member: Option<&'data GitWorkspaceMember<'data>>, git_member: Option<&'data GitWorkspaceMember<'data>>,
) -> impl Iterator<Item = Result<Self, LoweringError>> + 'data { ) -> impl Iterator<Item = Result<Self, LoweringError>> + 'data {
// Identify the source from the `tool.uv.sources` table.
let (source, origin) = if let Some(source) = project_sources.get(&requirement.name) { let (source, origin) = if let Some(source) = project_sources.get(&requirement.name) {
(Some(source), RequirementOrigin::Project) (Some(source), RequirementOrigin::Project)
} else if let Some(source) = workspace.sources().get(&requirement.name) { } else if let Some(source) = workspace.sources().get(&requirement.name) {
@ -51,7 +56,29 @@ impl LoweredRequirement {
} else { } else {
(None, RequirementOrigin::Project) (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::<Sources>()
});
let workspace_package_declared = let workspace_package_declared =
// We require that when you use a package that's part of the workspace, ... // 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. // Determine the space covered by the sources.
let mut total = MarkerTree::FALSE; let mut total = MarkerTree::FALSE;
for source in source.iter() { for source in source.iter() {
total.or(source.marker()); total.or(source.marker().clone());
} }
// Determine the space covered by the requirement. // Determine the space covered by the requirement.
@ -117,6 +144,7 @@ impl LoweredRequirement {
tag, tag,
branch, branch,
marker, marker,
..
} => { } => {
if matches!(requirement.version_or_url, Some(VersionOrUrl::Url(_))) { if matches!(requirement.version_or_url, Some(VersionOrUrl::Url(_))) {
return Err(LoweringError::ConflictingUrls); return Err(LoweringError::ConflictingUrls);
@ -134,6 +162,7 @@ impl LoweredRequirement {
url, url,
subdirectory, subdirectory,
marker, marker,
..
} => { } => {
if matches!(requirement.version_or_url, Some(VersionOrUrl::Url(_))) { if matches!(requirement.version_or_url, Some(VersionOrUrl::Url(_))) {
return Err(LoweringError::ConflictingUrls); return Err(LoweringError::ConflictingUrls);
@ -145,6 +174,7 @@ impl LoweredRequirement {
path, path,
editable, editable,
marker, marker,
..
} => { } => {
if matches!(requirement.version_or_url, Some(VersionOrUrl::Url(_))) { if matches!(requirement.version_or_url, Some(VersionOrUrl::Url(_))) {
return Err(LoweringError::ConflictingUrls); return Err(LoweringError::ConflictingUrls);
@ -158,7 +188,12 @@ impl LoweredRequirement {
)?; )?;
(source, marker) (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, // Identify the named index from either the project indexes or the workspace indexes,
// in that order. // in that order.
let Some(index) = locations let Some(index) = locations
@ -176,13 +211,23 @@ impl LoweredRequirement {
index, index,
)); ));
}; };
let source = let conflict = if let Some(extra) = extra {
registry_source(&requirement, index.into_url(), lower_bound)?; 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, marker)
} }
Source::Workspace { Source::Workspace {
workspace: is_workspace, workspace: is_workspace,
marker, marker,
..
} => { } => {
if !is_workspace { if !is_workspace {
return Err(LoweringError::WorkspaceFalse); return Err(LoweringError::WorkspaceFalse);
@ -291,13 +336,27 @@ impl LoweredRequirement {
return Either::Left(std::iter::once(Ok(Self(Requirement::from(requirement))))); 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::<Sources>();
// Determine whether the markers cover the full space for the requirement. If not, fill the // Determine whether the markers cover the full space for the requirement. If not, fill the
// remaining space with the negation of the sources. // remaining space with the negation of the sources.
let remaining = { let remaining = {
// Determine the space covered by the sources. // Determine the space covered by the sources.
let mut total = MarkerTree::FALSE; let mut total = MarkerTree::FALSE;
for source in source.iter() { for source in source.iter() {
total.or(source.marker()); total.or(source.marker().clone());
} }
// Determine the space covered by the requirement. // Determine the space covered by the requirement.
@ -322,6 +381,7 @@ impl LoweredRequirement {
tag, tag,
branch, branch,
marker, marker,
..
} => { } => {
if matches!(requirement.version_or_url, Some(VersionOrUrl::Url(_))) { if matches!(requirement.version_or_url, Some(VersionOrUrl::Url(_))) {
return Err(LoweringError::ConflictingUrls); return Err(LoweringError::ConflictingUrls);
@ -339,6 +399,7 @@ impl LoweredRequirement {
url, url,
subdirectory, subdirectory,
marker, marker,
..
} => { } => {
if matches!(requirement.version_or_url, Some(VersionOrUrl::Url(_))) { if matches!(requirement.version_or_url, Some(VersionOrUrl::Url(_))) {
return Err(LoweringError::ConflictingUrls); return Err(LoweringError::ConflictingUrls);
@ -350,6 +411,7 @@ impl LoweredRequirement {
path, path,
editable, editable,
marker, marker,
..
} => { } => {
if matches!(requirement.version_or_url, Some(VersionOrUrl::Url(_))) { if matches!(requirement.version_or_url, Some(VersionOrUrl::Url(_))) {
return Err(LoweringError::ConflictingUrls); return Err(LoweringError::ConflictingUrls);
@ -363,7 +425,7 @@ impl LoweredRequirement {
)?; )?;
(source, marker) (source, marker)
} }
Source::Registry { index, marker } => { Source::Registry { index, marker, .. } => {
let Some(index) = locations let Some(index) = locations
.indexes() .indexes()
.filter(|index| matches!(index.origin, Some(Origin::Cli))) .filter(|index| matches!(index.origin, Some(Origin::Cli)))
@ -378,8 +440,13 @@ impl LoweredRequirement {
index, index,
)); ));
}; };
let source = let conflict = None;
registry_source(&requirement, index.into_url(), lower_bound)?; let source = registry_source(
&requirement,
index.into_url(),
conflict,
lower_bound,
)?;
(source, marker) (source, marker)
} }
Source::Workspace { .. } => { Source::Workspace { .. } => {
@ -512,6 +579,7 @@ fn url_source(url: Url, subdirectory: Option<PathBuf>) -> Result<RequirementSour
fn registry_source( fn registry_source(
requirement: &uv_pep508::Requirement<VerbatimParsedUrl>, requirement: &uv_pep508::Requirement<VerbatimParsedUrl>,
index: Url, index: Url,
conflict: Option<ConflictItem>,
bounds: LowerBound, bounds: LowerBound,
) -> Result<RequirementSource, LoweringError> { ) -> Result<RequirementSource, LoweringError> {
match &requirement.version_or_url { match &requirement.version_or_url {
@ -525,11 +593,13 @@ fn registry_source(
Ok(RequirementSource::Registry { Ok(RequirementSource::Registry {
specifier: VersionSpecifiers::empty(), specifier: VersionSpecifiers::empty(),
index: Some(index), index: Some(index),
conflict,
}) })
} }
Some(VersionOrUrl::VersionSpecifier(version)) => Ok(RequirementSource::Registry { Some(VersionOrUrl::VersionSpecifier(version)) => Ok(RequirementSource::Registry {
specifier: version.clone(), specifier: version.clone(),
index: Some(index), index: Some(index),
conflict,
}), }),
Some(VersionOrUrl::Url(_)) => Err(LoweringError::ConflictingUrls), Some(VersionOrUrl::Url(_)) => Err(LoweringError::ConflictingUrls),
} }

View file

@ -28,6 +28,14 @@ pub enum MetadataError {
LoweringError(PackageName, #[source] Box<LoweringError>), LoweringError(PackageName, #[source] Box<LoweringError>),
#[error("Failed to parse entry in group `{0}`: `{1}`")] #[error("Failed to parse entry in group `{0}`: `{1}`")]
GroupLoweringError(GroupName, PackageName, #[source] Box<LoweringError>), GroupLoweringError(GroupName, PackageName, #[source] Box<LoweringError>),
#[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)] #[derive(Debug, Clone)]

View file

@ -7,7 +7,7 @@ use uv_configuration::{LowerBound, SourceStrategy};
use uv_distribution_types::IndexLocations; use uv_distribution_types::IndexLocations;
use uv_normalize::{ExtraName, GroupName, PackageName, DEV_DEPENDENCIES}; use uv_normalize::{ExtraName, GroupName, PackageName, DEV_DEPENDENCIES};
use uv_workspace::dependency_groups::FlatDependencyGroups; use uv_workspace::dependency_groups::FlatDependencyGroups;
use uv_workspace::pyproject::ToolUvSources; use uv_workspace::pyproject::{Sources, ToolUvSources};
use uv_workspace::{DiscoveryOptions, ProjectWorkspace}; use uv_workspace::{DiscoveryOptions, ProjectWorkspace};
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@ -111,6 +111,7 @@ impl RequiresDist {
SourceStrategy::Disabled => &empty, SourceStrategy::Disabled => &empty,
}; };
// Collect the dependency groups.
let dependency_groups = { let dependency_groups = {
// First, collect `tool.uv.dev_dependencies` // First, collect `tool.uv.dev_dependencies`
let dev_dependencies = project_workspace let dev_dependencies = project_workspace
@ -130,85 +131,90 @@ impl RequiresDist {
.flatten() .flatten()
.collect::<BTreeMap<_, _>>(); .collect::<BTreeMap<_, _>>();
// Resolve any `include-group` entries in `dependency-groups`. // Flatten the dependency groups.
let dependency_groups = let mut dependency_groups =
FlatDependencyGroups::from_dependency_groups(&dependency_groups) FlatDependencyGroups::from_dependency_groups(&dependency_groups)
.map_err(|err| err.with_dev_dependencies(dev_dependencies))? .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::<Result<Vec<_>, _>>(),
SourceStrategy::Disabled => Ok(requirements
.into_iter()
.map(uv_pypi_types::Requirement::from)
.collect()),
}?;
Ok::<(GroupName, Vec<uv_pypi_types::Requirement>), MetadataError>((
name,
requirements,
))
})
.collect::<Result<Vec<_>, _>>()?;
// Merge any overlapping groups. // Add the `dev` group, if `dev-dependencies` is defined.
let mut map = BTreeMap::new(); if let Some(dev_dependencies) = dev_dependencies {
for (name, dependencies) in dependency_groups { dependency_groups
match map.entry(name) { .entry(DEV_DEPENDENCIES.clone())
std::collections::btree_map::Entry::Vacant(entry) => { .or_insert_with(Vec::new)
entry.insert(dependencies); .extend(dev_dependencies.clone());
}
std::collections::btree_map::Entry::Occupied(mut entry) => {
entry.get_mut().extend(dependencies);
}
}
} }
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::<Result<Vec<_>, _>>(),
SourceStrategy::Disabled => Ok(requirements
.into_iter()
.map(uv_pypi_types::Requirement::from)
.collect()),
}?;
Ok::<(GroupName, Vec<uv_pypi_types::Requirement>), MetadataError>((
name,
requirements,
))
})
.collect::<Result<BTreeMap<_, _>, _>>()?;
// Lower the requirements.
let requires_dist = metadata.requires_dist.into_iter(); let requires_dist = metadata.requires_dist.into_iter();
let requires_dist = match source_strategy { let requires_dist = match source_strategy {
SourceStrategy::Enabled => requires_dist SourceStrategy::Enabled => requires_dist
.flat_map(|requirement| { .flat_map(|requirement| {
let requirement_name = requirement.name.clone(); let requirement_name = requirement.name.clone();
let extra = requirement.marker.top_level_extra_name();
let group = None;
LoweredRequirement::from_requirement( LoweredRequirement::from_requirement(
requirement, requirement,
&metadata.name, &metadata.name,
project_workspace.project_root(), project_workspace.project_root(),
project_sources, project_sources,
project_indexes, project_indexes,
extra.as_ref(),
group,
locations, locations,
project_workspace.workspace(), project_workspace.workspace(),
lower_bound, lower_bound,
@ -236,6 +242,64 @@ impl RequiresDist {
provides_extras: metadata.provides_extras, 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<PackageName, Sources>,
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<Metadata> for RequiresDist { impl From<Metadata> for RequiresDist {
@ -383,7 +447,28 @@ mod test {
| |
8 | tqdm = { git = "https://github.com/tqdm/tqdm", ref = "baaaaaab" } 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`
"###); "###);
} }

View file

@ -426,7 +426,16 @@ pub enum MarkerValueExtra {
} }
impl 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<ExtraName> {
match self { match self {
Self::Extra(extra) => Some(extra), Self::Extra(extra) => Some(extra),
Self::Arbitrary(_) => None, Self::Arbitrary(_) => None,
@ -1113,6 +1122,19 @@ impl MarkerTree {
extra_expression 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<ExtraName> {
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 /// Simplify this marker by *assuming* that the Python version range
/// provided is true and that the complement of it is false. /// provided is true and that the complement of it is false.
/// ///

View file

@ -16,8 +16,8 @@ use uv_pep508::{
}; };
use crate::{ use crate::{
Hashes, ParsedArchiveUrl, ParsedDirectoryUrl, ParsedGitUrl, ParsedPathUrl, ParsedUrl, ConflictItem, Hashes, ParsedArchiveUrl, ParsedDirectoryUrl, ParsedGitUrl, ParsedPathUrl,
ParsedUrlError, VerbatimParsedUrl, ParsedUrl, ParsedUrlError, VerbatimParsedUrl,
}; };
#[derive(Debug, Error)] #[derive(Debug, Error)]
@ -192,11 +192,13 @@ impl From<uv_pep508::Requirement<VerbatimParsedUrl>> for Requirement {
None => RequirementSource::Registry { None => RequirementSource::Registry {
specifier: VersionSpecifiers::empty(), specifier: VersionSpecifiers::empty(),
index: None, index: None,
conflict: None,
}, },
// The most popular case: just a name, a version range and maybe extras. // The most popular case: just a name, a version range and maybe extras.
Some(VersionOrUrl::VersionSpecifier(specifier)) => RequirementSource::Registry { Some(VersionOrUrl::VersionSpecifier(specifier)) => RequirementSource::Registry {
specifier, specifier,
index: None, index: None,
conflict: None,
}, },
Some(VersionOrUrl::Url(url)) => { Some(VersionOrUrl::Url(url)) => {
RequirementSource::from_parsed_url(url.parsed_url, url.verbatim) RequirementSource::from_parsed_url(url.parsed_url, url.verbatim)
@ -229,7 +231,9 @@ impl Display for Requirement {
)?; )?;
} }
match &self.source { match &self.source {
RequirementSource::Registry { specifier, index } => { RequirementSource::Registry {
specifier, index, ..
} => {
write!(f, "{specifier}")?; write!(f, "{specifier}")?;
if let Some(index) = index { if let Some(index) = index {
write!(f, " (index: {index})")?; write!(f, " (index: {index})")?;
@ -283,6 +287,8 @@ pub enum RequirementSource {
specifier: VersionSpecifiers, specifier: VersionSpecifiers,
/// Choose a version from the index at the given URL. /// Choose a version from the index at the given URL.
index: Option<Url>, index: Option<Url>,
/// The conflict item associated with the source, if any.
conflict: Option<ConflictItem>,
}, },
// TODO(konsti): Track and verify version specifier from `project.dependencies` matches the // TODO(konsti): Track and verify version specifier from `project.dependencies` matches the
// version in remote location. // version in remote location.
@ -513,7 +519,9 @@ impl Display for RequirementSource {
/// rather than for inclusion in a `requirements.txt` file. /// rather than for inclusion in a `requirements.txt` file.
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self { match self {
Self::Registry { specifier, index } => { Self::Registry {
specifier, index, ..
} => {
write!(f, "{specifier}")?; write!(f, "{specifier}")?;
if let Some(index) = index { if let Some(index) = index {
write!(f, " (index: {index})")?; write!(f, " (index: {index})")?;
@ -571,6 +579,7 @@ enum RequirementSourceWire {
#[serde(skip_serializing_if = "VersionSpecifiers::is_empty", default)] #[serde(skip_serializing_if = "VersionSpecifiers::is_empty", default)]
specifier: VersionSpecifiers, specifier: VersionSpecifiers,
index: Option<Url>, index: Option<Url>,
conflict: Option<ConflictItem>,
}, },
} }
@ -580,11 +589,16 @@ impl From<RequirementSource> for RequirementSourceWire {
RequirementSource::Registry { RequirementSource::Registry {
specifier, specifier,
mut index, mut index,
conflict,
} => { } => {
if let Some(index) = index.as_mut() { if let Some(index) = index.as_mut() {
redact_credentials(index); redact_credentials(index);
} }
Self::Registry { specifier, index } Self::Registry {
specifier,
index,
conflict,
}
} }
RequirementSource::Url { RequirementSource::Url {
subdirectory, subdirectory,
@ -686,9 +700,15 @@ impl TryFrom<RequirementSourceWire> for RequirementSource {
fn try_from(wire: RequirementSourceWire) -> Result<RequirementSource, RequirementError> { fn try_from(wire: RequirementSourceWire) -> Result<RequirementSource, RequirementError> {
match wire { match wire {
RequirementSourceWire::Registry { specifier, index } => { RequirementSourceWire::Registry {
Ok(Self::Registry { specifier, index }) specifier,
} index,
conflict,
} => Ok(Self::Registry {
specifier,
index,
conflict,
}),
RequirementSourceWire::Git { git } => { RequirementSourceWire::Git { git } => {
let mut repository = Url::parse(&git)?; let mut repository = Url::parse(&git)?;
@ -814,6 +834,7 @@ mod tests {
source: RequirementSource::Registry { source: RequirementSource::Registry {
specifier: ">1,<2".parse().unwrap(), specifier: ">1,<2".parse().unwrap(),
index: None, index: None,
conflict: None,
}, },
origin: None, origin: None,
}; };

View file

@ -3757,6 +3757,7 @@ fn normalize_requirement(
RequirementSource::Registry { RequirementSource::Registry {
specifier, specifier,
mut index, mut index,
conflict,
} => { } => {
if let Some(index) = index.as_mut() { if let Some(index) = index.as_mut() {
redact_credentials(index); redact_credentials(index);
@ -3765,7 +3766,11 @@ fn normalize_requirement(
name: requirement.name, name: requirement.name,
extras: requirement.extras, extras: requirement.extras,
marker: requirement.marker, marker: requirement.marker,
source: RequirementSource::Registry { specifier, index }, source: RequirementSource::Registry {
specifier,
index,
conflict,
},
origin: None, origin: None,
}) })
} }

View file

@ -1,7 +1,7 @@
use uv_distribution_types::IndexUrl; use uv_distribution_types::IndexUrl;
use uv_normalize::PackageName; use uv_normalize::PackageName;
use uv_pep508::VerbatimUrl; use uv_pep508::VerbatimUrl;
use uv_pypi_types::RequirementSource; use uv_pypi_types::{ConflictItem, RequirementSource};
use crate::resolver::ForkMap; use crate::resolver::ForkMap;
use crate::{DependencyMode, Manifest, ResolverEnvironment}; 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`. /// [`Indexes`] would contain a single entry mapping `torch` to `https://download.pytorch.org/whl/cu121`.
#[derive(Debug, Default, Clone)] #[derive(Debug, Default, Clone)]
pub(crate) struct Indexes(ForkMap<IndexUrl>); pub(crate) struct Indexes(ForkMap<Entry>);
#[derive(Debug, Clone)]
struct Entry {
index: IndexUrl,
conflict: Option<ConflictItem>,
}
impl Indexes { impl Indexes {
/// Determine the set of explicit, pinned indexes in the [`Manifest`]. /// Determine the set of explicit, pinned indexes in the [`Manifest`].
@ -33,13 +39,16 @@ impl Indexes {
for requirement in manifest.requirements(env, dependencies) { for requirement in manifest.requirements(env, dependencies) {
let RequirementSource::Registry { let RequirementSource::Registry {
index: Some(index), .. index: Some(index),
conflict,
..
} = &requirement.source } = &requirement.source
else { else {
continue; continue;
}; };
let index = IndexUrl::from(VerbatimUrl::from_url(index.clone())); 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) Self(indexes)
@ -51,11 +60,17 @@ impl Indexes {
} }
/// Return the explicit index used for a package in the given fork. /// Return the explicit index used for a package in the given fork.
pub(crate) fn get( pub(crate) fn get(&self, name: &PackageName, env: &ResolverEnvironment) -> Vec<&IndexUrl> {
&self, let entries = self.0.get(name, env);
package_name: &PackageName, entries
env: &ResolverEnvironment, .iter()
) -> Vec<&IndexUrl> { .filter(|entry| {
self.0.get(package_name, env) entry
.conflict
.as_ref()
.map_or(true, |conflict| env.included_by_group(conflict.as_ref()))
})
.map(|entry| &entry.index)
.collect()
} }
} }

View file

@ -512,11 +512,6 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
continue; continue;
} }
let for_package = if let PubGrubPackageInner::Root(_) = &*state.next {
None
} else {
state.next.name().map(|name| format!("{name}=={version}"))
};
// Retrieve that package dependencies. // Retrieve that package dependencies.
let forked_deps = self.get_dependencies_forking( let forked_deps = self.get_dependencies_forking(
&state.next, &state.next,
@ -540,7 +535,6 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
} }
ForkedDependencies::Unforked(dependencies) => { ForkedDependencies::Unforked(dependencies) => {
state.add_package_version_dependencies( state.add_package_version_dependencies(
for_package.as_deref(),
&version, &version,
&self.urls, &self.urls,
&self.indexes, &self.indexes,
@ -578,7 +572,6 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
&version, &version,
forks, forks,
&request_sink, &request_sink,
for_package.as_deref(),
&diverging_packages, &diverging_packages,
) { ) {
forked_states.push(new_fork_state?); forked_states.push(new_fork_state?);
@ -673,7 +666,6 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
version: &'a Version, version: &'a Version,
forks: Vec<Fork>, forks: Vec<Fork>,
request_sink: &'a Sender<Request>, request_sink: &'a Sender<Request>,
for_package: Option<&'a str>,
diverging_packages: &'a [PackageName], diverging_packages: &'a [PackageName],
) -> impl Iterator<Item = Result<ForkState, ResolveError>> + 'a { ) -> impl Iterator<Item = Result<ForkState, ResolveError>> + 'a {
debug!( debug!(
@ -709,7 +701,6 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
}) })
.map(move |(fork, mut forked_state)| { .map(move |(fork, mut forked_state)| {
forked_state.add_package_version_dependencies( forked_state.add_package_version_dependencies(
for_package,
version, version,
&self.urls, &self.urls,
&self.indexes, &self.indexes,
@ -2238,8 +2229,7 @@ impl ForkState {
/// self-dependencies and handling URLs. /// self-dependencies and handling URLs.
fn add_package_version_dependencies( fn add_package_version_dependencies(
&mut self, &mut self,
for_package: Option<&str>, for_version: &Version,
version: &Version,
urls: &Urls, urls: &Urls,
indexes: &Indexes, indexes: &Indexes,
mut dependencies: Vec<PubGrubDependency>, mut dependencies: Vec<PubGrubDependency>,
@ -2271,8 +2261,10 @@ impl ForkState {
} }
} }
if let Some(for_package) = for_package { if let Some(name) = self.next.name_no_root() {
debug!("Adding transitive dependency for {for_package}: {package}{version}"); debug!(
"Adding transitive dependency for {name}=={for_version}: {package}{version}"
);
} else { } else {
// A dependency from the root package or requirements.txt. // A dependency from the root package or requirements.txt.
debug!("Adding direct dependency: {package}{version}"); debug!("Adding direct dependency: {package}{version}");
@ -2301,7 +2293,7 @@ impl ForkState {
self.pubgrub.add_package_version_dependencies( self.pubgrub.add_package_version_dependencies(
self.next.clone(), self.next.clone(),
version.clone(), for_version.clone(),
dependencies.into_iter().map(|dependency| { dependencies.into_iter().map(|dependency| {
let PubGrubDependency { let PubGrubDependency {
package, package,

View file

@ -1,3 +1,4 @@
use std::collections::btree_map::Entry;
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::str::FromStr; use std::str::FromStr;
@ -102,6 +103,13 @@ impl FlatDependencyGroups {
) -> Option<&Vec<uv_pep508::Requirement<VerbatimParsedUrl>>> { ) -> Option<&Vec<uv_pep508::Requirement<VerbatimParsedUrl>>> {
self.0.get(group) self.0.get(group)
} }
pub fn entry(
&mut self,
group: GroupName,
) -> Entry<GroupName, Vec<uv_pep508::Requirement<VerbatimParsedUrl>>> {
self.0.entry(group)
}
} }
impl IntoIterator for FlatDependencyGroups { impl IntoIterator for FlatDependencyGroups {

View file

@ -729,6 +729,12 @@ impl Sources {
} }
} }
impl FromIterator<Source> for Sources {
fn from_iter<T: IntoIterator<Item = Source>>(iter: T) -> Self {
Self(iter.into_iter().collect())
}
}
impl IntoIterator for Sources { impl IntoIterator for Sources {
type Item = Source; type Item = Source;
type IntoIter = std::vec::IntoIter<Source>; type IntoIter = std::vec::IntoIter<Source>;
@ -792,13 +798,17 @@ impl TryFrom<SourcesWire> for Sources {
match wire { match wire {
SourcesWire::One(source) => Ok(Self(vec![source])), SourcesWire::One(source) => Ok(Self(vec![source])),
SourcesWire::Many(sources) => { SourcesWire::Many(sources) => {
// Ensure that the markers are disjoint. for (lhs, rhs) in sources.iter().zip(sources.iter().skip(1)) {
for (lhs, rhs) in sources if lhs.extra() != rhs.extra() {
.iter() continue;
.map(Source::marker) };
.zip(sources.iter().skip(1).map(Source::marker)) if lhs.group() != rhs.group() {
{ continue;
if !lhs.is_disjoint(&rhs) { };
let lhs = lhs.marker();
let rhs = rhs.marker();
if !lhs.is_disjoint(rhs) {
let Some(left) = lhs.contents().map(|contents| contents.to_string()) else { let Some(left) = lhs.contents().map(|contents| contents.to_string()) else {
return Err(SourceError::MissingMarkers); return Err(SourceError::MissingMarkers);
}; };
@ -856,6 +866,8 @@ pub enum Source {
default default
)] )]
marker: MarkerTree, marker: MarkerTree,
extra: Option<ExtraName>,
group: Option<GroupName>,
}, },
/// A remote `http://` or `https://` URL, either a wheel (`.whl`) or a source distribution /// A remote `http://` or `https://` URL, either a wheel (`.whl`) or a source distribution
/// (`.zip`, `.tar.gz`). /// (`.zip`, `.tar.gz`).
@ -875,6 +887,8 @@ pub enum Source {
default default
)] )]
marker: MarkerTree, marker: MarkerTree,
extra: Option<ExtraName>,
group: Option<GroupName>,
}, },
/// The path to a dependency, either a wheel (a `.whl` file), source distribution (a `.zip` or /// 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 /// `.tar.gz` file), or source tree (i.e., a directory containing a `pyproject.toml` or
@ -889,6 +903,8 @@ pub enum Source {
default default
)] )]
marker: MarkerTree, marker: MarkerTree,
extra: Option<ExtraName>,
group: Option<GroupName>,
}, },
/// A dependency pinned to a specific index, e.g., `torch` after setting `torch` to `https://download.pytorch.org/whl/cu118`. /// A dependency pinned to a specific index, e.g., `torch` after setting `torch` to `https://download.pytorch.org/whl/cu118`.
Registry { Registry {
@ -899,6 +915,8 @@ pub enum Source {
default default
)] )]
marker: MarkerTree, marker: MarkerTree,
extra: Option<ExtraName>,
group: Option<GroupName>,
}, },
/// A dependency on another package in the workspace. /// A dependency on another package in the workspace.
Workspace { Workspace {
@ -911,6 +929,8 @@ pub enum Source {
default default
)] )]
marker: MarkerTree, marker: MarkerTree,
extra: Option<ExtraName>,
group: Option<GroupName>,
}, },
} }
@ -940,6 +960,8 @@ impl<'de> Deserialize<'de> for Source {
default default
)] )]
marker: MarkerTree, marker: MarkerTree,
extra: Option<ExtraName>,
group: Option<GroupName>,
} }
// Attempt to deserialize as `CatchAll`. // Attempt to deserialize as `CatchAll`.
@ -955,8 +977,17 @@ impl<'de> Deserialize<'de> for Source {
index, index,
workspace, workspace,
marker, marker,
extra,
group,
} = CatchAll::deserialize(deserializer)?; } = 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 the `git` field is set, we're dealing with a Git source.
if let Some(git) = git { if let Some(git) = git {
if index.is_some() { if index.is_some() {
@ -1012,6 +1043,8 @@ impl<'de> Deserialize<'de> for Source {
tag, tag,
branch, branch,
marker, marker,
extra,
group,
}); });
} }
@ -1062,6 +1095,8 @@ impl<'de> Deserialize<'de> for Source {
url, url,
subdirectory, subdirectory,
marker, marker,
extra,
group,
}); });
} }
@ -1107,6 +1142,8 @@ impl<'de> Deserialize<'de> for Source {
path, path,
editable, editable,
marker, 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. // 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. // If none of the fields are set, we're dealing with an error.
@ -1269,6 +1316,8 @@ impl Source {
Ok(Some(Source::Workspace { Ok(Some(Source::Workspace {
workspace: true, workspace: true,
marker: MarkerTree::TRUE, marker: MarkerTree::TRUE,
extra: None,
group: None,
})) }))
} }
RequirementSource::Url { .. } => { RequirementSource::Url { .. } => {
@ -1292,6 +1341,8 @@ impl Source {
Source::Registry { Source::Registry {
index, index,
marker: MarkerTree::TRUE, marker: MarkerTree::TRUE,
extra: None,
group: None,
} }
} else { } else {
return Ok(None); return Ok(None);
@ -1306,6 +1357,8 @@ impl Source {
.map_err(SourceError::Absolute)?, .map_err(SourceError::Absolute)?,
), ),
marker: MarkerTree::TRUE, marker: MarkerTree::TRUE,
extra: None,
group: None,
}, },
RequirementSource::Url { RequirementSource::Url {
subdirectory, url, .. subdirectory, url, ..
@ -1313,6 +1366,8 @@ impl Source {
url: url.to_url(), url: url.to_url(),
subdirectory: subdirectory.map(PortablePathBuf::from), subdirectory: subdirectory.map(PortablePathBuf::from),
marker: MarkerTree::TRUE, marker: MarkerTree::TRUE,
extra: None,
group: None,
}, },
RequirementSource::Git { RequirementSource::Git {
repository, repository,
@ -1338,6 +1393,8 @@ impl Source {
git: repository, git: repository,
subdirectory: subdirectory.map(PortablePathBuf::from), subdirectory: subdirectory.map(PortablePathBuf::from),
marker: MarkerTree::TRUE, marker: MarkerTree::TRUE,
extra: None,
group: None,
} }
} else { } else {
Source::Git { Source::Git {
@ -1347,6 +1404,8 @@ impl Source {
git: repository, git: repository,
subdirectory: subdirectory.map(PortablePathBuf::from), subdirectory: subdirectory.map(PortablePathBuf::from),
marker: MarkerTree::TRUE, marker: MarkerTree::TRUE,
extra: None,
group: None,
} }
} }
} }
@ -1356,13 +1415,35 @@ impl Source {
} }
/// Return the [`MarkerTree`] for the source. /// Return the [`MarkerTree`] for the source.
pub fn marker(&self) -> MarkerTree { pub fn marker(&self) -> &MarkerTree {
match self { match self {
Source::Git { marker, .. } => marker.clone(), Source::Git { marker, .. } => marker,
Source::Url { marker, .. } => marker.clone(), Source::Url { marker, .. } => marker,
Source::Path { marker, .. } => marker.clone(), Source::Path { marker, .. } => marker,
Source::Registry { marker, .. } => marker.clone(), Source::Registry { marker, .. } => marker,
Source::Workspace { marker, .. } => marker.clone(), 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(),
} }
} }
} }

View file

@ -1607,104 +1607,108 @@ mod tests {
".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]" ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
}, },
@r###" @r###"
{ {
"project_root": "[ROOT]/albatross-root-workspace", "project_root": "[ROOT]/albatross-root-workspace",
"project_name": "albatross", "project_name": "albatross",
"workspace": { "workspace": {
"install_path": "[ROOT]/albatross-root-workspace", "install_path": "[ROOT]/albatross-root-workspace",
"packages": { "packages": {
"albatross": { "albatross": {
"root": "[ROOT]/albatross-root-workspace", "root": "[ROOT]/albatross-root-workspace",
"project": { "project": {
"name": "albatross", "name": "albatross",
"version": "0.1.0", "version": "0.1.0",
"requires-python": ">=3.12", "requires-python": ">=3.12",
"dependencies": [ "dependencies": [
"bird-feeder", "bird-feeder",
"tqdm>=4,<5" "tqdm>=4,<5"
], ],
"optional-dependencies": null "optional-dependencies": null
}, },
"pyproject_toml": "[PYPROJECT_TOML]" "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
}
]
}, },
"index": null, "bird-feeder": {
"workspace": { "root": "[ROOT]/albatross-root-workspace/packages/bird-feeder",
"members": [ "project": {
"packages/*" "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, "tool": {
"package": null, "uv": {
"default-groups": null, "sources": {
"dev-dependencies": null, "bird-feeder": [
"override-dependencies": null, {
"constraint-dependencies": null, "workspace": true,
"environments": null, "extra": null,
"conflicts": 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
} }
} "###);
}
"###);
}); });
} }

View file

@ -475,6 +475,8 @@ pub(crate) async fn add(
tag, tag,
branch, branch,
marker, marker,
extra,
group,
}) => { }) => {
let credentials = uv_auth::Credentials::from_url(&git); let credentials = uv_auth::Credentials::from_url(&git);
if let Some(credentials) = credentials { if let Some(credentials) = credentials {
@ -491,6 +493,8 @@ pub(crate) async fn add(
tag, tag,
branch, branch,
marker, marker,
extra,
group,
}) })
} }
_ => source, _ => source,

View file

@ -142,6 +142,7 @@ pub(crate) async fn install(
version.clone(), version.clone(),
)), )),
index: None, index: None,
conflict: None,
}, },
origin: None, origin: None,
} }
@ -159,6 +160,7 @@ pub(crate) async fn install(
source: RequirementSource::Registry { source: RequirementSource::Registry {
specifier: VersionSpecifiers::empty(), specifier: VersionSpecifiers::empty(),
index: None, index: None,
conflict: None,
}, },
origin: None, origin: None,
} }

View file

@ -478,6 +478,7 @@ async fn get_or_create_environment(
source: RequirementSource::Registry { source: RequirementSource::Registry {
specifier: VersionSpecifiers::empty(), specifier: VersionSpecifiers::empty(),
index: None, index: None,
conflict: None,
}, },
origin: None, origin: None,
}, },
@ -491,6 +492,7 @@ async fn get_or_create_environment(
version.clone(), version.clone(),
)), )),
index: None, index: None,
conflict: None,
}, },
origin: None, origin: None,
}, },
@ -502,6 +504,7 @@ async fn get_or_create_environment(
source: RequirementSource::Registry { source: RequirementSource::Registry {
specifier: VersionSpecifiers::empty(), specifier: VersionSpecifiers::empty(),
index: None, index: None,
conflict: None,
}, },
origin: None, origin: None,
}, },

View file

@ -15111,8 +15111,8 @@ fn lock_explicit_default_index() -> Result<()> {
DEBUG Found static `pyproject.toml` for: project @ file://[TEMP_DIR]/ DEBUG Found static `pyproject.toml` for: project @ file://[TEMP_DIR]/
DEBUG No workspace root found, using project root DEBUG No workspace root found, using project root
DEBUG Ignoring existing lockfile due to mismatched `requires-dist` for: `project==0.1.0` 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 }} 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 }) }, 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 installed Python version: 3.12.[X]
DEBUG Solving with target Python version: >=3.12 DEBUG Solving with target Python version: >=3.12
DEBUG Adding direct dependency: project* DEBUG Adding direct dependency: project*
@ -18075,7 +18075,7 @@ fn lock_multiple_sources_no_marker() -> Result<()> {
} }
#[test] #[test]
fn lock_multiple_sources_index() -> Result<()> { fn lock_multiple_sources_index_disjoint_markers() -> Result<()> {
let context = TestContext::new("3.12"); let context = TestContext::new("3.12");
let pyproject_toml = context.temp_dir.child("pyproject.toml"); let pyproject_toml = context.temp_dir.child("pyproject.toml");
@ -18789,6 +18789,867 @@ fn lock_multiple_sources_extra() -> Result<()> {
Ok(()) 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] #[test]
fn lock_dry_run() -> Result<()> { fn lock_dry_run() -> Result<()> {
let context = TestContext::new("3.12"); let context = TestContext::new("3.12");

View file

@ -4277,6 +4277,73 @@ fn sync_all_groups() -> Result<()> {
Ok(()) 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] #[test]
fn sync_derivation_chain() -> Result<()> { fn sync_derivation_chain() -> Result<()> {
let context = TestContext::new("3.12"); let context = TestContext::new("3.12");

View file

@ -335,18 +335,17 @@ dependencies = ["torch"]
[tool.uv.sources] [tool.uv.sources]
torch = [ torch = [
{ index = "torch-cu118", marker = "sys_platform == 'darwin'"}, { index = "torch-cpu", marker = "platform_system == 'Darwin'"},
{ index = "torch-cu124", marker = "sys_platform != 'darwin'"}, { index = "torch-gpu", marker = "platform_system == 'Linux'"},
] ]
[[tool.uv.index]] [[tool.uv.index]]
name = "torch-cu118" name = "torch-cpu"
url = "https://download.pytorch.org/whl/cu118" url = "https://download.pytorch.org/whl/cpu"
[[tool.uv.index]] [[tool.uv.index]]
name = "torch-cu124" name = "torch-gpu"
url = "https://download.pytorch.org/whl/cu124" url = "https://download.pytorch.org/whl/cu124"
``` ```
## Optional dependencies ## Optional dependencies
@ -394,6 +393,36 @@ $ uv add httpx --optional network
If you have optional dependencies that conflict with one another, resolution will fail If you have optional dependencies that conflict with one another, resolution will fail
unless you explicitly [declare them as conflicting](./projects.md#optional-dependencies). 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 ## Development dependencies
Unlike optional dependencies, development dependencies are local-only and will _not_ be included in Unlike optional dependencies, development dependencies are local-only and will _not_ be included in

100
uv.schema.json generated
View file

@ -1409,11 +1409,31 @@
"null" "null"
] ]
}, },
"extra": {
"anyOf": [
{
"$ref": "#/definitions/ExtraName"
},
{
"type": "null"
}
]
},
"git": { "git": {
"description": "The repository URL (without the `git+` prefix).", "description": "The repository URL (without the `git+` prefix).",
"type": "string", "type": "string",
"format": "uri" "format": "uri"
}, },
"group": {
"anyOf": [
{
"$ref": "#/definitions/GroupName"
},
{
"type": "null"
}
]
},
"marker": { "marker": {
"$ref": "#/definitions/MarkerTree" "$ref": "#/definitions/MarkerTree"
}, },
@ -1450,6 +1470,26 @@
"url" "url"
], ],
"properties": { "properties": {
"extra": {
"anyOf": [
{
"$ref": "#/definitions/ExtraName"
},
{
"type": "null"
}
]
},
"group": {
"anyOf": [
{
"$ref": "#/definitions/GroupName"
},
{
"type": "null"
}
]
},
"marker": { "marker": {
"$ref": "#/definitions/MarkerTree" "$ref": "#/definitions/MarkerTree"
}, },
@ -1485,6 +1525,26 @@
"null" "null"
] ]
}, },
"extra": {
"anyOf": [
{
"$ref": "#/definitions/ExtraName"
},
{
"type": "null"
}
]
},
"group": {
"anyOf": [
{
"$ref": "#/definitions/GroupName"
},
{
"type": "null"
}
]
},
"marker": { "marker": {
"$ref": "#/definitions/MarkerTree" "$ref": "#/definitions/MarkerTree"
}, },
@ -1501,6 +1561,26 @@
"index" "index"
], ],
"properties": { "properties": {
"extra": {
"anyOf": [
{
"$ref": "#/definitions/ExtraName"
},
{
"type": "null"
}
]
},
"group": {
"anyOf": [
{
"$ref": "#/definitions/GroupName"
},
{
"type": "null"
}
]
},
"index": { "index": {
"$ref": "#/definitions/IndexName" "$ref": "#/definitions/IndexName"
}, },
@ -1517,6 +1597,26 @@
"workspace" "workspace"
], ],
"properties": { "properties": {
"extra": {
"anyOf": [
{
"$ref": "#/definitions/ExtraName"
},
{
"type": "null"
}
]
},
"group": {
"anyOf": [
{
"$ref": "#/definitions/GroupName"
},
{
"type": "null"
}
]
},
"marker": { "marker": {
"$ref": "#/definitions/MarkerTree" "$ref": "#/definitions/MarkerTree"
}, },