Implement PEP 440-compliant local version semantics (#8797)

Implement a full working version of local version semantics. The (AFAIA)
major move towards this was implemented in #2430. This added support
such that the version specifier `torch==2.1.0+cpu` would install
`torch@2.1.0+cpu` and consider `torch@2.1.0+cpu` a valid way to satisfy
the requirement `torch==2.1.0` in further dependency resolution.

In this feature, we more fully support local version semantics. Namely,
we now allow `torch==2.1.0` to install `torch@2.1.0+cpu` regardless of
whether `torch@2.1.0` (no local tag) actually exists.

We do this by adding an internal-only `Max` value to local versions that
compare greater to all other local versions. Then we can translate
`torch==2.1.0` into bounds: greater than 2.1.0 with no local tag and
less than 2.1.0 with the `Max` local tag.

Depends on https://github.com/astral-sh/packse/pull/227.
This commit is contained in:
Eric Mark Martin 2024-11-05 22:18:43 -05:00 committed by Zanie Blue
parent 8ef5949294
commit c49c7bdf97
15 changed files with 631 additions and 840 deletions

View file

@ -27,8 +27,9 @@
pub use version_ranges::{release_specifier_to_range, release_specifiers_to_ranges};
pub use {
version::{
LocalSegment, Operator, OperatorParseError, Prerelease, PrereleaseKind, Version,
VersionParseError, VersionPattern, VersionPatternParseError, MIN_VERSION,
LocalSegment, LocalVersion, LocalVersionSlice, Operator, OperatorParseError, Prerelease,
PrereleaseKind, Version, VersionParseError, VersionPattern, VersionPatternParseError,
MIN_VERSION,
},
version_specifier::{
VersionSpecifier, VersionSpecifierBuildError, VersionSpecifiers,

View file

@ -388,10 +388,10 @@ impl Version {
/// Returns the local segments in this version, if any exist.
#[inline]
pub fn local(&self) -> &[LocalSegment] {
pub fn local(&self) -> LocalVersionSlice {
match *self.inner {
VersionInner::Small { ref small } => small.local(),
VersionInner::Full { ref full } => &full.local,
VersionInner::Full { ref full } => full.local.as_slice(),
}
}
@ -530,15 +530,28 @@ impl Version {
/// Set the local segments and return the updated version.
#[inline]
#[must_use]
pub fn with_local(mut self, value: Vec<LocalSegment>) -> Self {
pub fn with_local_segments(mut self, value: Vec<LocalSegment>) -> Self {
if value.is_empty() {
self.without_local()
} else {
self.make_full().local = value;
self.make_full().local = LocalVersion::Segments(value);
self
}
}
/// Set the local version and return the updated version.
#[inline]
#[must_use]
pub fn with_local(mut self, value: LocalVersion) -> Self {
match value {
LocalVersion::Segments(segments) => self.with_local_segments(segments),
LocalVersion::Max => {
self.make_full().local = value;
self
}
}
}
/// For PEP 440 specifier matching: "Except where specifically noted below,
/// local version identifiers MUST NOT be permitted in version specifiers,
/// and local version labels MUST be ignored entirely when checking if
@ -615,7 +628,7 @@ impl Version {
pre: small.pre(),
post: small.post(),
dev: small.dev(),
local: vec![],
local: LocalVersion::Segments(vec![]),
};
*self = Self {
inner: Arc::new(VersionInner::Full { full }),
@ -712,14 +725,12 @@ impl std::fmt::Display for Version {
let local = if self.local().is_empty() {
String::new()
} else {
format!(
"+{}",
self.local()
.iter()
.map(ToString::to_string)
.collect::<Vec<String>>()
.join(".")
)
match self.local() {
LocalVersionSlice::Segments(_) => {
format!("+{}", self.local())
}
LocalVersionSlice::Max => String::new(),
}
};
write!(f, "{epoch}{release}{pre}{post}{dev}{local}")
}
@ -1195,10 +1206,10 @@ impl VersionSmall {
#[inline]
#[allow(clippy::unused_self)]
fn local(&self) -> &[LocalSegment] {
fn local(&self) -> LocalVersionSlice {
// A "small" version is never used if the version has a non-zero number
// of local segments.
&[]
LocalVersionSlice::Segments(&[])
}
#[inline]
@ -1283,7 +1294,7 @@ struct VersionFull {
///
/// Local versions allow multiple segments separated by periods, such as `deadbeef.1.2.3`, see
/// [`LocalSegment`] for details on the semantics.
local: Vec<LocalSegment>,
local: LocalVersion,
/// An internal-only segment that does not exist in PEP 440, used to
/// represent the smallest possible version of a release, preceding any
/// `dev`, `pre`, `post` or releases.
@ -1414,6 +1425,93 @@ impl std::fmt::Display for Prerelease {
}
}
/// Either a sequence of local segments or [`LocalVersion::Sentinel`], an internal-only value that
/// compares greater than all other local versions.
#[derive(Eq, PartialEq, Debug, Clone, Hash)]
#[cfg_attr(
feature = "rkyv",
derive(rkyv::Archive, rkyv::Deserialize, rkyv::Serialize)
)]
#[cfg_attr(feature = "rkyv", rkyv(derive(Debug, Eq, PartialEq, PartialOrd, Ord)))]
pub enum LocalVersion {
/// A sequence of local segments.
Segments(Vec<LocalSegment>),
/// An internal-only value that compares greater to all other local versions.
Max,
}
/// Like [`LocalVersion`], but using a slice
#[derive(Eq, PartialEq, Debug, Clone, Hash)]
pub enum LocalVersionSlice<'a> {
/// Like [`LocalVersion::Segments`]
Segments(&'a [LocalSegment]),
/// Like [`LocalVersion::Sentinel`]
Max,
}
impl LocalVersion {
/// Convert the local version segments into a slice.
pub fn as_slice(&self) -> LocalVersionSlice<'_> {
match self {
LocalVersion::Segments(segments) => LocalVersionSlice::Segments(segments),
LocalVersion::Max => LocalVersionSlice::Max,
}
}
/// Clear the local version segments, if they exist.
pub fn clear(&mut self) {
match self {
Self::Segments(segments) => segments.clear(),
Self::Max => *self = Self::Segments(Vec::new()),
}
}
}
/// Output the local version identifier string.
///
/// [`LocalVersionSlice::Max`] maps to `"[max]"` which is otherwise an illegal local
/// version because `[` and `]` are not allowed.
impl std::fmt::Display for LocalVersionSlice<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
LocalVersionSlice::Segments(segments) => {
for (i, segment) in segments.iter().enumerate() {
if i > 0 {
write!(f, ".")?;
}
write!(f, "{segment}")?;
}
Ok(())
}
LocalVersionSlice::Max => write!(f, "[max]"),
}
}
}
impl PartialOrd for LocalVersionSlice<'_> {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for LocalVersionSlice<'_> {
fn cmp(&self, other: &Self) -> Ordering {
match (self, other) {
(LocalVersionSlice::Segments(lv1), LocalVersionSlice::Segments(lv2)) => lv1.cmp(lv2),
(LocalVersionSlice::Segments(_), LocalVersionSlice::Max) => Ordering::Less,
(LocalVersionSlice::Max, LocalVersionSlice::Segments(_)) => Ordering::Greater,
(LocalVersionSlice::Max, LocalVersionSlice::Max) => Ordering::Equal,
}
}
}
impl LocalVersionSlice<'_> {
/// Whether the local version is absent
pub fn is_empty(&self) -> bool {
matches!(self, Self::Segments(&[]))
}
}
/// A part of the [local version identifier](<https://peps.python.org/pep-0440/#local-version-identifiers>)
///
/// Local versions are a mess:
@ -1855,7 +1953,7 @@ impl<'a> Parser<'a> {
.with_pre(self.pre)
.with_post(self.post)
.with_dev(self.dev)
.with_local(self.local);
.with_local(LocalVersion::Segments(self.local));
VersionPattern {
version,
wildcard: self.wildcard,
@ -2326,7 +2424,7 @@ pub(crate) fn compare_release(this: &[u64], other: &[u64]) -> Ordering {
/// implementation
///
/// [pep440-suffix-ordering]: https://peps.python.org/pep-0440/#summary-of-permitted-suffixes-and-relative-ordering
fn sortable_tuple(version: &Version) -> (u64, u64, Option<u64>, u64, &[LocalSegment]) {
fn sortable_tuple(version: &Version) -> (u64, u64, Option<u64>, u64, LocalVersionSlice) {
// If the version is a "max" version, use a post version larger than any possible post version.
let post = if version.max().is_some() {
Some(u64::MAX)

View file

@ -125,46 +125,50 @@ fn test_packaging_versions() {
("1.1.dev1", Version::new([1, 1]).with_dev(Some(1))),
(
"1.2+123abc",
Version::new([1, 2]).with_local(vec![LocalSegment::String("123abc".to_string())]),
Version::new([1, 2])
.with_local_segments(vec![LocalSegment::String("123abc".to_string())]),
),
(
"1.2+123abc456",
Version::new([1, 2]).with_local(vec![LocalSegment::String("123abc456".to_string())]),
Version::new([1, 2])
.with_local_segments(vec![LocalSegment::String("123abc456".to_string())]),
),
(
"1.2+abc",
Version::new([1, 2]).with_local(vec![LocalSegment::String("abc".to_string())]),
Version::new([1, 2]).with_local_segments(vec![LocalSegment::String("abc".to_string())]),
),
(
"1.2+abc123",
Version::new([1, 2]).with_local(vec![LocalSegment::String("abc123".to_string())]),
Version::new([1, 2])
.with_local_segments(vec![LocalSegment::String("abc123".to_string())]),
),
(
"1.2+abc123def",
Version::new([1, 2]).with_local(vec![LocalSegment::String("abc123def".to_string())]),
Version::new([1, 2])
.with_local_segments(vec![LocalSegment::String("abc123def".to_string())]),
),
(
"1.2+1234.abc",
Version::new([1, 2]).with_local(vec![
Version::new([1, 2]).with_local_segments(vec![
LocalSegment::Number(1234),
LocalSegment::String("abc".to_string()),
]),
),
(
"1.2+123456",
Version::new([1, 2]).with_local(vec![LocalSegment::Number(123_456)]),
Version::new([1, 2]).with_local_segments(vec![LocalSegment::Number(123_456)]),
),
(
"1.2.r32+123456",
Version::new([1, 2])
.with_post(Some(32))
.with_local(vec![LocalSegment::Number(123_456)]),
.with_local_segments(vec![LocalSegment::Number(123_456)]),
),
(
"1.2.rev33+123456",
Version::new([1, 2])
.with_post(Some(33))
.with_local(vec![LocalSegment::Number(123_456)]),
.with_local_segments(vec![LocalSegment::Number(123_456)]),
),
// Explicit epoch of 1
(
@ -316,35 +320,35 @@ fn test_packaging_versions() {
"1!1.2+123abc",
Version::new([1, 2])
.with_epoch(1)
.with_local(vec![LocalSegment::String("123abc".to_string())]),
.with_local_segments(vec![LocalSegment::String("123abc".to_string())]),
),
(
"1!1.2+123abc456",
Version::new([1, 2])
.with_epoch(1)
.with_local(vec![LocalSegment::String("123abc456".to_string())]),
.with_local_segments(vec![LocalSegment::String("123abc456".to_string())]),
),
(
"1!1.2+abc",
Version::new([1, 2])
.with_epoch(1)
.with_local(vec![LocalSegment::String("abc".to_string())]),
.with_local_segments(vec![LocalSegment::String("abc".to_string())]),
),
(
"1!1.2+abc123",
Version::new([1, 2])
.with_epoch(1)
.with_local(vec![LocalSegment::String("abc123".to_string())]),
.with_local_segments(vec![LocalSegment::String("abc123".to_string())]),
),
(
"1!1.2+abc123def",
Version::new([1, 2])
.with_epoch(1)
.with_local(vec![LocalSegment::String("abc123def".to_string())]),
.with_local_segments(vec![LocalSegment::String("abc123def".to_string())]),
),
(
"1!1.2+1234.abc",
Version::new([1, 2]).with_epoch(1).with_local(vec![
Version::new([1, 2]).with_epoch(1).with_local_segments(vec![
LocalSegment::Number(1234),
LocalSegment::String("abc".to_string()),
]),
@ -353,28 +357,28 @@ fn test_packaging_versions() {
"1!1.2+123456",
Version::new([1, 2])
.with_epoch(1)
.with_local(vec![LocalSegment::Number(123_456)]),
.with_local_segments(vec![LocalSegment::Number(123_456)]),
),
(
"1!1.2.r32+123456",
Version::new([1, 2])
.with_epoch(1)
.with_post(Some(32))
.with_local(vec![LocalSegment::Number(123_456)]),
.with_local_segments(vec![LocalSegment::Number(123_456)]),
),
(
"1!1.2.rev33+123456",
Version::new([1, 2])
.with_epoch(1)
.with_post(Some(33))
.with_local(vec![LocalSegment::Number(123_456)]),
.with_local_segments(vec![LocalSegment::Number(123_456)]),
),
(
"98765!1.2.rev33+123456",
Version::new([1, 2])
.with_epoch(98765)
.with_post(Some(33))
.with_local(vec![LocalSegment::Number(123_456)]),
.with_local_segments(vec![LocalSegment::Number(123_456)]),
),
];
for (string, structured) in versions {
@ -879,50 +883,50 @@ fn parse_version_valid() {
// local tests
assert_eq!(
p("5+2"),
Version::new([5]).with_local(vec![LocalSegment::Number(2)])
Version::new([5]).with_local_segments(vec![LocalSegment::Number(2)])
);
assert_eq!(
p("5+a"),
Version::new([5]).with_local(vec![LocalSegment::String("a".to_string())])
Version::new([5]).with_local_segments(vec![LocalSegment::String("a".to_string())])
);
assert_eq!(
p("5+abc.123"),
Version::new([5]).with_local(vec![
Version::new([5]).with_local_segments(vec![
LocalSegment::String("abc".to_string()),
LocalSegment::Number(123),
])
);
assert_eq!(
p("5+123.abc"),
Version::new([5]).with_local(vec![
Version::new([5]).with_local_segments(vec![
LocalSegment::Number(123),
LocalSegment::String("abc".to_string()),
])
);
assert_eq!(
p("5+18446744073709551615.abc"),
Version::new([5]).with_local(vec![
Version::new([5]).with_local_segments(vec![
LocalSegment::Number(18_446_744_073_709_551_615),
LocalSegment::String("abc".to_string()),
])
);
assert_eq!(
p("5+18446744073709551616.abc"),
Version::new([5]).with_local(vec![
Version::new([5]).with_local_segments(vec![
LocalSegment::String("18446744073709551616".to_string()),
LocalSegment::String("abc".to_string()),
])
);
assert_eq!(
p("5+ABC.123"),
Version::new([5]).with_local(vec![
Version::new([5]).with_local_segments(vec![
LocalSegment::String("abc".to_string()),
LocalSegment::Number(123),
])
);
assert_eq!(
p("5+ABC-123.4_5_xyz-MNO"),
Version::new([5]).with_local(vec![
Version::new([5]).with_local_segments(vec![
LocalSegment::String("abc".to_string()),
LocalSegment::Number(123),
LocalSegment::Number(4),
@ -933,21 +937,21 @@ fn parse_version_valid() {
);
assert_eq!(
p("5.6.7+abc-00123"),
Version::new([5, 6, 7]).with_local(vec![
Version::new([5, 6, 7]).with_local_segments(vec![
LocalSegment::String("abc".to_string()),
LocalSegment::Number(123),
])
);
assert_eq!(
p("5.6.7+abc-foo00123"),
Version::new([5, 6, 7]).with_local(vec![
Version::new([5, 6, 7]).with_local_segments(vec![
LocalSegment::String("abc".to_string()),
LocalSegment::String("foo00123".to_string()),
])
);
assert_eq!(
p("5.6.7+abc-00123a"),
Version::new([5, 6, 7]).with_local(vec![
Version::new([5, 6, 7]).with_local_segments(vec![
LocalSegment::String("abc".to_string()),
LocalSegment::String("00123a".to_string()),
])
@ -992,7 +996,7 @@ fn parse_version_valid() {
assert_eq!(p(" 5 "), Version::new([5]));
assert_eq!(
p(" 5.6.7+abc.123.xyz "),
Version::new([5, 6, 7]).with_local(vec![
Version::new([5, 6, 7]).with_local_segments(vec![
LocalSegment::String("abc".to_string()),
LocalSegment::Number(123),
LocalSegment::String("xyz".to_string())

View file

@ -2,7 +2,10 @@
use version_ranges::Ranges;
use crate::{Operator, Prerelease, Version, VersionSpecifier, VersionSpecifiers};
use crate::{
LocalVersion, LocalVersionSlice, Operator, Prerelease, Version, VersionSpecifier,
VersionSpecifiers,
};
impl From<VersionSpecifiers> for Ranges<Version> {
/// Convert [`VersionSpecifiers`] to a PubGrub-compatible version range, using PEP 440
@ -22,9 +25,23 @@ impl From<VersionSpecifier> for Ranges<Version> {
fn from(specifier: VersionSpecifier) -> Self {
let VersionSpecifier { operator, version } = specifier;
match operator {
Operator::Equal => Ranges::singleton(version),
Operator::Equal => match version.local() {
LocalVersionSlice::Segments(&[]) => {
let low = version;
let high = low.clone().with_local(LocalVersion::Max);
Ranges::between(low, high)
}
LocalVersionSlice::Segments(_) => Ranges::singleton(version),
LocalVersionSlice::Max => unreachable!(
"found `LocalVersionSlice::Sentinel`, which should be an internal-only value"
),
},
Operator::ExactEqual => Ranges::singleton(version),
Operator::NotEqual => Ranges::singleton(version).complement(),
Operator::NotEqual => Ranges::from(VersionSpecifier {
operator: Operator::Equal,
version,
})
.complement(),
Operator::TildeEqual => {
let [rest @ .., last, _] = version.release() else {
unreachable!("~= must have at least two segments");
@ -45,7 +62,7 @@ impl From<VersionSpecifier> for Ranges<Version> {
Ranges::strictly_lower_than(version.with_min(Some(0)))
}
}
Operator::LessThanEqual => Ranges::lower_than(version),
Operator::LessThanEqual => Ranges::lower_than(version.with_local(LocalVersion::Max)),
Operator::GreaterThan => {
// Per PEP 440: "The exclusive ordered comparison >V MUST NOT allow a post-release of
// the given version unless V itself is a post release."

View file

@ -652,12 +652,7 @@ impl std::fmt::Display for VersionSpecifierBuildError {
operator: ref op,
ref version,
} => {
let local = version
.local()
.iter()
.map(ToString::to_string)
.collect::<Vec<String>>()
.join(".");
let local = version.local();
write!(
f,
"Operator {op} is incompatible with versions \

View file

@ -579,7 +579,8 @@ fn test_invalid_specifier() {
ParseErrorKind::InvalidSpecifier(
BuildErrorKind::OperatorLocalCombo {
operator: Operator::TildeEqual,
version: Version::new([1, 0]).with_local(vec![LocalSegment::Number(5)]),
version: Version::new([1, 0])
.with_local_segments(vec![LocalSegment::Number(5)]),
}
.into(),
)
@ -591,7 +592,7 @@ fn test_invalid_specifier() {
BuildErrorKind::OperatorLocalCombo {
operator: Operator::GreaterThanEqual,
version: Version::new([1, 0])
.with_local(vec![LocalSegment::String("deadbeef".to_string())]),
.with_local_segments(vec![LocalSegment::String("deadbeef".to_string())]),
}
.into(),
)
@ -603,7 +604,7 @@ fn test_invalid_specifier() {
BuildErrorKind::OperatorLocalCombo {
operator: Operator::LessThanEqual,
version: Version::new([1, 0])
.with_local(vec![LocalSegment::String("abc123".to_string())]),
.with_local_segments(vec![LocalSegment::String("abc123".to_string())]),
}
.into(),
)
@ -615,7 +616,7 @@ fn test_invalid_specifier() {
BuildErrorKind::OperatorLocalCombo {
operator: Operator::GreaterThan,
version: Version::new([1, 0])
.with_local(vec![LocalSegment::String("watwat".to_string())]),
.with_local_segments(vec![LocalSegment::String("watwat".to_string())]),
}
.into(),
)
@ -626,8 +627,10 @@ fn test_invalid_specifier() {
ParseErrorKind::InvalidSpecifier(
BuildErrorKind::OperatorLocalCombo {
operator: Operator::LessThan,
version: Version::new([1, 0])
.with_local(vec![LocalSegment::Number(1), LocalSegment::Number(0)]),
version: Version::new([1, 0]).with_local_segments(vec![
LocalSegment::Number(1),
LocalSegment::Number(0),
]),
}
.into(),
)

View file

@ -2125,7 +2125,9 @@ impl FromStr for VersionRequest {
return Err(Error::InvalidVersionRequest(s.to_string()));
}
let [uv_pep440::LocalSegment::String(local)] = version.local() else {
let uv_pep440::LocalVersionSlice::Segments([uv_pep440::LocalSegment::String(local)]) =
version.local()
else {
return Err(Error::InvalidVersionRequest(s.to_string()));
};

View file

@ -1,10 +1,20 @@
use std::collections::{BTreeMap, BTreeSet};
use std::collections::{BTreeMap, BTreeSet, Bound};
use std::fmt::Formatter;
use std::sync::Arc;
use indexmap::IndexSet;
use pubgrub::{DefaultStringReporter, DerivationTree, Derived, External, Range, Reporter};
use pubgrub::{
DefaultStringReporter, DerivationTree, Derived, External, Range, Ranges, Reporter, Term,
};
use rustc_hash::FxHashMap;
use tracing::trace;
use uv_distribution_types::{
BuiltDist, IndexCapabilities, IndexLocations, IndexUrl, InstalledDist, SourceDist,
};
use uv_normalize::PackageName;
use uv_pep440::{LocalVersionSlice, Version};
use uv_static::EnvVars;
use crate::candidate_selector::CandidateSelector;
use crate::dependency_provider::UvDependencyProvider;
@ -16,13 +26,6 @@ use crate::resolver::{
IncompletePackage, ResolverEnvironment, UnavailablePackage, UnavailableReason,
};
use crate::Options;
use tracing::trace;
use uv_distribution_types::{
BuiltDist, IndexCapabilities, IndexLocations, IndexUrl, InstalledDist, SourceDist,
};
use uv_normalize::PackageName;
use uv_pep440::Version;
use uv_static::EnvVars;
#[derive(Debug, thiserror::Error)]
pub enum ResolveError {
@ -221,6 +224,178 @@ impl NoSolutionError {
.expect("derivation tree should contain at least one external term")
}
/// Simplifies the version ranges on any incompatibilities to remove the `[max]` sentinel.
///
/// The `[max]` sentinel is used to represent the maximum local version of a package, to
/// implement PEP 440 semantics for local version equality. For example, `1.0.0+foo` needs to
/// satisfy `==1.0.0`.
pub(crate) fn collapse_local_version_segments(derivation_tree: ErrorTree) -> ErrorTree {
/// Remove local versions sentinels (`+[max]`) from the interval.
fn strip_sentinel(
mut lower: Bound<Version>,
mut upper: Bound<Version>,
) -> (Bound<Version>, Bound<Version>) {
match (&lower, &upper) {
(Bound::Unbounded, Bound::Unbounded) => {}
(Bound::Unbounded, Bound::Included(v)) => {
// `<=1.0.0+[max]` is equivalent to `<=1.0.0`
if v.local() == LocalVersionSlice::Max {
upper = Bound::Included(v.clone().without_local());
}
}
(Bound::Unbounded, Bound::Excluded(v)) => {
// `<1.0.0+[max]` is equivalent to `<1.0.0`
if v.local() == LocalVersionSlice::Max {
upper = Bound::Excluded(v.clone().without_local());
}
}
(Bound::Included(v), Bound::Unbounded) => {
// `>=1.0.0+[max]` is equivalent to `>1.0.0`
if v.local() == LocalVersionSlice::Max {
lower = Bound::Excluded(v.clone().without_local());
}
}
(Bound::Included(v), Bound::Included(b)) => {
// `>=1.0.0+[max]` is equivalent to `>1.0.0`
if v.local() == LocalVersionSlice::Max {
lower = Bound::Excluded(v.clone().without_local());
}
// `<=1.0.0+[max]` is equivalent to `<=1.0.0`
if b.local() == LocalVersionSlice::Max {
upper = Bound::Included(b.clone().without_local());
}
}
(Bound::Included(v), Bound::Excluded(b)) => {
// `>=1.0.0+[max]` is equivalent to `>1.0.0`
if v.local() == LocalVersionSlice::Max {
lower = Bound::Excluded(v.clone().without_local());
}
// `<1.0.0+[max]` is equivalent to `<1.0.0`
if b.local() == LocalVersionSlice::Max {
upper = Bound::Included(b.clone().without_local());
}
}
(Bound::Excluded(v), Bound::Unbounded) => {
// `>1.0.0+[max]` is equivalent to `>1.0.0`
if v.local() == LocalVersionSlice::Max {
lower = Bound::Excluded(v.clone().without_local());
}
}
(Bound::Excluded(v), Bound::Included(b)) => {
// `>1.0.0+[max]` is equivalent to `>1.0.0`
if v.local() == LocalVersionSlice::Max {
lower = Bound::Excluded(v.clone().without_local());
}
// `<=1.0.0+[max]` is equivalent to `<=1.0.0`
if b.local() == LocalVersionSlice::Max {
upper = Bound::Included(b.clone().without_local());
}
}
(Bound::Excluded(v), Bound::Excluded(b)) => {
// `>1.0.0+[max]` is equivalent to `>1.0.0`
if v.local() == LocalVersionSlice::Max {
lower = Bound::Excluded(v.clone().without_local());
}
// `<1.0.0+[max]` is equivalent to `<1.0.0`
if b.local() == LocalVersionSlice::Max {
upper = Bound::Excluded(b.clone().without_local());
}
}
}
(lower, upper)
}
/// Remove local versions sentinels (`+[max]`) from the version ranges.
#[allow(clippy::needless_pass_by_value)]
fn strip_sentinels(versions: Ranges<Version>) -> Ranges<Version> {
let mut range = Ranges::empty();
for (lower, upper) in versions.iter() {
let (lower, upper) = strip_sentinel(lower.clone(), upper.clone());
range = range.union(&Range::from_range_bounds((lower, upper)));
}
range
}
/// Returns `true` if the range appears to be, e.g., `>1.0.0, <1.0.0+[max]`.
fn is_sentinel(versions: &Ranges<Version>) -> bool {
versions.iter().all(|(lower, upper)| {
let (Bound::Excluded(lower), Bound::Excluded(upper)) = (lower, upper) else {
return false;
};
if lower.local() == LocalVersionSlice::Max {
return false;
}
if upper.local() != LocalVersionSlice::Max {
return false;
}
*lower == upper.clone().without_local()
})
}
fn strip(derivation_tree: ErrorTree) -> Option<ErrorTree> {
match derivation_tree {
DerivationTree::External(External::NotRoot(_, _)) => Some(derivation_tree),
DerivationTree::External(External::NoVersions(package, versions)) => {
if is_sentinel(&versions) {
return None;
}
let versions = strip_sentinels(versions);
Some(DerivationTree::External(External::NoVersions(
package, versions,
)))
}
DerivationTree::External(External::FromDependencyOf(
package1,
versions1,
package2,
versions2,
)) => {
let versions1 = strip_sentinels(versions1);
let versions2 = strip_sentinels(versions2);
Some(DerivationTree::External(External::FromDependencyOf(
package1, versions1, package2, versions2,
)))
}
DerivationTree::External(External::Custom(package, versions, reason)) => {
let versions = strip_sentinels(versions);
Some(DerivationTree::External(External::Custom(
package, versions, reason,
)))
}
DerivationTree::Derived(mut derived) => {
let cause1 = strip((*derived.cause1).clone());
let cause2 = strip((*derived.cause2).clone());
match (cause1, cause2) {
(Some(cause1), Some(cause2)) => Some(DerivationTree::Derived(Derived {
cause1: Arc::new(cause1),
cause2: Arc::new(cause2),
terms: std::mem::take(&mut derived.terms)
.into_iter()
.map(|(pkg, term)| {
let term = match term {
Term::Positive(versions) => {
Term::Positive(strip_sentinels(versions))
}
Term::Negative(versions) => {
Term::Negative(strip_sentinels(versions))
}
};
(pkg, term)
})
.collect(),
shared_id: derived.shared_id,
})),
(Some(cause), None) | (None, Some(cause)) => Some(cause),
_ => None,
}
}
}
}
strip(derivation_tree).expect("derivation tree should contain at least one term")
}
/// Initialize a [`NoSolutionHeader`] for this error.
pub fn header(&self) -> NoSolutionHeader {
NoSolutionHeader::new(self.env.clone())

View file

@ -1964,7 +1964,9 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
index_locations: &IndexLocations,
index_capabilities: &IndexCapabilities,
) -> ResolveError {
err = NoSolutionError::collapse_proxies(err);
err = NoSolutionError::collapse_local_version_segments(NoSolutionError::collapse_proxies(
err,
));
let mut unavailable_packages = FxHashMap::default();
for package in err.packages() {

View file

@ -2780,7 +2780,7 @@ fn fork_non_local_fork_marker_direct() -> Result<()> {
----- stderr -----
× No solution found when resolving dependencies:
Because package-b{sys_platform == 'darwin'}==1.0.0 depends on package-c>=2.0.0 and package-a{sys_platform == 'linux'}==1.0.0 depends on package-c<2.0.0, we can conclude that package-a{sys_platform == 'linux'}==1.0.0 and package-b{sys_platform == 'darwin'}==1.0.0 are incompatible.
Because package-a{sys_platform == 'linux'}==1.0.0 depends on package-c<2.0.0 and package-b{sys_platform == 'darwin'}==1.0.0 depends on package-c>=2.0.0, we can conclude that package-a{sys_platform == 'linux'}==1.0.0 and package-b{sys_platform == 'darwin'}==1.0.0 are incompatible.
And because your project depends on package-a{sys_platform == 'linux'}==1.0.0 and package-b{sys_platform == 'darwin'}==1.0.0, we can conclude that your project's requirements are unsatisfiable.
"###
);
@ -2852,11 +2852,11 @@ fn fork_non_local_fork_marker_transitive() -> Result<()> {
----- stderr -----
× No solution found when resolving dependencies:
Because package-b==1.0.0 depends on package-c{sys_platform == 'darwin'}>=2.0.0 and only package-c{sys_platform == 'darwin'}<=2.0.0 is available, we can conclude that package-b==1.0.0 depends on package-c{sys_platform == 'darwin'}==2.0.0.
And because only the following versions of package-c{sys_platform == 'linux'} are available:
Because package-a==1.0.0 depends on package-c{sys_platform == 'linux'}<2.0.0 and only the following versions of package-c{sys_platform == 'linux'} are available:
package-c{sys_platform == 'linux'}==1.0.0
package-c{sys_platform == 'linux'}>2.0.0
and package-a==1.0.0 depends on package-c{sys_platform == 'linux'}<2.0.0, we can conclude that package-a==1.0.0 and package-b==1.0.0 are incompatible.
we can conclude that package-a==1.0.0 depends on package-c{sys_platform == 'linux'}==1.0.0.
And because only package-c{sys_platform == 'darwin'}<=2.0.0 is available and package-b==1.0.0 depends on package-c{sys_platform == 'darwin'}>=2.0.0, we can conclude that package-a==1.0.0 and package-b==1.0.0 are incompatible.
And because your project depends on package-a==1.0.0 and package-b==1.0.0, we can conclude that your project's requirements are unsatisfiable.
"###
);

View file

@ -7179,7 +7179,7 @@ fn universal_transitive_disjoint_locals() -> Result<()> {
# -r requirements.in
# torchvision
# triton
torchvision==0.15.1
torchvision==0.15.1+rocm5.4.2
# via -r requirements.in
triton==2.0.0 ; platform_machine == 'x86_64' and platform_system == 'Linux'
# via torch
@ -7452,30 +7452,33 @@ fn universal_disjoint_base_or_local_requirement() -> Result<()> {
----- stdout -----
# This file was autogenerated by uv via the following command:
# uv pip compile --cache-dir [CACHE_DIR] requirements.in --universal
cmake==3.28.4 ; python_full_version >= '3.11' and python_full_version < '3.13' and platform_machine == 'x86_64' and platform_system == 'Linux'
# via triton
cmake==3.28.4 ; python_full_version < '3.11' or (python_full_version < '3.13' and platform_machine == 'x86_64' and platform_system == 'Linux')
# via
# pytorch-triton-rocm
# triton
.
# via -r requirements.in
filelock==3.13.1
# via
# pytorch-triton-rocm
# torch
# triton
jinja2==3.1.3
# via torch
lit==18.1.2 ; python_full_version >= '3.11' and python_full_version < '3.13' and platform_machine == 'x86_64' and platform_system == 'Linux'
# via triton
lit==18.1.2 ; python_full_version < '3.11' or (python_full_version < '3.13' and platform_machine == 'x86_64' and platform_system == 'Linux')
# via
# pytorch-triton-rocm
# triton
markupsafe==2.1.5
# via jinja2
mpmath==1.3.0
# via sympy
networkx==3.2.1
# via torch
pytorch-triton-rocm==2.0.2 ; python_full_version < '3.11'
# via torch
sympy==1.12
# via torch
torch==2.0.0 ; python_full_version < '3.11'
# via
# -r requirements.in
# example
torch==2.0.0+cpu ; python_full_version >= '3.13'
# via
# -r requirements.in
@ -7485,13 +7488,18 @@ fn universal_disjoint_base_or_local_requirement() -> Result<()> {
# -r requirements.in
# example
# triton
torch==2.0.0+rocm5.4.2 ; python_full_version < '3.11'
# via
# -r requirements.in
# example
# pytorch-triton-rocm
triton==2.0.0 ; python_full_version >= '3.11' and python_full_version < '3.13' and platform_machine == 'x86_64' and platform_system == 'Linux'
# via torch
typing-extensions==4.10.0
# via torch
----- stderr -----
Resolved 14 packages in [TIME]
Resolved 15 packages in [TIME]
"###
);
@ -7539,6 +7547,7 @@ fn universal_nested_overlapping_local_requirement() -> Result<()> {
# via -r requirements.in
filelock==3.13.1
# via
# pytorch-triton-rocm
# torch
# triton
fsspec==2024.3.1 ; platform_machine != 'x86_64'
@ -7557,6 +7566,8 @@ fn universal_nested_overlapping_local_requirement() -> Result<()> {
# via sympy
networkx==3.2.1
# via torch
pytorch-triton-rocm==2.3.0 ; platform_machine != 'x86_64'
# via torch
sympy==1.12
# via torch
tbb==2021.11.0 ; platform_machine != 'x86_64' and platform_system == 'Windows'
@ -7566,7 +7577,7 @@ fn universal_nested_overlapping_local_requirement() -> Result<()> {
# -r requirements.in
# example
# triton
torch==2.3.0 ; platform_machine != 'x86_64'
torch==2.3.0+rocm6.0 ; platform_machine != 'x86_64'
# via -r requirements.in
triton==2.0.0 ; platform_machine == 'x86_64' and platform_system == 'Linux'
# via torch
@ -7574,7 +7585,7 @@ fn universal_nested_overlapping_local_requirement() -> Result<()> {
# via torch
----- stderr -----
Resolved 17 packages in [TIME]
Resolved 18 packages in [TIME]
"###
);
@ -7613,6 +7624,7 @@ fn universal_nested_overlapping_local_requirement() -> Result<()> {
# via -r requirements.in
filelock==3.13.1
# via
# pytorch-triton-rocm
# torch
# triton
fsspec==2024.3.1 ; platform_machine != 'x86_64'
@ -7631,6 +7643,8 @@ fn universal_nested_overlapping_local_requirement() -> Result<()> {
# via sympy
networkx==3.2.1
# via torch
pytorch-triton-rocm==2.3.0 ; platform_machine != 'x86_64'
# via torch
sympy==1.12
# via torch
tbb==2021.11.0 ; platform_machine != 'x86_64' and platform_system == 'Windows'
@ -7640,7 +7654,7 @@ fn universal_nested_overlapping_local_requirement() -> Result<()> {
# -r requirements.in
# example
# triton
torch==2.3.0 ; platform_machine != 'x86_64'
torch==2.3.0+rocm6.0 ; platform_machine != 'x86_64'
# via -r requirements.in
triton==2.0.0 ; platform_machine == 'x86_64' and platform_system == 'Linux'
# via torch
@ -7648,7 +7662,7 @@ fn universal_nested_overlapping_local_requirement() -> Result<()> {
# via torch
----- stderr -----
Resolved 17 packages in [TIME]
Resolved 18 packages in [TIME]
"###
);
@ -7698,6 +7712,7 @@ fn universal_nested_disjoint_local_requirement() -> Result<()> {
# via -r requirements.in
filelock==3.13.1
# via
# pytorch-triton-rocm
# torch
# triton
fsspec==2024.3.1 ; os_name != 'Linux'
@ -7716,36 +7731,7 @@ fn universal_nested_disjoint_local_requirement() -> Result<()> {
# via sympy
networkx==3.2.1
# via torch
nvidia-cublas-cu12==12.1.3.1 ; os_name != 'Linux' and platform_machine == 'x86_64' and platform_system == 'Linux'
# via
# nvidia-cudnn-cu12
# nvidia-cusolver-cu12
# torch
nvidia-cuda-cupti-cu12==12.1.105 ; os_name != 'Linux' and platform_machine == 'x86_64' and platform_system == 'Linux'
# via torch
nvidia-cuda-nvrtc-cu12==12.1.105 ; os_name != 'Linux' and platform_machine == 'x86_64' and platform_system == 'Linux'
# via torch
nvidia-cuda-runtime-cu12==12.1.105 ; os_name != 'Linux' and platform_machine == 'x86_64' and platform_system == 'Linux'
# via torch
nvidia-cudnn-cu12==8.9.2.26 ; os_name != 'Linux' and platform_machine == 'x86_64' and platform_system == 'Linux'
# via torch
nvidia-cufft-cu12==11.0.2.54 ; os_name != 'Linux' and platform_machine == 'x86_64' and platform_system == 'Linux'
# via torch
nvidia-curand-cu12==10.3.2.106 ; os_name != 'Linux' and platform_machine == 'x86_64' and platform_system == 'Linux'
# via torch
nvidia-cusolver-cu12==11.4.5.107 ; os_name != 'Linux' and platform_machine == 'x86_64' and platform_system == 'Linux'
# via torch
nvidia-cusparse-cu12==12.1.0.106 ; os_name != 'Linux' and platform_machine == 'x86_64' and platform_system == 'Linux'
# via
# nvidia-cusolver-cu12
# torch
nvidia-nccl-cu12==2.20.5 ; os_name != 'Linux' and platform_machine == 'x86_64' and platform_system == 'Linux'
# via torch
nvidia-nvjitlink-cu12==12.4.99 ; os_name != 'Linux' and platform_machine == 'x86_64' and platform_system == 'Linux'
# via
# nvidia-cusolver-cu12
# nvidia-cusparse-cu12
nvidia-nvtx-cu12==12.1.105 ; os_name != 'Linux' and platform_machine == 'x86_64' and platform_system == 'Linux'
pytorch-triton-rocm==2.3.0 ; os_name != 'Linux'
# via torch
sympy==1.12
# via torch
@ -7760,7 +7746,7 @@ fn universal_nested_disjoint_local_requirement() -> Result<()> {
# -r requirements.in
# example
# triton
torch==2.3.0 ; os_name != 'Linux'
torch==2.3.0+rocm6.0 ; os_name != 'Linux'
# via -r requirements.in
triton==2.0.0 ; os_name == 'Linux' and platform_machine == 'x86_64' and platform_system == 'Linux'
# via torch
@ -7768,7 +7754,7 @@ fn universal_nested_disjoint_local_requirement() -> Result<()> {
# via torch
----- stderr -----
Resolved 30 packages in [TIME]
Resolved 19 packages in [TIME]
"###
);
@ -8515,14 +8501,20 @@ fn universal_marker_propagation() -> Result<()> {
# via requests
charset-normalizer==3.3.2
# via requests
cmake==3.28.4 ; platform_machine == 'x86_64'
# via pytorch-triton-rocm
filelock==3.13.1
# via torch
# via
# pytorch-triton-rocm
# torch
fsspec==2024.3.1 ; platform_machine != 'x86_64'
# via torch
idna==3.6
# via requests
jinja2==3.1.3
# via torch
lit==18.1.2 ; platform_machine == 'x86_64'
# via pytorch-triton-rocm
markupsafe==2.1.5
# via jinja2
mpmath==1.3.0
@ -8533,15 +8525,20 @@ fn universal_marker_propagation() -> Result<()> {
# via torchvision
pillow==10.2.0
# via torchvision
pytorch-triton-rocm==2.0.2 ; platform_machine == 'x86_64'
# via torch
pytorch-triton-rocm==2.2.0 ; platform_machine != 'x86_64'
# via torch
requests==2.31.0
# via torchvision
sympy==1.12
# via torch
torch==2.0.0 ; platform_machine == 'x86_64'
torch==2.0.0+rocm5.4.2 ; platform_machine == 'x86_64'
# via
# -r requirements.in
# pytorch-triton-rocm
# torchvision
torch==2.2.0 ; platform_machine != 'x86_64'
torch==2.2.0+rocm5.7 ; platform_machine != 'x86_64'
# via
# -r requirements.in
# torchvision
@ -8556,7 +8553,7 @@ fn universal_marker_propagation() -> Result<()> {
----- stderr -----
warning: The requested Python version 3.8 is not available; 3.12.[X] will be used to build dependencies instead.
Resolved 19 packages in [TIME]
Resolved 23 packages in [TIME]
"###
);

View file

@ -334,10 +334,10 @@ fn dependency_excludes_non_contiguous_range_of_compatible_versions() {
----- stderr -----
× No solution found when resolving dependencies:
Because package-a==1.0.0 depends on package-b==1.0.0 and only the following versions of package-a are available:
Because only the following versions of package-a are available:
package-a==1.0.0
package-a>2.0.0,<=3.0.0
we can conclude that package-a<2.0.0 depends on package-b==1.0.0. (1)
and package-a==1.0.0 depends on package-b==1.0.0, we can conclude that package-a<2.0.0 depends on package-b==1.0.0. (1)
Because only the following versions of package-c are available:
package-c==1.0.0
@ -445,10 +445,10 @@ fn dependency_excludes_range_of_compatible_versions() {
----- stderr -----
× No solution found when resolving dependencies:
Because package-a==1.0.0 depends on package-b==1.0.0 and only the following versions of package-a are available:
Because only the following versions of package-a are available:
package-a==1.0.0
package-a>2.0.0,<=3.0.0
we can conclude that package-a<2.0.0 depends on package-b==1.0.0. (1)
and package-a==1.0.0 depends on package-b==1.0.0, we can conclude that package-a<2.0.0 depends on package-b==1.0.0. (1)
Because only the following versions of package-c are available:
package-c==1.0.0
@ -529,17 +529,17 @@ fn excluded_only_compatible_version() {
----- stderr -----
× No solution found when resolving dependencies:
Because package-a==1.0.0 depends on package-b==1.0.0 and only the following versions of package-a are available:
Because only the following versions of package-a are available:
package-a==1.0.0
package-a==2.0.0
package-a==3.0.0
we can conclude that package-a<2.0.0 depends on package-b==1.0.0.
and package-a==1.0.0 depends on package-b==1.0.0, we can conclude that package-a<2.0.0 depends on package-b==1.0.0.
And because package-a==3.0.0 depends on package-b==3.0.0, we can conclude that all of:
package-a<2.0.0
package-a>2.0.0
depend on one of:
package-b<=1.0.0
package-b>=3.0.0
package-b==1.0.0
package-b==3.0.0
And because you require one of:
package-a<2.0.0
@ -1276,8 +1276,10 @@ fn transitive_incompatible_with_transitive() {
/// │ └── python3.8
/// ├── root
/// │ └── requires a>=1.2.3
/// │ ├── satisfied by a-1.2.3+bar
/// │ └── satisfied by a-1.2.3+foo
/// └── a
/// ├── a-1.2.3+bar
/// └── a-1.2.3+foo
/// ```
#[test]
@ -1354,8 +1356,10 @@ fn local_greater_than() {
/// │ └── python3.8
/// ├── root
/// │ └── requires a<=1.2.3
/// │ ├── satisfied by a-1.2.3+bar
/// │ └── satisfied by a-1.2.3+foo
/// └── a
/// ├── a-1.2.3+bar
/// └── a-1.2.3+foo
/// ```
#[test]
@ -1369,19 +1373,22 @@ fn local_less_than_or_equal() {
uv_snapshot!(filters, command(&context)
.arg("local-less-than-or-equal-a<=1.2.3")
, @r###"
success: false
exit_code: 1
success: true
exit_code: 0
----- stdout -----
----- stderr -----
× No solution found when resolving dependencies:
Because only package-a==1.2.3+foo is available and you require package-a<=1.2.3, we can conclude that your requirements are unsatisfiable.
Resolved 1 package in [TIME]
Prepared 1 package in [TIME]
Installed 1 package in [TIME]
+ package-a==1.2.3+foo
"###);
// The version '1.2.3+foo' satisfies the constraint '<=1.2.3'.
assert_not_installed(
assert_installed(
&context.venv,
"local_less_than_or_equal_a",
"1.2.3+foo",
&context.temp_dir,
);
}
@ -1500,14 +1507,14 @@ fn local_not_used_with_sdist() {
Resolved 1 package in [TIME]
Prepared 1 package in [TIME]
Installed 1 package in [TIME]
+ package-a==1.2.3
+ package-a==1.2.3+foo
"###);
// The version '1.2.3' with an sdist satisfies the constraint '==1.2.3'.
assert_installed(
&context.venv,
"local_not_used_with_sdist_a",
"1.2.3",
"1.2.3+foo",
&context.temp_dir,
);
}
@ -1520,8 +1527,10 @@ fn local_not_used_with_sdist() {
/// │ └── python3.8
/// ├── root
/// │ └── requires a==1.2.3
/// │ ├── satisfied by a-1.2.3+bar
/// │ └── satisfied by a-1.2.3+foo
/// └── a
/// ├── a-1.2.3+bar
/// └── a-1.2.3+foo
/// ```
#[test]
@ -1535,17 +1544,24 @@ fn local_simple() {
uv_snapshot!(filters, command(&context)
.arg("local-simple-a==1.2.3")
, @r###"
success: false
exit_code: 1
success: true
exit_code: 0
----- stdout -----
----- stderr -----
× No solution found when resolving dependencies:
Because there is no version of package-a==1.2.3 and you require package-a==1.2.3, we can conclude that your requirements are unsatisfiable.
Resolved 1 package in [TIME]
Prepared 1 package in [TIME]
Installed 1 package in [TIME]
+ package-a==1.2.3+foo
"###);
// The version '1.2.3+foo' satisfies the constraint '==1.2.3'.
assert_not_installed(&context.venv, "local_simple_a", &context.temp_dir);
assert_installed(
&context.venv,
"local_simple_a",
"1.2.3+foo",
&context.temp_dir,
);
}
/// A dependency depends on a conflicting local version of a direct dependency, but we can backtrack to a compatible version.
@ -1563,14 +1579,14 @@ fn local_simple() {
/// ├── a
/// │ ├── a-1.0.0
/// │ │ └── requires b==2.0.0
/// │ │ ├── satisfied by b-2.0.0+foo
/// │ │ └── satisfied by b-2.0.0+bar
/// │ │ ├── satisfied by b-2.0.0+bar
/// │ │ └── satisfied by b-2.0.0+foo
/// │ └── a-2.0.0
/// │ └── requires b==2.0.0+bar
/// │ └── satisfied by b-2.0.0+bar
/// └── b
/// ├── b-2.0.0+foo
/// └── b-2.0.0+bar
/// ├── b-2.0.0+bar
/// └── b-2.0.0+foo
/// ```
#[test]
fn local_transitive_backtrack() {
@ -1627,8 +1643,8 @@ fn local_transitive_backtrack() {
/// │ └── requires b==2.0.0+bar
/// │ └── satisfied by b-2.0.0+bar
/// └── b
/// ├── b-2.0.0+foo
/// └── b-2.0.0+bar
/// ├── b-2.0.0+bar
/// └── b-2.0.0+foo
/// ```
#[test]
fn local_transitive_conflicting() {
@ -1677,9 +1693,11 @@ fn local_transitive_conflicting() {
/// │ └── a-1.0.0
/// │ └── requires b==2.0.0
/// │ ├── satisfied by b-2.0.0
/// │ ├── satisfied by b-2.0.0+bar
/// │ └── satisfied by b-2.0.0+foo
/// └── b
/// ├── b-2.0.0
/// ├── b-2.0.0+bar
/// └── b-2.0.0+foo
/// ```
#[test]
@ -1693,20 +1711,29 @@ fn local_transitive_confounding() {
uv_snapshot!(filters, command(&context)
.arg("local-transitive-confounding-a")
, @r###"
success: false
exit_code: 1
success: true
exit_code: 0
----- stdout -----
----- stderr -----
× No solution found when resolving dependencies:
Because package-b==2.0.0 has no wheels with a matching Python ABI tag and package-a==1.0.0 depends on package-b==2.0.0, we can conclude that package-a==1.0.0 cannot be used.
And because only package-a==1.0.0 is available and you require package-a, we can conclude that your requirements are unsatisfiable.
Resolved 2 packages in [TIME]
Prepared 2 packages in [TIME]
Installed 2 packages in [TIME]
+ package-a==1.0.0
+ package-b==2.0.0+foo
"###);
// The version '2.0.0+foo' satisfies the constraint '==2.0.0'.
assert_not_installed(
assert_installed(
&context.venv,
"local_transitive_confounding_a",
"1.0.0",
&context.temp_dir,
);
assert_installed(
&context.venv,
"local_transitive_confounding_b",
"2.0.0+foo",
&context.temp_dir,
);
}
@ -1725,8 +1752,10 @@ fn local_transitive_confounding() {
/// ├── a
/// │ └── a-1.0.0
/// │ └── requires b>=2.0.0
/// │ ├── satisfied by b-2.0.0+bar
/// │ └── satisfied by b-2.0.0+foo
/// └── b
/// ├── b-2.0.0+bar
/// └── b-2.0.0+foo
/// ```
#[test]
@ -1784,6 +1813,7 @@ fn local_transitive_greater_than_or_equal() {
/// │ └── requires b>2.0.0
/// │ └── unsatisfied: no matching version
/// └── b
/// ├── b-2.0.0+bar
/// └── b-2.0.0+foo
/// ```
#[test]
@ -1834,8 +1864,10 @@ fn local_transitive_greater_than() {
/// ├── a
/// │ └── a-1.0.0
/// │ └── requires b<=2.0.0
/// │ ├── satisfied by b-2.0.0+bar
/// │ └── satisfied by b-2.0.0+foo
/// └── b
/// ├── b-2.0.0+bar
/// └── b-2.0.0+foo
/// ```
#[test]
@ -1893,6 +1925,7 @@ fn local_transitive_less_than_or_equal() {
/// │ └── requires b<2.0.0
/// │ └── unsatisfied: no matching version
/// └── b
/// ├── b-2.0.0+bar
/// └── b-2.0.0+foo
/// ```
#[test]
@ -1943,9 +1976,11 @@ fn local_transitive_less_than() {
/// ├── a
/// │ └── a-1.0.0
/// │ └── requires b==2.0.0
/// │ └── satisfied by b-2.0.0+foo
/// │ ├── satisfied by b-2.0.0+foo
/// │ └── satisfied by b-2.0.0+bar
/// └── b
/// └── b-2.0.0+foo
/// ├── b-2.0.0+foo
/// └── b-2.0.0+bar
/// ```
#[test]
fn local_transitive() {
@ -2011,19 +2046,22 @@ fn local_used_without_sdist() {
uv_snapshot!(filters, command(&context)
.arg("local-used-without-sdist-a==1.2.3")
, @r###"
success: false
exit_code: 1
success: true
exit_code: 0
----- stdout -----
----- stderr -----
× No solution found when resolving dependencies:
Because package-a==1.2.3 has no wheels with a matching Python ABI tag and you require package-a==1.2.3, we can conclude that your requirements are unsatisfiable.
Resolved 1 package in [TIME]
Prepared 1 package in [TIME]
Installed 1 package in [TIME]
+ package-a==1.2.3+foo
"###);
// The version '1.2.3+foo' satisfies the constraint '==1.2.3'.
assert_not_installed(
assert_installed(
&context.venv,
"local_used_without_sdist_a",
"1.2.3+foo",
&context.temp_dir,
);
}

View file

@ -1,5 +1,5 @@
---
source: crates/uv/tests/ecosystem.rs
source: crates/uv/tests/it/ecosystem.rs
expression: snapshot
---
success: true
@ -7,4 +7,4 @@ exit_code: 0
----- stdout -----
----- stderr -----
Resolved 296 packages in [TIME]
Resolved 281 packages in [TIME]

View file

@ -152,18 +152,6 @@ def main(scenarios: list[Path], snapshot_update: bool = True):
else:
scenario["python_patch"] = False
# We don't yet support local versions that aren't expressed as direct dependencies.
for scenario in data["scenarios"]:
expected = scenario["expected"]
if scenario["name"] in (
"local-less-than-or-equal",
"local-simple",
"local-transitive-confounding",
"local-used-without-sdist",
):
expected["satisfiable"] = False
# Split scenarios into `install`, `compile` and `lock` cases
install_scenarios = []
compile_scenarios = []