diff --git a/crates/uv-resolver/src/marker.rs b/crates/uv-resolver/src/marker.rs index 182f3188a837..1012d31449d6 100644 --- a/crates/uv-resolver/src/marker.rs +++ b/crates/uv-resolver/src/marker.rs @@ -1,10 +1,11 @@ #![allow(clippy::enum_glob_use)] -use std::cmp::Ordering; use std::collections::HashMap; use std::ops::Bound::{self, *}; use std::ops::RangeBounds; +use pubgrub::range::Range as PubGrubRange; + use pep440_rs::{Operator, Version, VersionSpecifier}; use pep508_rs::{ ExtraName, ExtraOperator, MarkerExpression, MarkerOperator, MarkerTree, MarkerValueString, @@ -13,7 +14,6 @@ use pep508_rs::{ use crate::pubgrub::PubGrubSpecifier; use crate::RequiresPythonBound; -use pubgrub::range::{Range as PubGrubRange, Range}; /// Returns `true` if there is no environment in which both marker trees can both apply, i.e. /// the expression `first and second` is always false. @@ -92,27 +92,27 @@ pub(crate) fn requires_python_marker(tree: &MarkerTree) -> Option { - // Take the minimum of any nested expressions. - trees.iter().filter_map(requires_python_marker).min() + // Take the maximum of any nested expressions. + trees.iter().filter_map(requires_python_marker).max() } MarkerTree::Or(trees) => { - // If all subtrees have a minimum, take the maximum. - let mut version = None; - for tree in trees.iter() { - let next = requires_python_marker(tree)?; - version = match version { - Some(version) => Some(std::cmp::max(version, next)), - None => Some(next), + // If all subtrees have a bound, take the minimum. + let mut min_version = None; + for tree in trees { + let version = requires_python_marker(tree)?; + min_version = match min_version { + Some(min_version) => Some(std::cmp::min(min_version, version)), + None => Some(version), }; } - version + min_version } MarkerTree::Expression(_) => None, } diff --git a/crates/uv-resolver/src/python_requirement.rs b/crates/uv-resolver/src/python_requirement.rs index ad8709b119ca..cf06fdcdda9e 100644 --- a/crates/uv-resolver/src/python_requirement.rs +++ b/crates/uv-resolver/src/python_requirement.rs @@ -49,8 +49,9 @@ impl PythonRequirement { } } - /// Narrow the [`PythonRequirement`] to the given version. - pub fn narrow(&self, target: RequiresPythonBound) -> Option { + /// Narrow the [`PythonRequirement`] to the given version, if it's stricter (i.e., greater) + /// than the current `Requires-Python` minimum. + pub fn narrow(&self, target: &RequiresPythonBound) -> Option { let Some(PythonTarget::RequiresPython(requires_python)) = self.target.as_ref() else { return None; }; diff --git a/crates/uv-resolver/src/requires_python.rs b/crates/uv-resolver/src/requires_python.rs index 51d917389517..43a861b20337 100644 --- a/crates/uv-resolver/src/requires_python.rs +++ b/crates/uv-resolver/src/requires_python.rs @@ -1,5 +1,6 @@ use std::cmp::Ordering; use std::collections::Bound; +use std::ops::Deref; use itertools::Itertools; use pubgrub::range::Range; @@ -29,16 +30,6 @@ pub struct RequiresPython { } impl RequiresPython { - /// Narrow the [`RequiresPython`] to the given version, if it's stricter than the current - /// target. - pub fn narrow(&self, target: RequiresPythonBound) -> Option { - let target = VersionSpecifiers::from(VersionSpecifier::from_lower_bound(&target.0)?); - Self::union(std::iter::once(&target)) - .ok() - .flatten() - .filter(|next| next.bound > self.bound) - } - /// Returns a [`RequiresPython`] to express `>=` equality with the given version. pub fn greater_than_equal_version(version: Version) -> Self { Self { @@ -89,6 +80,16 @@ impl RequiresPython { Ok(Some(Self { specifiers, bound })) } + /// Narrow the [`RequiresPython`] to the given version, if it's stricter (i.e., greater) than + /// the current target. + pub fn narrow(&self, target: &RequiresPythonBound) -> Option { + let target = VersionSpecifiers::from(VersionSpecifier::from_lower_bound(target)?); + Self::union(std::iter::once(&target)) + .ok() + .flatten() + .filter(|next| next.bound > self.bound) + } + /// Returns `true` if the `Requires-Python` is compatible with the given version. pub fn contains(&self, version: &Version) -> bool { self.specifiers.contains(version) @@ -153,7 +154,7 @@ impl RequiresPython { // Alternatively, we could vary the semantics depending on whether or not the user included // a pre-release in their specifier, enforcing pre-release compatibility only if the user // explicitly requested it. - match (target, &self.bound.0) { + match (target, self.bound.as_ref()) { (Bound::Included(target_lower), Bound::Included(requires_python_lower)) => { target_lower.release() <= requires_python_lower.release() } @@ -179,9 +180,9 @@ impl RequiresPython { &self.specifiers } - /// Returns the lower [`Bound`] for the `Requires-Python` specifier. - pub fn bound(&self) -> &Bound { - &self.bound.0 + /// Returns `true` if the `Requires-Python` specifier is unbounded. + pub fn is_unbounded(&self) -> bool { + self.bound.as_ref() == Bound::Unbounded } /// Returns this `Requires-Python` specifier as an equivalent marker @@ -197,16 +198,14 @@ impl RequiresPython { /// returns a marker tree that evaluates to `true` for all possible marker /// environments. pub fn to_marker_tree(&self) -> MarkerTree { - let (op, version) = match self.bound.0 { + let (op, version) = match self.bound.as_ref() { // If we see this anywhere, then it implies the marker // tree we would generate would always evaluate to // `true` because every possible Python version would // satisfy it. Bound::Unbounded => return MarkerTree::And(vec![]), - Bound::Excluded(ref version) => { - (Operator::GreaterThan, version.clone().without_local()) - } - Bound::Included(ref version) => { + Bound::Excluded(version) => (Operator::GreaterThan, version.clone().without_local()), + Bound::Included(version) => { (Operator::GreaterThanEqual, version.clone().without_local()) } }; @@ -281,6 +280,14 @@ impl RequiresPythonBound { } } +impl Deref for RequiresPythonBound { + type Target = Bound; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + impl PartialOrd for RequiresPythonBound { fn partial_cmp(&self, other: &Self) -> Option { Some(self.cmp(other)) @@ -289,7 +296,7 @@ impl PartialOrd for RequiresPythonBound { impl Ord for RequiresPythonBound { fn cmp(&self, other: &Self) -> Ordering { - match (&self.0, &other.0) { + match (self.as_ref(), other.as_ref()) { (Bound::Included(a), Bound::Included(b)) => a.cmp(b), (Bound::Included(_), Bound::Excluded(_)) => Ordering::Less, (Bound::Excluded(_), Bound::Included(_)) => Ordering::Greater, diff --git a/crates/uv-resolver/src/resolver/mod.rs b/crates/uv-resolver/src/resolver/mod.rs index e06c12c931bd..411e190f5a01 100644 --- a/crates/uv-resolver/src/resolver/mod.rs +++ b/crates/uv-resolver/src/resolver/mod.rs @@ -25,7 +25,7 @@ use tracing::{debug, enabled, instrument, trace, warn, Level}; use distribution_types::{ BuiltDist, CompatibleDist, Dist, DistributionMetadata, IncompatibleDist, IncompatibleSource, IncompatibleWheel, InstalledDist, PythonRequirementKind, RemoteSource, ResolvedDist, - ResolvedDistRef, SourceDist, VersionOrUrlRef, WheelCompatibility, + ResolvedDistRef, SourceDist, VersionOrUrlRef, }; pub(crate) use locals::Locals; use pep440_rs::{Version, MIN_VERSION}; @@ -412,27 +412,6 @@ impl ResolverState ResolverState ResolverState { @@ -576,7 +555,7 @@ impl ResolverState= 2); + assert!(forks.len() >= 2); // This is a somewhat tortured technique to ensure // that our resolver state is only cloned as much // as it needs to be. We basically move the state @@ -597,24 +576,21 @@ impl ResolverState=1.26 ; python_version >= "3.9" + /// numpy <1.26 ; python_version < "3.9" + /// ``` + /// + /// The top fork has a narrower Python compatibility range, and thus can find a + /// solution that omits Python 3.8 support. python_requirement: PythonRequirement, + /// The [`MarkerTree`] corresponding to the [`PythonRequirement`]. requires_python: Option, } @@ -2434,7 +2422,7 @@ impl Dependencies { continue; } }; - // assert!(fork_groups.forks.len() >= 2, "expected definitive fork"); + assert!(fork_groups.forks.len() >= 2, "expected definitive fork"); let mut new_forks: Vec = vec![]; for group in fork_groups.forks { let mut new_forks_for_group = forks.clone(); @@ -2612,18 +2600,7 @@ impl<'a> PossibleForks<'a> { let PossibleForks::PossiblyForking(ref fork_groups) = *self else { return false; }; - if fork_groups.forks.len() > 1 { - return true; - } - if fork_groups.forks.iter().any(|fork_groups| { - fork_groups - .packages - .iter() - .any(|(index, markers)| requires_python_marker(markers).is_some()) - }) { - return true; - }; - false + fork_groups.forks.len() > 1 } /// Consumes this possible set of forks and converts a "possibly forking" @@ -2636,14 +2613,7 @@ impl<'a> PossibleForks<'a> { let PossibleForks::PossiblyForking(ref fork_groups) = self else { return self; }; - if fork_groups.forks.len() == 1 - && !fork_groups.forks.iter().any(|fork_groups| { - fork_groups - .packages - .iter() - .any(|(index, markers)| requires_python_marker(markers).is_some()) - }) - { + if fork_groups.forks.len() == 1 { self.make_no_forks_possible(); return self; } diff --git a/crates/uv/src/commands/project/lock.rs b/crates/uv/src/commands/project/lock.rs index 2d58ba07127a..d2d9455bdf40 100644 --- a/crates/uv/src/commands/project/lock.rs +++ b/crates/uv/src/commands/project/lock.rs @@ -1,5 +1,3 @@ -use std::collections::Bound; - use anstream::eprint; use distribution_types::UnresolvedRequirementSpecification; @@ -127,7 +125,7 @@ pub(super) async fn do_lock( let requires_python = find_requires_python(workspace)?; let requires_python = if let Some(requires_python) = requires_python { - if matches!(requires_python.bound(), Bound::Unbounded) { + if requires_python.is_unbounded() { let default = RequiresPython::greater_than_equal_version(interpreter.python_minor_version()); warn_user!("The workspace `requires-python` field does not contain a lower bound: `{requires_python}`. Set a lower bound to indicate the minimum compatible Python version (e.g., `{default}`)."); diff --git a/crates/uv/tests/pip_compile.rs b/crates/uv/tests/pip_compile.rs index 8e2533e6ebfe..cc557a6a6f05 100644 --- a/crates/uv/tests/pip_compile.rs +++ b/crates/uv/tests/pip_compile.rs @@ -6564,6 +6564,41 @@ fn universal_multi_version() -> Result<()> { Ok(()) } +/// Perform a universal resolution that requires narrowing the supported Python range in one of the +/// fork branches. +#[test] +fn universal_requires_python() -> Result<()> { + let context = TestContext::new("3.12"); + let requirements_in = context.temp_dir.child("requirements.in"); + requirements_in.write_str(indoc::indoc! {r" + numpy >=1.26 ; python_version >= '3.9' + numpy <1.26 ; python_version < '3.9' + "})?; + + uv_snapshot!(context.filters(), windows_filters=false, context.pip_compile() + .arg("requirements.in") + .arg("-p") + .arg("3.8") + .arg("--universal"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + # This file was autogenerated by uv via the following command: + # uv pip compile --cache-dir [CACHE_DIR] requirements.in -p 3.8 --universal + numpy==1.24.4 ; python_version < '3.9' + # via -r requirements.in + numpy==1.26.4 ; python_version >= '3.9' + # via -r requirements.in + + ----- stderr ----- + warning: The requested Python version 3.8 is not available; 3.12.[X] will be used to build dependencies instead. + Resolved 2 packages in [TIME] + "### + ); + + Ok(()) +} + /// Resolve a package from a `requirements.in` file, with a `constraints.txt` file pinning one of /// its transitive dependencies to a specific version. #[test] diff --git a/req.in b/req.in deleted file mode 100644 index f9a855472488..000000000000 --- a/req.in +++ /dev/null @@ -1,2 +0,0 @@ -numpy >=1.26; python_version>="3.9" -numpy <1.26; python_version<"3.9"