diff --git a/PIP_COMPATIBILITY.md b/PIP_COMPATIBILITY.md index d2189eea2..02092a8bb 100644 --- a/PIP_COMPATIBILITY.md +++ b/PIP_COMPATIBILITY.md @@ -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 diff --git a/crates/pep440-rs/src/lib.rs b/crates/pep440-rs/src/lib.rs index b96bf0044..1b35778c3 100644 --- a/crates/pep440-rs/src/lib.rs +++ b/crates/pep440-rs/src/lib.rs @@ -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; diff --git a/crates/pep440-rs/src/version_specifier.rs b/crates/pep440-rs/src/version_specifier.rs index af1873963..64e81ee34 100644 --- a/crates/pep440-rs/src/version_specifier.rs +++ b/crates/pep440-rs/src/version_specifier.rs @@ -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 { 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 { + // "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" diff --git a/crates/pep508-rs/src/lib.rs b/crates/pep508-rs/src/lib.rs index 05dc1a603..9b05d72c9 100644 --- a/crates/pep508-rs/src/lib.rs +++ b/crates/pep508-rs/src/lib.rs @@ -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])), ) diff --git a/crates/pep508-rs/src/marker.rs b/crates/pep508-rs/src/marker.rs index 9764099a2..69ad5484a 100644 --- a/crates/pep508-rs/src/marker.rs +++ b/crates/pep508-rs/src/marker.rs @@ -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::().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()), ) { diff --git a/crates/uv-resolver/src/error.rs b/crates/uv-resolver/src/error.rs index d83ab963c..a09cefed9 100644 --- a/crates/uv-resolver/src/error.rs +++ b/crates/uv-resolver/src/error.rs @@ -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, }, + #[error("Attempted to construct an invalid version specifier")] + InvalidVersion(#[from] pep440_rs::VersionSpecifierBuildError), + /// Something unexpected happened. #[error("{0}")] Failure(String), diff --git a/crates/uv-resolver/src/pubgrub/dependencies.rs b/crates/uv-resolver/src/pubgrub/dependencies.rs index af105522e..97de05b40 100644 --- a/crates/uv-resolver/src/pubgrub/dependencies.rs +++ b/crates/uv-resolver/src/pubgrub/dependencies.rs @@ -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)>); 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 { 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, urls: &Urls, + locals: &Locals, ) -> Result<(PubGrubPackage, Range), 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, diff --git a/crates/uv-resolver/src/resolver/locals.rs b/crates/uv-resolver/src/resolver/locals.rs new file mode 100644 index 000000000..a515d3e9d --- /dev/null +++ b/crates/uv-resolver/src/resolver/locals.rs @@ -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, +} + +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 = 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 { + 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(()) + } +} diff --git a/crates/uv-resolver/src/resolver/mod.rs b/crates/uv-resolver/src/resolver/mod.rs index 8f413a5bd..3d56d0cae 100644 --- a/crates/uv-resolver/src/resolver/mod.rs +++ b/crates/uv-resolver/src/resolver/mod.rs @@ -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, )?; diff --git a/crates/uv/tests/pip_compile_scenarios.rs b/crates/uv/tests/pip_compile_scenarios.rs index 8b2250cb9..0605f931e 100644 --- a/crates/uv/tests/pip_compile_scenarios.rs +++ b/crates/uv/tests/pip_compile_scenarios.rs @@ -1,7 +1,7 @@ //! DO NOT EDIT //! //! Generated with ./scripts/scenarios/sync.sh -//! Scenarios from +//! Scenarios from //! #![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()) diff --git a/crates/uv/tests/pip_install_scenarios.rs b/crates/uv/tests/pip_install_scenarios.rs index 5cc867799..0cfba6771 100644 --- a/crates/uv/tests/pip_install_scenarios.rs +++ b/crates/uv/tests/pip_install_scenarios.rs @@ -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, ); } diff --git a/scripts/scenarios/generate.py b/scripts/scenarios/generate.py index ae62ed136..529552d2d 100755 --- a/scripts/scenarios/generate.py +++ b/scripts/scenarios/generate.py @@ -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[