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()),
),
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,
},
}
}

View file

@ -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<PackageName, Sources>,
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<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) {
(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::<Sources>()
});
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::<Sources>();
// 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<PathBuf>) -> Result<RequirementSour
fn registry_source(
requirement: &uv_pep508::Requirement<VerbatimParsedUrl>,
index: Url,
conflict: Option<ConflictItem>,
bounds: LowerBound,
) -> Result<RequirementSource, LoweringError> {
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),
}

View file

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

View file

@ -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::<BTreeMap<_, _>>();
// 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::<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<_>, _>>()?;
.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::<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 = 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<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 {
@ -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`
"###);
}

View file

@ -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<ExtraName> {
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<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
/// provided is true and that the complement of it is false.
///

View file

@ -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<uv_pep508::Requirement<VerbatimParsedUrl>> 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<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
// 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<Url>,
conflict: Option<ConflictItem>,
},
}
@ -580,11 +589,16 @@ impl From<RequirementSource> 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<RequirementSourceWire> for RequirementSource {
fn try_from(wire: RequirementSourceWire) -> Result<RequirementSource, RequirementError> {
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,
};

View file

@ -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,
})
}

View file

@ -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<IndexUrl>);
pub(crate) struct Indexes(ForkMap<Entry>);
#[derive(Debug, Clone)]
struct Entry {
index: IndexUrl,
conflict: Option<ConflictItem>,
}
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()
}
}

View file

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

View file

@ -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<uv_pep508::Requirement<VerbatimParsedUrl>>> {
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 {

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 {
type Item = Source;
type IntoIter = std::vec::IntoIter<Source>;
@ -792,13 +798,17 @@ impl TryFrom<SourcesWire> 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<ExtraName>,
group: Option<GroupName>,
},
/// 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<ExtraName>,
group: Option<GroupName>,
},
/// 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<ExtraName>,
group: Option<GroupName>,
},
/// 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<ExtraName>,
group: Option<GroupName>,
},
/// A dependency on another package in the workspace.
Workspace {
@ -911,6 +929,8 @@ pub enum Source {
default
)]
marker: MarkerTree,
extra: Option<ExtraName>,
group: Option<GroupName>,
},
}
@ -940,6 +960,8 @@ impl<'de> Deserialize<'de> for Source {
default
)]
marker: MarkerTree,
extra: Option<ExtraName>,
group: Option<GroupName>,
}
// 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(),
}
}
}

View file

@ -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
}
}
}
}
"###);
"###);
});
}

View file

@ -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,

View file

@ -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,
}

View file

@ -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,
},

View file

@ -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");

View file

@ -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");

View file

@ -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

100
uv.schema.json generated
View file

@ -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"
},