From 1c5309080b7341ddd593f58bcb36a320edda951c Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Thu, 10 Oct 2024 00:48:53 +0200 Subject: [PATCH] Add gap-preserving range-to-PEP 440 routine (#8060) ## Summary These are changes I apparently forgot to push as per https://github.com/astral-sh/uv/pull/7897/files#r1794312988. --- Cargo.toml | 2 +- crates/uv-pep440/src/version_specifier.rs | 44 +++++++++++- crates/uv-resolver/src/requires_python.rs | 81 +++++++++++------------ crates/uv/tests/lock.rs | 10 +-- crates/uv/tests/sync.rs | 2 +- 5 files changed, 89 insertions(+), 50 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 3658cae7a..c26e1efa4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,7 +45,7 @@ uv-metadata = { path = "crates/uv-metadata" } uv-normalize = { path = "crates/uv-normalize" } uv-once-map = { path = "crates/uv-once-map" } uv-options-metadata = { path = "crates/uv-options-metadata" } -uv-pep440 = { path = "crates/uv-pep440" } +uv-pep440 = { path = "crates/uv-pep440", features = ["tracing"] } uv-pep508 = { path = "crates/uv-pep508", features = ["non-pep508-extensions"] } uv-platform-tags = { path = "crates/uv-platform-tags" } uv-pubgrub = { path = "crates/uv-pubgrub" } diff --git a/crates/uv-pep440/src/version_specifier.rs b/crates/uv-pep440/src/version_specifier.rs index b8785e653..dfa123228 100644 --- a/crates/uv-pep440/src/version_specifier.rs +++ b/crates/uv-pep440/src/version_specifier.rs @@ -2,11 +2,11 @@ use std::cmp::Ordering; use std::ops::Bound; use std::str::FromStr; -use serde::{de, Deserialize, Deserializer, Serialize, Serializer}; - use crate::{ version, Operator, OperatorParseError, Version, VersionPattern, VersionPatternParseError, }; +use serde::{de, Deserialize, Deserializer, Serialize, Serializer}; +use tracing::warn; /// Sorted version specifiers, such as `>=2.1,<3`. /// @@ -69,6 +69,46 @@ impl VersionSpecifiers { specifiers.sort_by(|a, b| a.version().cmp(b.version())); Self(specifiers) } + + /// Returns the [`VersionSpecifiers`] whose union represents the given range. + /// + /// This function is not applicable to ranges involving pre-release versions. + pub fn from_release_only_bounds<'a>( + mut bounds: impl Iterator, &'a Bound)>, + ) -> Self { + let mut specifiers = Vec::new(); + + let Some((start, mut next)) = bounds.next() else { + return Self::empty(); + }; + + // Add specifiers for the holes between the bounds. + for (lower, upper) in bounds { + match (next, lower) { + // Ex) [3.7, 3.8.5), (3.8.5, 3.9] -> >=3.7,!=3.8.5,<=3.9 + (Bound::Excluded(prev), Bound::Excluded(lower)) if prev == lower => { + specifiers.push(VersionSpecifier::not_equals_version(prev.clone())); + } + // Ex) [3.7, 3.8), (3.8, 3.9] -> >=3.7,!=3.8.*,<=3.9 + (Bound::Excluded(prev), Bound::Included(lower)) + if prev.release().len() == 2 + && lower.release() == [prev.release()[0], prev.release()[1] + 1] => + { + specifiers.push(VersionSpecifier::not_equals_star_version(prev.clone())); + } + _ => { + warn!("Ignoring unsupported gap in `requires-python` version: {next:?} -> {lower:?}"); + } + } + next = upper; + } + let end = next; + + // Add the specifiers for the bounding range. + specifiers.extend(VersionSpecifier::from_release_only_bounds((start, end))); + + Self::from_unsorted(specifiers) + } } impl FromIterator for VersionSpecifiers { diff --git a/crates/uv-resolver/src/requires_python.rs b/crates/uv-resolver/src/requires_python.rs index 99d9176a1..e2f49aaa5 100644 --- a/crates/uv-resolver/src/requires_python.rs +++ b/crates/uv-resolver/src/requires_python.rs @@ -1,12 +1,13 @@ -use std::cmp::Ordering; -use std::collections::{BTreeSet, Bound}; -use std::ops::Deref; - +use itertools::Itertools; use pubgrub::Range; +use std::cmp::Ordering; +use std::collections::Bound; +use std::ops::Deref; use uv_distribution_filename::WheelFilename; use uv_pep440::{Version, VersionSpecifier, VersionSpecifiers}; use uv_pep508::{MarkerExpression, MarkerTree, MarkerValueVersion}; +use uv_pubgrub::PubGrubSpecifier; #[derive(thiserror::Error, Debug)] pub enum RequiresPythonError { @@ -52,11 +53,10 @@ impl RequiresPython { /// Returns a [`RequiresPython`] from a version specifier. pub fn from_specifiers(specifiers: &VersionSpecifiers) -> Result { - let (lower_bound, upper_bound) = - crate::pubgrub::PubGrubSpecifier::from_release_specifiers(specifiers)? - .bounding_range() - .map(|(lower_bound, upper_bound)| (lower_bound.cloned(), upper_bound.cloned())) - .unwrap_or((Bound::Unbounded, Bound::Unbounded)); + let (lower_bound, upper_bound) = PubGrubSpecifier::from_release_specifiers(specifiers)? + .bounding_range() + .map(|(lower_bound, upper_bound)| (lower_bound.cloned(), upper_bound.cloned())) + .unwrap_or((Bound::Unbounded, Bound::Unbounded)); Ok(Self { specifiers: specifiers.clone(), range: RequiresPythonRange(LowerBound(lower_bound), UpperBound(upper_bound)), @@ -69,35 +69,35 @@ impl RequiresPython { pub fn intersection<'a>( specifiers: impl Iterator, ) -> Result, RequiresPythonError> { - let mut combined: BTreeSet = BTreeSet::new(); - let mut lower_bound: LowerBound = LowerBound(Bound::Unbounded); - let mut upper_bound: UpperBound = UpperBound(Bound::Unbounded); - - for specifier in specifiers { - // Convert to PubGrub range and perform an intersection. - let requires_python = - crate::pubgrub::PubGrubSpecifier::from_release_specifiers(specifier)?; - if let Some((lower, upper)) = requires_python.bounding_range() { - let lower = LowerBound(lower.cloned()); - let upper = UpperBound(upper.cloned()); - if lower > lower_bound { - lower_bound = lower; + // Convert to PubGrub range and perform an intersection. + let range = specifiers + .into_iter() + .map(PubGrubSpecifier::from_release_specifiers) + .fold_ok(None, |range: Option>, requires_python| { + if let Some(range) = range { + Some(range.intersection(&requires_python.into())) + } else { + Some(requires_python.into()) } - if upper < upper_bound { - upper_bound = upper; - } - } + })?; - // Track all specifiers for the final result. - combined.extend(specifier.iter().cloned()); - } - - if combined.is_empty() { + let Some(range) = range else { return Ok(None); - } + }; - // Compute the intersection by combining the specifiers. - let specifiers = combined.into_iter().collect(); + // Extract the bounds. + let (lower_bound, upper_bound) = range + .bounding_range() + .map(|(lower_bound, upper_bound)| { + ( + LowerBound(lower_bound.cloned()), + UpperBound(upper_bound.cloned()), + ) + }) + .unwrap_or((LowerBound::default(), UpperBound::default())); + + // Convert back to PEP 440 specifiers. + let specifiers = VersionSpecifiers::from_release_only_bounds(range.iter()); Ok(Some(Self { specifiers, @@ -223,7 +223,7 @@ impl RequiresPython { /// provided range. However, `>=3.9` would not be considered compatible, as the /// `Requires-Python` includes Python 3.8, but `>=3.9` does not. pub fn is_contained_by(&self, target: &VersionSpecifiers) -> bool { - let Ok(target) = crate::pubgrub::PubGrubSpecifier::from_release_specifiers(target) else { + let Ok(target) = PubGrubSpecifier::from_release_specifiers(target) else { return false; }; let target = target @@ -458,12 +458,11 @@ impl serde::Serialize for RequiresPython { impl<'de> serde::Deserialize<'de> for RequiresPython { fn deserialize>(deserializer: D) -> Result { let specifiers = VersionSpecifiers::deserialize(deserializer)?; - let (lower_bound, upper_bound) = - crate::pubgrub::PubGrubSpecifier::from_release_specifiers(&specifiers) - .map_err(serde::de::Error::custom)? - .bounding_range() - .map(|(lower_bound, upper_bound)| (lower_bound.cloned(), upper_bound.cloned())) - .unwrap_or((Bound::Unbounded, Bound::Unbounded)); + let (lower_bound, upper_bound) = PubGrubSpecifier::from_release_specifiers(&specifiers) + .map_err(serde::de::Error::custom)? + .bounding_range() + .map(|(lower_bound, upper_bound)| (lower_bound.cloned(), upper_bound.cloned())) + .unwrap_or((Bound::Unbounded, Bound::Unbounded)); Ok(Self { specifiers, range: RequiresPythonRange(LowerBound(lower_bound), UpperBound(upper_bound)), diff --git a/crates/uv/tests/lock.rs b/crates/uv/tests/lock.rs index 95b9b30f5..7f1ff8659 100644 --- a/crates/uv/tests/lock.rs +++ b/crates/uv/tests/lock.rs @@ -3818,7 +3818,7 @@ fn lock_requires_python_star() -> Result<()> { /// `Requires-Python` uses the != operator. #[test] fn lock_requires_python_not_equal() -> Result<()> { - let context = TestContext::new("3.11"); + let context = TestContext::new("3.12"); let lockfile = context.temp_dir.join("uv.lock"); @@ -3828,7 +3828,7 @@ fn lock_requires_python_not_equal() -> Result<()> { [project] name = "project" version = "0.1.0" - requires-python = ">3.10, !=3.10.9, <3.13" + requires-python = ">3.10, !=3.10.9, !=3.10.10, !=3.11.*, <3.13" dependencies = ["iniconfig"] [build-system] @@ -3854,7 +3854,7 @@ fn lock_requires_python_not_equal() -> Result<()> { assert_snapshot!( lock, @r###" version = 1 - requires-python = ">3.10, !=3.10.9, <3.13" + requires-python = ">3.10, !=3.10.9, !=3.10.10, !=3.11.*, <3.13" [options] exclude-newer = "2024-03-25T00:00:00Z" @@ -3936,7 +3936,7 @@ fn lock_requires_python_pre() -> Result<()> { assert_snapshot!( lock, @r###" version = 1 - requires-python = ">=3.11b1" + requires-python = ">=3.11" [options] exclude-newer = "2024-03-25T00:00:00Z" @@ -12954,7 +12954,7 @@ fn lock_simplified_environments() -> Result<()> { assert_snapshot!( lock, @r###" version = 1 - requires-python = ">=3.11, <3.12" + requires-python = "==3.11.*" resolution-markers = [ "sys_platform == 'darwin'", "sys_platform != 'darwin'", diff --git a/crates/uv/tests/sync.rs b/crates/uv/tests/sync.rs index e5ab66245..37046134a 100644 --- a/crates/uv/tests/sync.rs +++ b/crates/uv/tests/sync.rs @@ -378,7 +378,7 @@ fn mixed_requires_python() -> Result<()> { ----- stderr ----- Using CPython 3.8.[X] interpreter at: [PYTHON-3.8] - error: The requested interpreter resolved to Python 3.8.[X], which is incompatible with the project's Python requirement: `>=3.8, >=3.12`. However, a workspace member (`bird-feeder`) supports Python >=3.8. To install the workspace member on its own, navigate to `packages/bird-feeder`, then run `uv venv --python 3.8.[X]` followed by `uv pip install -e .`. + error: The requested interpreter resolved to Python 3.8.[X], which is incompatible with the project's Python requirement: `>=3.12`. However, a workspace member (`bird-feeder`) supports Python >=3.8. To install the workspace member on its own, navigate to `packages/bird-feeder`, then run `uv venv --python 3.8.[X]` followed by `uv pip install -e .`. "###); Ok(())