Add support for PyTorch-style local version semantics (#2430)

## Summary

This PR adds limited support for PEP 440-compatible local version
testing. Our behavior is _not_ comprehensively in-line with the spec.
However, it does fix by _far_ the biggest practical limitation, and
resolves all the issues that've been raised on uv related to local
versions without introducing much complexity into the resolver, so it
feels like a good tradeoff for me.

I'll summarize the change here, but for more context, see [Andrew's
write-up](https://github.com/astral-sh/uv/issues/1855#issuecomment-1967024866)
in the linked issue.

Local version identifiers are really tricky because of asymmetry.
`==1.2.3` should allow `1.2.3+foo`, but `==1.2.3+foo` should not allow
`1.2.3`. It's very hard to map them to PubGrub, because PubGrub doesn't
think of things in terms of individual specifiers (unlike the PEP 440
spec) -- it only thinks in terms of ranges.

Right now, resolving PyTorch and friends fails, because...

- The user provides requirements like `torch==2.0.0+cu118` and
`torchvision==0.15.1+cu118`.
- We then match those exact versions.
- We then look at the requirements of `torchvision==0.15.1+cu118`, which
includes `torch==2.0.0`.
- Under PEP 440, this is fine, because `torch @ 2.0.0+cu118` should be
compatible with `torch==2.0.0`.
- In our model, though, it's not, because these are different versions.
If we change our comparison logic in various places to allow this, we
risk breaking some fundamental assumptions of PubGrub around version
continuity.
- Thus, we fail to resolve, because we can't accept both `torch @ 2.0.0`
and `torch @ 2.0.0+cu118`.

As compared to the solutions we explored in
https://github.com/astral-sh/uv/issues/1855#issuecomment-1967024866, at
a high level, this approach differs in that we lie about the
_dependencies_ of packages that rely on our local-version-using package,
rather than lying about the versions that exist, or the version we're
returning, etc.

In short:

- When users specify local versions upfront, we keep track of them. So,
above, we'd take note of `torch` and `torchvision`.
- When we convert the dependencies of a package to PubGrub ranges, we
check if the requirement matches `torch` or `torchvision`. If it's
an`==`, we check if it matches (in the above example) for
`torch==2.0.0`. If so, we _change_ the requirement to
`torch==2.0.0+cu118`. (If it's `==` some other version, we return an
incompatibility.)

In other words, we selectively override the declared dependencies by
making them _more specific_ if a compatible local version was specified
upfront.

The net effect here is that the motivating PyTorch resolutions all work.
And, in general, transitive local versions work as expected.

The thing that still _doesn't_ work is: imagine if there were _only_
local versions of `torch` available. Like, `torch @ 2.0.0` didn't exist,
but `torch @ 2.0.0+cpu` did, and `torch @ 2.0.0+gpu` did, and so on.
`pip install torch==2.0.0` would arbitrarily choose one one `2.0.0+cpu`
or `2.0.0+gpu`, and that's correct as per PEP 440 (local version
segments should be completely ignored on `torch==2.0.0`). However, uv
would fail to identify a compatible version. I'd _probably_ prefer to
fix this, although candidly I think our behavior is _ok_ in practice,
and it's never been reported as an issue.

Closes https://github.com/astral-sh/uv/issues/1855.

Closes https://github.com/astral-sh/uv/issues/2080.

Closes https://github.com/astral-sh/uv/issues/2328.
This commit is contained in:
Charlie Marsh 2024-03-16 07:24:50 -07:00 committed by GitHub
parent 62fdd3db59
commit 5a95f50619
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 388 additions and 71 deletions

View file

@ -132,13 +132,32 @@ broadly.
## Local version identifiers
uv does not implement spec-compliant handling of local version identifiers (e.g., `1.0.0+local`).
Though local version identifiers are rare in published packages (and, e.g., disallowed on PyPI),
they're common in the PyTorch ecosystem. uv's incorrect handling of local version identifiers
may lead to resolution failures in some cases.
uv does not implement spec-compliant handling of local version identifiers (e.g., `1.2.3+local`).
This is considered a known limitation. Although local version identifiers are rare in published
packages (and, e.g., disallowed on PyPI), they're common in the PyTorch ecosystem, and uv's approach
to local versions _does_ support typical PyTorch workflows to succeed out-of-the-box.
In the future, uv intends to implement spec-compliant handling of local version identifiers.
For more, see [#1855](https://github.com/astral-sh/uv/issues/1855).
[PEP 440](https://peps.python.org/pep-0440/#version-specifiers) specifies that the local version
segment should typically be ignored when evaluating version specifiers, with a few exceptions.
For example, `foo==1.2.3` should accept `1.2.3+local`, but `foo==1.2.3+local` should _not_ accept
`1.2.3`. These asymmetries are hard to model in a resolution algorithm. As such, uv treats `1.2.3`
and `1.2.3+local` as entirely separate versions, but respects local versions provided as direct
dependencies throughout the resolution, such that if you provide `foo==1.2.3+local` as a direct
dependency, `1.2.3+local` _will_ be accepted for any transitive dependencies that request
`foo==1.2.3`.
To take an example from the PyTorch ecosystem, it's common to specify `torch==2.0.0+cu118` and
`torchvision==0.15.1+cu118` as direct dependencies. `torchvision @ 0.15.1+cu118` declares a
dependency on `torch==2.0.0`. In this case, uv would recognize that `torch==2.0.0+cu118` satisfies
the specifier, since it was provided as a direct dependency.
As compared to pip, the main differences in observed behavior are as follows:
- In general, local versions must be provided as direct dependencies. Resolution may succeed for
transitive dependencies that request a non-local version, but this is not guaranteed.
- If _only_ local versions exist for a package `foo` at a given version (e.g., `1.2.3+local` exists,
but `1.2.3` does not), `uv pip install foo==1.2.3` will fail, while `pip install foo==1.2.3` will
resolve to an arbitrary local version.
## Packages that exist on multiple indexes

View file

@ -41,7 +41,10 @@ pub use {
LocalSegment, Operator, OperatorParseError, PreRelease, PreReleaseKind, Version,
VersionParseError, VersionPattern, VersionPatternParseError, MIN_VERSION,
},
version_specifier::{VersionSpecifier, VersionSpecifiers, VersionSpecifiersParseError},
version_specifier::{
VersionSpecifier, VersionSpecifierBuildError, VersionSpecifiers,
VersionSpecifiersParseError,
},
};
mod version;

View file

@ -342,16 +342,12 @@ impl Serialize for VersionSpecifier {
impl VersionSpecifier {
/// Build from parts, validating that the operator is allowed with that version. The last
/// parameter indicates a trailing `.*`, to differentiate between `1.1.*` and `1.1`
pub fn new(
pub fn from_pattern(
operator: Operator,
version_pattern: VersionPattern,
) -> Result<Self, VersionSpecifierBuildError> {
let star = version_pattern.is_wildcard();
let version = version_pattern.into_version();
// "Local version identifiers are NOT permitted in this version specifier."
if version.is_local() && !operator.is_local_compatible() {
return Err(BuildErrorKind::OperatorLocalCombo { operator, version }.into());
}
// Check if there are star versions and if so, switch operator to star version
let operator = if star {
@ -365,6 +361,19 @@ impl VersionSpecifier {
operator
};
Self::from_version(operator, version)
}
/// Create a new version specifier from an operator and a version.
pub fn from_version(
operator: Operator,
version: Version,
) -> Result<Self, VersionSpecifierBuildError> {
// "Local version identifiers are NOT permitted in this version specifier."
if version.is_local() && !operator.is_local_compatible() {
return Err(BuildErrorKind::OperatorLocalCombo { operator, version }.into());
}
if operator == Operator::TildeEqual && version.release().len() < 2 {
return Err(BuildErrorKind::CompatibleRelease.into());
}
@ -545,7 +554,7 @@ impl FromStr for VersionSpecifier {
}
let vpat = version.parse().map_err(ParseErrorKind::InvalidVersion)?;
let version_specifier =
Self::new(operator, vpat).map_err(ParseErrorKind::InvalidSpecifier)?;
Self::from_pattern(operator, vpat).map_err(ParseErrorKind::InvalidSpecifier)?;
s.eat_while(|c: char| c.is_whitespace());
if !s.done() {
return Err(ParseErrorKind::InvalidTrailing(s.after().to_string()).into());
@ -1664,7 +1673,7 @@ Failed to parse version: Unexpected end of version specifier, expected operator:
let op = Operator::TildeEqual;
let v = Version::new([5]);
let vpat = VersionPattern::verbatim(v);
assert_eq!(err, VersionSpecifier::new(op, vpat).unwrap_err());
assert_eq!(err, VersionSpecifier::from_pattern(op, vpat).unwrap_err());
assert_eq!(
err.to_string(),
"The ~= operator requires at least two segments in the release version"

View file

@ -1198,12 +1198,12 @@ mod tests {
],
version_or_url: Some(VersionOrUrl::VersionSpecifier(
[
VersionSpecifier::new(
VersionSpecifier::from_pattern(
Operator::GreaterThanEqual,
VersionPattern::verbatim(Version::new([2, 8, 1])),
)
.unwrap(),
VersionSpecifier::new(
VersionSpecifier::from_pattern(
Operator::Equal,
VersionPattern::wildcard(Version::new([2, 8])),
)

View file

@ -595,7 +595,7 @@ impl MarkerExpression {
Some(operator) => operator,
};
let specifier = match VersionSpecifier::new(operator, r_vpat) {
let specifier = match VersionSpecifier::from_pattern(operator, r_vpat) {
Ok(specifier) => specifier,
Err(err) => {
reporter(
@ -674,7 +674,7 @@ impl MarkerExpression {
Some(operator) => operator,
};
let specifier = match VersionSpecifier::new(
let specifier = match VersionSpecifier::from_pattern(
operator,
VersionPattern::verbatim(r_version.clone()),
) {
@ -784,7 +784,7 @@ impl MarkerExpression {
let r_vpat = r_string.parse::<VersionPattern>().ok()?;
let operator = operator.to_pep440_operator()?;
// operator and right hand side make the specifier
let specifier = VersionSpecifier::new(operator, r_vpat).ok()?;
let specifier = VersionSpecifier::from_pattern(operator, r_vpat).ok()?;
let compatible = python_versions
.iter()
@ -808,7 +808,7 @@ impl MarkerExpression {
let compatible = python_versions.iter().any(|r_version| {
// operator and right hand side make the specifier and in this case the
// right hand is `python_version` so changes every iteration
match VersionSpecifier::new(
match VersionSpecifier::from_pattern(
operator,
VersionPattern::verbatim(r_version.clone()),
) {

View file

@ -52,9 +52,6 @@ pub enum ResolveError {
#[error("There are conflicting URLs for package `{0}`:\n- {1}\n- {2}")]
ConflictingUrlsTransitive(PackageName, String, String),
#[error("There are conflicting versions for `{0}`: {1}")]
ConflictingVersions(String, String),
#[error("Package `{0}` attempted to resolve via URL: {1}. URL dependencies must be expressed as direct requirements or constraints. Consider adding `{0} @ {1}` to your dependencies or constraints file.")]
DisallowedUrl(PackageName, String),
@ -87,6 +84,9 @@ pub enum ResolveError {
version: Box<Version>,
},
#[error("Attempted to construct an invalid version specifier")]
InvalidVersion(#[from] pep440_rs::VersionSpecifierBuildError),
/// Something unexpected happened.
#[error("{0}")]
Failure(String),

View file

@ -11,7 +11,7 @@ use crate::constraints::Constraints;
use crate::overrides::Overrides;
use crate::pubgrub::specifier::PubGrubSpecifier;
use crate::pubgrub::PubGrubPackage;
use crate::resolver::Urls;
use crate::resolver::{Locals, Urls};
use crate::ResolveError;
#[derive(Debug)]
@ -19,6 +19,7 @@ pub struct PubGrubDependencies(Vec<(PubGrubPackage, Range<Version>)>);
impl PubGrubDependencies {
/// Generate a set of `PubGrub` dependencies from a set of requirements.
#[allow(clippy::too_many_arguments)]
pub(crate) fn from_requirements(
requirements: &[Requirement],
constraints: &Constraints,
@ -26,6 +27,7 @@ impl PubGrubDependencies {
source_name: Option<&PackageName>,
source_extra: Option<&ExtraName>,
urls: &Urls,
locals: &Locals,
env: &MarkerEnvironment,
) -> Result<Self, ResolveError> {
let mut dependencies = Vec::default();
@ -42,12 +44,12 @@ impl PubGrubDependencies {
}
// Add the package, plus any extra variants.
for result in std::iter::once(to_pubgrub(requirement, None, urls)).chain(
for result in std::iter::once(to_pubgrub(requirement, None, urls, locals)).chain(
requirement
.extras
.clone()
.into_iter()
.map(|extra| to_pubgrub(requirement, Some(extra), urls)),
.map(|extra| to_pubgrub(requirement, Some(extra), urls, locals)),
) {
let (mut package, version) = result?;
@ -76,12 +78,12 @@ impl PubGrubDependencies {
}
// Add the package, plus any extra variants.
for result in std::iter::once(to_pubgrub(constraint, None, urls)).chain(
for result in std::iter::once(to_pubgrub(constraint, None, urls, locals)).chain(
constraint
.extras
.clone()
.into_iter()
.map(|extra| to_pubgrub(constraint, Some(extra), urls)),
.map(|extra| to_pubgrub(constraint, Some(extra), urls, locals)),
) {
let (mut package, version) = result?;
@ -128,6 +130,7 @@ fn to_pubgrub(
requirement: &Requirement,
extra: Option<ExtraName>,
urls: &Urls,
locals: &Locals,
) -> Result<(PubGrubPackage, Range<Version>), ResolveError> {
match requirement.version_or_url.as_ref() {
// The requirement has no specifier (e.g., `flask`).
@ -138,12 +141,28 @@ fn to_pubgrub(
// The requirement has a specifier (e.g., `flask>=1.0`).
Some(VersionOrUrl::VersionSpecifier(specifiers)) => {
let version = specifiers
.iter()
.map(PubGrubSpecifier::try_from)
.fold_ok(Range::full(), |range, specifier| {
range.intersection(&specifier.into())
})?;
// If the specifier is an exact version, and the user requested a local version that's
// more precise than the specifier, use the local version instead.
let version = if let Some(expected) = locals.get(&requirement.name) {
specifiers
.iter()
.map(|specifier| {
Locals::map(expected, specifier)
.map_err(ResolveError::InvalidVersion)
.and_then(|specifier| PubGrubSpecifier::try_from(&specifier))
})
.fold_ok(Range::full(), |range, specifier| {
range.intersection(&specifier.into())
})?
} else {
specifiers
.iter()
.map(PubGrubSpecifier::try_from)
.fold_ok(Range::full(), |range, specifier| {
range.intersection(&specifier.into())
})?
};
Ok((
PubGrubPackage::from_package(requirement.name.clone(), extra, urls),
version,

View file

@ -0,0 +1,252 @@
use rustc_hash::FxHashMap;
use pep440_rs::{Operator, Version, VersionSpecifier, VersionSpecifierBuildError};
use pep508_rs::{MarkerEnvironment, VersionOrUrl};
use uv_normalize::PackageName;
use crate::Manifest;
#[derive(Debug, Default)]
pub(crate) struct Locals {
/// A map of package names to their associated, required local versions.
required: FxHashMap<PackageName, Version>,
}
impl Locals {
/// Determine the set of permitted local versions in the [`Manifest`].
pub(crate) fn from_manifest(manifest: &Manifest, markers: &MarkerEnvironment) -> Self {
let mut required: FxHashMap<PackageName, Version> = FxHashMap::default();
// Add all direct requirements and constraints. There's no need to look for conflicts,
// since conflicting versions will be tracked upstream.
for requirement in manifest
.requirements
.iter()
.filter(|requirement| requirement.evaluate_markers(markers, &[]))
.chain(
manifest
.constraints
.iter()
.filter(|requirement| requirement.evaluate_markers(markers, &[])),
)
.chain(manifest.editables.iter().flat_map(|(editable, metadata)| {
metadata
.requires_dist
.iter()
.filter(|requirement| requirement.evaluate_markers(markers, &editable.extras))
}))
.chain(
manifest
.overrides
.iter()
.filter(|requirement| requirement.evaluate_markers(markers, &[])),
)
{
if let Some(VersionOrUrl::VersionSpecifier(specifiers)) =
requirement.version_or_url.as_ref()
{
for specifier in specifiers.iter() {
if let Some(version) = to_local(specifier) {
required.insert(requirement.name.clone(), version.clone());
}
}
}
}
Self { required }
}
/// Return the local [`Version`] to which a package is pinned, if any.
pub(crate) fn get(&self, package: &PackageName) -> Option<&Version> {
self.required.get(package)
}
/// Given a specifier that may include the version _without_ a local segment, return a specifier
/// that includes the local segment from the expected version.
pub(crate) fn map(
local: &Version,
specifier: &VersionSpecifier,
) -> Result<VersionSpecifier, VersionSpecifierBuildError> {
match specifier.operator() {
Operator::Equal | Operator::EqualStar => {
// Given `foo==1.0.0`, if the local version is `1.0.0+local`, map to
// `foo==1.0.0+local`.
//
// This has the intended effect of allowing `1.0.0+local`.
if is_compatible(local, specifier.version()) {
VersionSpecifier::from_version(Operator::Equal, local.clone())
} else {
Ok(specifier.clone())
}
}
Operator::NotEqual | Operator::NotEqualStar => {
// Given `foo!=1.0.0`, if the local version is `1.0.0+local`, map to
// `foo!=1.0.0+local`.
//
// This has the intended effect of disallowing `1.0.0+local`.
//
// There's no risk of accidentally including `foo @ 1.0.0` in the resolution, since
// we _know_ `foo @ 1.0.0+local` is required and would therefore conflict.
if is_compatible(local, specifier.version()) {
VersionSpecifier::from_version(Operator::NotEqual, local.clone())
} else {
Ok(specifier.clone())
}
}
Operator::LessThanEqual => {
// Given `foo<=1.0.0`, if the local version is `1.0.0+local`, map to
// `foo==1.0.0+local`.
//
// This has the intended effect of allowing `1.0.0+local`.
//
// Since `foo==1.0.0+local` is already required, we know that to satisfy
// `foo<=1.0.0`, we _must_ satisfy `foo==1.0.0+local`. We _could_ map to
// `foo<=1.0.0+local`, but local versions are _not_ allowed in exclusive ordered
// specifiers, so introducing `foo<=1.0.0+local` would risk breaking invariants.
if is_compatible(local, specifier.version()) {
VersionSpecifier::from_version(Operator::Equal, local.clone())
} else {
Ok(specifier.clone())
}
}
Operator::GreaterThan => {
// Given `foo>1.0.0`, `foo @ 1.0.0+local` is already (correctly) disallowed.
Ok(specifier.clone())
}
Operator::ExactEqual => {
// Given `foo===1.0.0`, `1.0.0+local` is already (correctly) disallowed.
Ok(specifier.clone())
}
Operator::TildeEqual => {
// Given `foo~=1.0.0`, `foo~=1.0.0+local` is already (correctly) allowed.
Ok(specifier.clone())
}
Operator::LessThan => {
// Given `foo<1.0.0`, `1.0.0+local` is already (correctly) disallowed.
Ok(specifier.clone())
}
Operator::GreaterThanEqual => {
// Given `foo>=1.0.0`, `foo @ 1.0.0+local` is already (correctly) allowed.
Ok(specifier.clone())
}
}
}
}
/// Returns `true` if a provided version is compatible with the expected local version.
///
/// The versions are compatible if they are the same including their local segment, or the
/// same except for the local segment, which is empty in the provided version.
///
/// For example, if the expected version is `1.0.0+local` and the provided version is `1.0.0+other`,
/// this function will return `false`.
///
/// If the expected version is `1.0.0+local` and the provided version is `1.0.0`, the function will
/// return `true`.
fn is_compatible(expected: &Version, provided: &Version) -> bool {
// The requirements should be the same, ignoring local segments.
if expected.clone().without_local() != provided.clone().without_local() {
return false;
}
// If the provided version has a local segment, it should be the same as the expected
// version.
if provided.local().is_empty() {
true
} else {
expected.local() == provided.local()
}
}
/// If a [`VersionSpecifier`] represents exact equality against a local version, return the local
/// version.
fn to_local(specifier: &VersionSpecifier) -> Option<&Version> {
if !matches!(specifier.operator(), Operator::Equal | Operator::ExactEqual) {
return None;
};
if specifier.version().local().is_empty() {
return None;
}
Some(specifier.version())
}
#[cfg(test)]
mod tests {
use std::str::FromStr;
use anyhow::Result;
use pep440_rs::{Operator, Version, VersionSpecifier};
use super::Locals;
#[test]
fn map_version() -> Result<()> {
// Given `==1.0.0`, if the local version is `1.0.0+local`, map to `==1.0.0+local`.
let local = Version::from_str("1.0.0+local")?;
let specifier =
VersionSpecifier::from_version(Operator::Equal, Version::from_str("1.0.0")?)?;
assert_eq!(
Locals::map(&local, &specifier)?,
VersionSpecifier::from_version(Operator::Equal, Version::from_str("1.0.0+local")?)?
);
// Given `!=1.0.0`, if the local version is `1.0.0+local`, map to `!=1.0.0+local`.
let local = Version::from_str("1.0.0+local")?;
let specifier =
VersionSpecifier::from_version(Operator::NotEqual, Version::from_str("1.0.0")?)?;
assert_eq!(
Locals::map(&local, &specifier)?,
VersionSpecifier::from_version(Operator::NotEqual, Version::from_str("1.0.0+local")?)?
);
// Given `<=1.0.0`, if the local version is `1.0.0+local`, map to `==1.0.0+local`.
let local = Version::from_str("1.0.0+local")?;
let specifier =
VersionSpecifier::from_version(Operator::LessThanEqual, Version::from_str("1.0.0")?)?;
assert_eq!(
Locals::map(&local, &specifier)?,
VersionSpecifier::from_version(Operator::Equal, Version::from_str("1.0.0+local")?)?
);
// Given `>1.0.0`, `1.0.0+local` is already (correctly) disallowed.
let local = Version::from_str("1.0.0+local")?;
let specifier =
VersionSpecifier::from_version(Operator::GreaterThan, Version::from_str("1.0.0")?)?;
assert_eq!(
Locals::map(&local, &specifier)?,
VersionSpecifier::from_version(Operator::GreaterThan, Version::from_str("1.0.0")?)?
);
// Given `===1.0.0`, `1.0.0+local` is already (correctly) disallowed.
let local = Version::from_str("1.0.0+local")?;
let specifier =
VersionSpecifier::from_version(Operator::ExactEqual, Version::from_str("1.0.0")?)?;
assert_eq!(
Locals::map(&local, &specifier)?,
VersionSpecifier::from_version(Operator::ExactEqual, Version::from_str("1.0.0")?)?
);
// Given `==1.0.0+local`, `1.0.0+local` is already (correctly) allowed.
let local = Version::from_str("1.0.0+local")?;
let specifier =
VersionSpecifier::from_version(Operator::Equal, Version::from_str("1.0.0+local")?)?;
assert_eq!(
Locals::map(&local, &specifier)?,
VersionSpecifier::from_version(Operator::Equal, Version::from_str("1.0.0+local")?)?
);
// Given `==1.0.0+other`, `1.0.0+local` is already (correctly) disallowed.
let local = Version::from_str("1.0.0+local")?;
let specifier =
VersionSpecifier::from_version(Operator::Equal, Version::from_str("1.0.0+other")?)?;
assert_eq!(
Locals::map(&local, &specifier)?,
VersionSpecifier::from_version(Operator::Equal, Version::from_str("1.0.0+other")?)?
);
Ok(())
}
}

View file

@ -55,8 +55,10 @@ pub use crate::resolver::reporter::{BuildId, Reporter};
use crate::yanks::AllowedYanks;
use crate::{DependencyMode, Options};
pub(crate) use locals::Locals;
mod index;
mod locals;
mod provider;
mod reporter;
mod urls;
@ -94,6 +96,7 @@ pub struct Resolver<'a, Provider: ResolverProvider> {
overrides: Overrides,
editables: Editables,
urls: Urls,
locals: Locals,
dependency_mode: DependencyMode,
markers: &'a MarkerEnvironment,
python_requirement: PythonRequirement,
@ -162,6 +165,7 @@ impl<'a, Provider: ResolverProvider> Resolver<'a, Provider> {
selector: CandidateSelector::for_resolution(options, &manifest, markers),
dependency_mode: options.dependency_mode,
urls: Urls::from_manifest(&manifest, markers)?,
locals: Locals::from_manifest(&manifest, markers),
project: manifest.project,
requirements: manifest.requirements,
constraints: Constraints::from_requirements(manifest.constraints),
@ -751,6 +755,7 @@ impl<'a, Provider: ResolverProvider> Resolver<'a, Provider> {
None,
None,
&self.urls,
&self.locals,
self.markers,
);
@ -826,6 +831,7 @@ impl<'a, Provider: ResolverProvider> Resolver<'a, Provider> {
Some(package_name),
extra.as_ref(),
&self.urls,
&self.locals,
self.markers,
)?;
@ -882,6 +888,7 @@ impl<'a, Provider: ResolverProvider> Resolver<'a, Provider> {
Some(package_name),
extra.as_ref(),
&self.urls,
&self.locals,
self.markers,
)?;

View file

@ -1,7 +1,7 @@
//! DO NOT EDIT
//!
//! Generated with ./scripts/scenarios/sync.sh
//! Scenarios from <https://github.com/zanieb/packse/tree/0.3.10/scenarios>
//! Scenarios from <https://github.com/zanieb/packse/tree/0.3.12/scenarios>
//!
#![cfg(all(feature = "python", feature = "pypi"))]
@ -27,9 +27,9 @@ fn command(context: &TestContext, python_versions: &[&str]) -> Command {
.arg("compile")
.arg("requirements.in")
.arg("--index-url")
.arg("https://astral-sh.github.io/packse/0.3.10/simple-html/")
.arg("https://astral-sh.github.io/packse/0.3.12/simple-html/")
.arg("--find-links")
.arg("https://raw.githubusercontent.com/zanieb/packse/0.3.10/vendor/links.html")
.arg("https://raw.githubusercontent.com/zanieb/packse/0.3.12/vendor/links.html")
.arg("--cache-dir")
.arg(context.cache_dir.path())
.env("VIRTUAL_ENV", context.venv.as_os_str())

View file

@ -1461,19 +1461,31 @@ fn local_transitive() {
.arg("local-transitive-a")
.arg("local-transitive-b==2.0.0+foo")
, @r###"
success: false
exit_code: 1
success: true
exit_code: 0
----- stdout -----
----- stderr -----
× No solution found when resolving dependencies:
Because only package-a==1.0.0 is available and package-a==1.0.0 depends on package-b==2.0.0, we can conclude that all versions of package-a depend on package-b==2.0.0.
And because you require package-a and you require package-b==2.0.0+foo, we can conclude that the requirements are unsatisfiable.
Resolved 2 packages in [TIME]
Downloaded 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 both ==2.0.0 and ==2.0.0+foo.
assert_not_installed(&context.venv, "local_transitive_a", &context.temp_dir);
assert_not_installed(&context.venv, "local_transitive_b", &context.temp_dir);
assert_installed(
&context.venv,
"local_transitive_a",
"1.0.0",
&context.temp_dir,
);
assert_installed(
&context.venv,
"local_transitive_b",
"2.0.0+foo",
&context.temp_dir,
);
}
/// A transitive constraint on a local version should not match an exclusive ordered
@ -1671,25 +1683,29 @@ fn local_transitive_less_than_or_equal() {
.arg("local-transitive-less-than-or-equal-a")
.arg("local-transitive-less-than-or-equal-b==2.0.0+foo")
, @r###"
success: false
exit_code: 1
success: true
exit_code: 0
----- stdout -----
----- stderr -----
× No solution found when resolving dependencies:
Because only package-a==1.0.0 is available and package-a==1.0.0 depends on package-b<=2.0.0, we can conclude that all versions of package-a depend on package-b<=2.0.0.
And because you require package-a and you require package-b==2.0.0+foo, we can conclude that the requirements are unsatisfiable.
Resolved 2 packages in [TIME]
Downloaded 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 both <=2.0.0 and ==2.0.0+foo.
assert_not_installed(
assert_installed(
&context.venv,
"local_transitive_less_than_or_equal_a",
"1.0.0",
&context.temp_dir,
);
assert_not_installed(
assert_installed(
&context.venv,
"local_transitive_less_than_or_equal_b",
"2.0.0+foo",
&context.temp_dir,
);
}
@ -1832,32 +1848,29 @@ fn local_transitive_backtrack() {
.arg("local-transitive-backtrack-a")
.arg("local-transitive-backtrack-b==2.0.0+foo")
, @r###"
success: false
exit_code: 1
success: true
exit_code: 0
----- stdout -----
----- stderr -----
× No solution found when resolving dependencies:
Because only the following versions of package-a are available:
package-a==1.0.0
package-a==2.0.0
and package-a==1.0.0 depends on package-b==2.0.0, we can conclude that package-a<2.0.0 depends on package-b==2.0.0.
And because package-a==2.0.0 depends on package-b==2.0.0+bar, we can conclude that all versions of package-a depend on one of:
package-b==2.0.0
package-b==2.0.0+bar
And because you require package-a and you require package-b==2.0.0+foo, we can conclude that the requirements are unsatisfiable.
Resolved 2 packages in [TIME]
Downloaded 2 packages in [TIME]
Installed 2 packages in [TIME]
+ package-a==1.0.0
+ package-b==2.0.0+foo
"###);
// Backtracking to '1.0.0' gives us compatible local versions of b.
assert_not_installed(
assert_installed(
&context.venv,
"local_transitive_backtrack_a",
"1.0.0",
&context.temp_dir,
);
assert_not_installed(
assert_installed(
&context.venv,
"local_transitive_backtrack_b",
"2.0.0+foo",
&context.temp_dir,
);
}

View file

@ -133,20 +133,15 @@ def main(scenarios: list[Path], snapshot_update: bool = True):
else []
)
# We don't yet support local versions that aren't expressed as direct dependencies.
for scenario in data["scenarios"]:
expected = scenario["expected"]
# TODO(charlie): We do not yet support local version identifiers
if scenario["name"] in (
"local-less-than-or-equal",
"local-simple",
"local-transitive-confounding",
"local-transitive-backtrack",
"local-used-with-sdist",
"local-used-without-sdist",
"local-transitive",
"local-transitive-less-than-or-equal",
):
expected["satisfiable"] = False
expected[