Narrow requires-python requirement in resolver forks (#4707)

## Summary

Given:

```text
numpy >=1.26 ; python_version >= '3.9'
numpy <1.26 ; python_version < '3.9'
```

When resolving for Python 3.8, we need to narrow the `requires-python`
requirement in the top branch of the fork, because `numpy >=1.26` all
require Python 3.9 or later -- but we know (in that branch) that we only
need to _solve_ for Python 3.9 or later.

Closes https://github.com/astral-sh/uv/issues/4669.
This commit is contained in:
Charlie Marsh 2024-07-02 08:23:38 -04:00 committed by GitHub
parent 89b3324ae1
commit d9f389a58d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 251 additions and 58 deletions

View file

@ -436,7 +436,7 @@ impl VersionSpecifier {
} }
/// Returns a version specifier representing the given lower bound. /// Returns a version specifier representing the given lower bound.
fn from_lower_bound(bound: &Bound<Version>) -> Option<VersionSpecifier> { pub fn from_lower_bound(bound: &Bound<Version>) -> Option<VersionSpecifier> {
match bound { match bound {
Bound::Included(version) => Some( Bound::Included(version) => Some(
VersionSpecifier::from_version(Operator::GreaterThanEqual, version.clone()) VersionSpecifier::from_version(Operator::GreaterThanEqual, version.clone())
@ -450,7 +450,7 @@ impl VersionSpecifier {
} }
/// Returns a version specifier representing the given upper bound. /// Returns a version specifier representing the given upper bound.
fn from_upper_bound(bound: &Bound<Version>) -> Option<VersionSpecifier> { pub fn from_upper_bound(bound: &Bound<Version>) -> Option<VersionSpecifier> {
match bound { match bound {
Bound::Included(version) => Some( Bound::Included(version) => Some(
VersionSpecifier::from_version(Operator::LessThanEqual, version.clone()).unwrap(), VersionSpecifier::from_version(Operator::LessThanEqual, version.clone()).unwrap(),

View file

@ -10,7 +10,7 @@ pub use preferences::{Preference, PreferenceError, Preferences};
pub use prerelease_mode::PreReleaseMode; pub use prerelease_mode::PreReleaseMode;
pub use pubgrub::{PubGrubSpecifier, PubGrubSpecifierError}; pub use pubgrub::{PubGrubSpecifier, PubGrubSpecifierError};
pub use python_requirement::PythonRequirement; pub use python_requirement::PythonRequirement;
pub use requires_python::{RequiresPython, RequiresPythonError}; pub use requires_python::{RequiresPython, RequiresPythonBound, RequiresPythonError};
pub use resolution::{AnnotationStyle, DisplayResolutionGraph, ResolutionGraph}; pub use resolution::{AnnotationStyle, DisplayResolutionGraph, ResolutionGraph};
pub use resolution_mode::ResolutionMode; pub use resolution_mode::ResolutionMode;
pub use resolver::{ pub use resolver::{

View file

@ -4,6 +4,8 @@ use std::collections::HashMap;
use std::ops::Bound::{self, *}; use std::ops::Bound::{self, *};
use std::ops::RangeBounds; use std::ops::RangeBounds;
use pubgrub::range::Range as PubGrubRange;
use pep440_rs::{Operator, Version, VersionSpecifier}; use pep440_rs::{Operator, Version, VersionSpecifier};
use pep508_rs::{ use pep508_rs::{
ExtraName, ExtraOperator, MarkerExpression, MarkerOperator, MarkerTree, MarkerValueString, ExtraName, ExtraOperator, MarkerExpression, MarkerOperator, MarkerTree, MarkerValueString,
@ -11,7 +13,7 @@ use pep508_rs::{
}; };
use crate::pubgrub::PubGrubSpecifier; use crate::pubgrub::PubGrubSpecifier;
use pubgrub::range::Range as PubGrubRange; use crate::RequiresPythonBound;
/// Returns `true` if there is no environment in which both marker trees can both apply, i.e. /// 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. /// the expression `first and second` is always false.
@ -80,6 +82,42 @@ fn string_is_disjoint(this: &MarkerExpression, other: &MarkerExpression) -> bool
true true
} }
/// Returns the minimum Python version that can satisfy the [`MarkerTree`], if it's constrained.
pub(crate) fn requires_python_marker(tree: &MarkerTree) -> Option<RequiresPythonBound> {
match tree {
MarkerTree::Expression(MarkerExpression::Version {
key: MarkerValueVersion::PythonFullVersion | MarkerValueVersion::PythonVersion,
specifier,
}) => {
let specifier = PubGrubSpecifier::try_from(specifier).ok()?;
// Convert to PubGrub range and perform a union.
let range = PubGrubRange::from(specifier);
let (lower, _) = range.iter().next()?;
// Extract the lower bound.
Some(RequiresPythonBound::new(lower.clone()))
}
MarkerTree::And(trees) => {
// Take the maximum of any nested expressions.
trees.iter().filter_map(requires_python_marker).max()
}
MarkerTree::Or(trees) => {
// 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),
};
}
min_version
}
MarkerTree::Expression(_) => None,
}
}
/// Normalizes this marker tree. /// Normalizes this marker tree.
/// ///
/// This function does a number of operations to normalize a marker tree recursively: /// This function does a number of operations to normalize a marker tree recursively:

View file

@ -1,4 +1,3 @@
use std::fmt::{Display, Formatter};
use std::ops::Deref; use std::ops::Deref;
use std::sync::Arc; use std::sync::Arc;
@ -17,9 +16,9 @@ impl Deref for PubGrubPackage {
} }
} }
impl Display for PubGrubPackage { impl std::fmt::Display for PubGrubPackage {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
Display::fmt(&self.0, f) std::fmt::Display::fmt(&self.0, f)
} }
} }

View file

@ -2,7 +2,7 @@ use pep440_rs::VersionSpecifiers;
use pep508_rs::{MarkerTree, StringVersion}; use pep508_rs::{MarkerTree, StringVersion};
use uv_toolchain::{Interpreter, PythonVersion}; use uv_toolchain::{Interpreter, PythonVersion};
use crate::RequiresPython; use crate::{RequiresPython, RequiresPythonBound};
#[derive(Debug, Clone, Eq, PartialEq)] #[derive(Debug, Clone, Eq, PartialEq)]
pub struct PythonRequirement { pub struct PythonRequirement {
@ -49,6 +49,19 @@ impl PythonRequirement {
} }
} }
/// 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<Self> {
let Some(PythonTarget::RequiresPython(requires_python)) = self.target.as_ref() else {
return None;
};
let requires_python = requires_python.narrow(target)?;
Some(Self {
installed: self.installed.clone(),
target: Some(PythonTarget::RequiresPython(requires_python)),
})
}
/// Return the installed version of Python. /// Return the installed version of Python.
pub fn installed(&self) -> &StringVersion { pub fn installed(&self) -> &StringVersion {
&self.installed &self.installed

View file

@ -1,4 +1,6 @@
use std::cmp::Ordering;
use std::collections::Bound; use std::collections::Bound;
use std::ops::Deref;
use itertools::Itertools; use itertools::Itertools;
use pubgrub::range::Range; use pubgrub::range::Range;
@ -24,7 +26,7 @@ pub enum RequiresPythonError {
#[derive(Debug, Clone, Eq, PartialEq, Hash)] #[derive(Debug, Clone, Eq, PartialEq, Hash)]
pub struct RequiresPython { pub struct RequiresPython {
specifiers: VersionSpecifiers, specifiers: VersionSpecifiers,
bound: Bound<Version>, bound: RequiresPythonBound,
} }
impl RequiresPython { impl RequiresPython {
@ -34,7 +36,7 @@ impl RequiresPython {
specifiers: VersionSpecifiers::from(VersionSpecifier::greater_than_equal_version( specifiers: VersionSpecifiers::from(VersionSpecifier::greater_than_equal_version(
version.clone(), version.clone(),
)), )),
bound: Bound::Included(version), bound: RequiresPythonBound(Bound::Included(version)),
} }
} }
@ -61,11 +63,13 @@ impl RequiresPython {
}; };
// Extract the lower bound. // Extract the lower bound.
let bound = range let bound = RequiresPythonBound(
.iter() range
.next() .iter()
.map(|(lower, _)| lower.clone()) .next()
.unwrap_or(Bound::Unbounded); .map(|(lower, _)| lower.clone())
.unwrap_or(Bound::Unbounded),
);
// Convert back to PEP 440 specifiers. // Convert back to PEP 440 specifiers.
let specifiers = range let specifiers = range
@ -76,6 +80,16 @@ impl RequiresPython {
Ok(Some(Self { specifiers, bound })) 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<Self> {
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. /// Returns `true` if the `Requires-Python` is compatible with the given version.
pub fn contains(&self, version: &Version) -> bool { pub fn contains(&self, version: &Version) -> bool {
self.specifiers.contains(version) self.specifiers.contains(version)
@ -140,7 +154,7 @@ impl RequiresPython {
// Alternatively, we could vary the semantics depending on whether or not the user included // 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 // a pre-release in their specifier, enforcing pre-release compatibility only if the user
// explicitly requested it. // explicitly requested it.
match (target, &self.bound) { match (target, self.bound.as_ref()) {
(Bound::Included(target_lower), Bound::Included(requires_python_lower)) => { (Bound::Included(target_lower), Bound::Included(requires_python_lower)) => {
target_lower.release() <= requires_python_lower.release() target_lower.release() <= requires_python_lower.release()
} }
@ -166,9 +180,9 @@ impl RequiresPython {
&self.specifiers &self.specifiers
} }
/// Returns the lower [`Bound`] for the `Requires-Python` specifier. /// Returns `true` if the `Requires-Python` specifier is unbounded.
pub fn bound(&self) -> &Bound<Version> { pub fn is_unbounded(&self) -> bool {
&self.bound self.bound.as_ref() == Bound::Unbounded
} }
/// Returns this `Requires-Python` specifier as an equivalent marker /// Returns this `Requires-Python` specifier as an equivalent marker
@ -184,16 +198,14 @@ impl RequiresPython {
/// returns a marker tree that evaluates to `true` for all possible marker /// returns a marker tree that evaluates to `true` for all possible marker
/// environments. /// environments.
pub fn to_marker_tree(&self) -> MarkerTree { pub fn to_marker_tree(&self) -> MarkerTree {
let (op, version) = match self.bound { let (op, version) = match self.bound.as_ref() {
// If we see this anywhere, then it implies the marker // If we see this anywhere, then it implies the marker
// tree we would generate would always evaluate to // tree we would generate would always evaluate to
// `true` because every possible Python version would // `true` because every possible Python version would
// satisfy it. // satisfy it.
Bound::Unbounded => return MarkerTree::And(vec![]), Bound::Unbounded => return MarkerTree::And(vec![]),
Bound::Excluded(ref version) => { Bound::Excluded(version) => (Operator::GreaterThan, version.clone().without_local()),
(Operator::GreaterThan, version.clone().without_local()) Bound::Included(version) => {
}
Bound::Included(ref version) => {
(Operator::GreaterThanEqual, version.clone().without_local()) (Operator::GreaterThanEqual, version.clone().without_local())
} }
}; };
@ -247,12 +259,50 @@ impl serde::Serialize for RequiresPython {
impl<'de> serde::Deserialize<'de> for RequiresPython { impl<'de> serde::Deserialize<'de> for RequiresPython {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> { fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let specifiers = VersionSpecifiers::deserialize(deserializer)?; let specifiers = VersionSpecifiers::deserialize(deserializer)?;
let bound = crate::pubgrub::PubGrubSpecifier::try_from(&specifiers) let bound = RequiresPythonBound(
.map_err(serde::de::Error::custom)? crate::pubgrub::PubGrubSpecifier::try_from(&specifiers)
.iter() .map_err(serde::de::Error::custom)?
.next() .iter()
.map(|(lower, _)| lower.clone()) .next()
.unwrap_or(Bound::Unbounded); .map(|(lower, _)| lower.clone())
.unwrap_or(Bound::Unbounded),
);
Ok(Self { specifiers, bound }) Ok(Self { specifiers, bound })
} }
} }
#[derive(Debug, Clone, Eq, PartialEq, Hash)]
pub struct RequiresPythonBound(Bound<Version>);
impl RequiresPythonBound {
pub fn new(bound: Bound<Version>) -> Self {
Self(bound)
}
}
impl Deref for RequiresPythonBound {
type Target = Bound<Version>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl PartialOrd for RequiresPythonBound {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for RequiresPythonBound {
fn cmp(&self, other: &Self) -> Ordering {
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,
(Bound::Excluded(a), Bound::Excluded(b)) => a.cmp(b),
(Bound::Unbounded, _) => Ordering::Less,
(_, Bound::Unbounded) => Ordering::Greater,
}
}
}

View file

@ -44,7 +44,7 @@ use crate::dependency_provider::UvDependencyProvider;
use crate::error::ResolveError; use crate::error::ResolveError;
use crate::fork_urls::ForkUrls; use crate::fork_urls::ForkUrls;
use crate::manifest::Manifest; use crate::manifest::Manifest;
use crate::marker::normalize; use crate::marker::{normalize, requires_python_marker};
use crate::pins::FilePins; use crate::pins::FilePins;
use crate::preferences::Preferences; use crate::preferences::Preferences;
use crate::pubgrub::{ use crate::pubgrub::{
@ -312,6 +312,14 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
visited: &mut FxHashSet<PackageName>, visited: &mut FxHashSet<PackageName>,
request_sink: Sender<Request>, request_sink: Sender<Request>,
) -> Result<ResolutionGraph, ResolveError> { ) -> Result<ResolutionGraph, ResolveError> {
debug!(
"Solving with installed Python version: {}",
self.python_requirement.installed()
);
if let Some(target) = self.python_requirement.target() {
debug!("Solving with target Python version: {}", target);
}
let root = PubGrubPackage::from(PubGrubPackageInner::Root(self.project.clone())); let root = PubGrubPackage::from(PubGrubPackageInner::Root(self.project.clone()));
let mut prefetcher = BatchPrefetcher::default(); let mut prefetcher = BatchPrefetcher::default();
let state = ForkState { let state = ForkState {
@ -322,22 +330,20 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
priorities: PubGrubPriorities::default(), priorities: PubGrubPriorities::default(),
added_dependencies: FxHashMap::default(), added_dependencies: FxHashMap::default(),
markers: MarkerTree::And(vec![]), markers: MarkerTree::And(vec![]),
python_requirement: self.python_requirement.clone(),
requires_python: self.requires_python.clone(),
}; };
let mut preferences = self.preferences.clone(); let mut preferences = self.preferences.clone();
let mut forked_states = vec![state]; let mut forked_states = vec![state];
let mut resolutions = vec![]; let mut resolutions = vec![];
debug!(
"Solving with installed Python version: {}",
self.python_requirement.installed()
);
if let Some(target) = self.python_requirement.target() {
debug!("Solving with target Python version: {}", target);
}
'FORK: while let Some(mut state) = forked_states.pop() { 'FORK: while let Some(mut state) = forked_states.pop() {
if !state.markers.is_universal() { if !state.markers.is_universal() {
debug!("Solving split {}", state.markers); if let Some(requires_python) = state.requires_python.as_ref() {
debug!("Solving split {} ({})", state.markers, requires_python);
} else {
debug!("Solving split {}", state.markers);
}
} }
let start = Instant::now(); let start = Instant::now();
loop { loop {
@ -424,6 +430,7 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
&mut state.pins, &mut state.pins,
&preferences, &preferences,
&state.fork_urls, &state.fork_urls,
&state.python_requirement,
visited, visited,
&request_sink, &request_sink,
)?; )?;
@ -502,6 +509,7 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
&version, &version,
&state.fork_urls, &state.fork_urls,
&state.markers, &state.markers,
state.requires_python.as_ref(),
)?; )?;
match forked_deps { match forked_deps {
ForkedDependencies::Unavailable(reason) => { ForkedDependencies::Unavailable(reason) => {
@ -562,10 +570,29 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
if !is_last { if !is_last {
cur_state = Some(forked_state.clone()); cur_state = Some(forked_state.clone());
} }
forked_state.markers.and(fork.markers); forked_state.markers.and(fork.markers);
forked_state.markers = normalize(forked_state.markers) forked_state.markers = normalize(forked_state.markers)
.unwrap_or(MarkerTree::And(Vec::new())); .unwrap_or(MarkerTree::And(Vec::new()));
// If the fork contains a narrowed Python requirement, apply it.
let python_requirement = requires_python_marker(
&forked_state.markers,
)
.and_then(|marker| forked_state.python_requirement.narrow(&marker));
if let Some(python_requirement) = python_requirement {
if let Some(target) = python_requirement.target() {
debug!("Narrowed `requires-python` bound to: {target}");
}
forked_state.requires_python =
if forked_state.requires_python.is_some() {
python_requirement.to_marker_tree()
} else {
None
};
forked_state.python_requirement = python_requirement;
}
forked_state.add_package_version_dependencies( forked_state.add_package_version_dependencies(
for_package.as_deref(), for_package.as_deref(),
&version, &version,
@ -726,6 +753,7 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
pins: &mut FilePins, pins: &mut FilePins,
preferences: &Preferences, preferences: &Preferences,
fork_urls: &ForkUrls, fork_urls: &ForkUrls,
python_requirement: &PythonRequirement,
visited: &mut FxHashSet<PackageName>, visited: &mut FxHashSet<PackageName>,
request_sink: &Sender<Request>, request_sink: &Sender<Request>,
) -> Result<Option<ResolverVersion>, ResolveError> { ) -> Result<Option<ResolverVersion>, ResolveError> {
@ -746,13 +774,14 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
| PubGrubPackageInner::Dev { name, .. } | PubGrubPackageInner::Dev { name, .. }
| PubGrubPackageInner::Package { name, .. } => { | PubGrubPackageInner::Package { name, .. } => {
if let Some(url) = package.name().and_then(|name| fork_urls.get(name)) { if let Some(url) = package.name().and_then(|name| fork_urls.get(name)) {
self.choose_version_url(name, range, url) self.choose_version_url(name, range, url, python_requirement)
} else { } else {
self.choose_version_registry( self.choose_version_registry(
name, name,
range, range,
package, package,
preferences, preferences,
python_requirement,
pins, pins,
visited, visited,
request_sink, request_sink,
@ -769,6 +798,7 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
name: &PackageName, name: &PackageName,
range: &Range<Version>, range: &Range<Version>,
url: &VerbatimParsedUrl, url: &VerbatimParsedUrl,
python_requirement: &PythonRequirement,
) -> Result<Option<ResolverVersion>, ResolveError> { ) -> Result<Option<ResolverVersion>, ResolveError> {
debug!( debug!(
"Searching for a compatible version of {name} @ {} ({range})", "Searching for a compatible version of {name} @ {} ({range})",
@ -826,8 +856,9 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
} }
// The version is incompatible due to its Python requirement. // The version is incompatible due to its Python requirement.
// STOPSHIP(charlie): Merge markers into `python_requirement`.
if let Some(requires_python) = metadata.requires_python.as_ref() { if let Some(requires_python) = metadata.requires_python.as_ref() {
if let Some(target) = self.python_requirement.target() { if let Some(target) = python_requirement.target() {
if !target.is_compatible_with(requires_python) { if !target.is_compatible_with(requires_python) {
return Ok(Some(ResolverVersion::Unavailable( return Ok(Some(ResolverVersion::Unavailable(
version.clone(), version.clone(),
@ -840,7 +871,7 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
))); )));
} }
} }
if !requires_python.contains(self.python_requirement.installed()) { if !requires_python.contains(python_requirement.installed()) {
return Ok(Some(ResolverVersion::Unavailable( return Ok(Some(ResolverVersion::Unavailable(
version.clone(), version.clone(),
UnavailableVersion::IncompatibleDist(IncompatibleDist::Source( UnavailableVersion::IncompatibleDist(IncompatibleDist::Source(
@ -864,6 +895,7 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
range: &Range<Version>, range: &Range<Version>,
package: &PubGrubPackage, package: &PubGrubPackage,
preferences: &Preferences, preferences: &Preferences,
python_requirement: &PythonRequirement,
pins: &mut FilePins, pins: &mut FilePins,
visited: &mut FxHashSet<PackageName>, visited: &mut FxHashSet<PackageName>,
request_sink: &Sender<Request>, request_sink: &Sender<Request>,
@ -932,7 +964,7 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
.requires_python .requires_python
.as_ref() .as_ref()
.and_then(|requires_python| { .and_then(|requires_python| {
if let Some(target) = self.python_requirement.target() { if let Some(target) = python_requirement.target() {
if !target.is_compatible_with(requires_python) { if !target.is_compatible_with(requires_python) {
return Some(IncompatibleDist::Source( return Some(IncompatibleDist::Source(
IncompatibleSource::RequiresPython( IncompatibleSource::RequiresPython(
@ -942,7 +974,7 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
)); ));
} }
} }
if !requires_python.contains(self.python_requirement.installed()) { if !requires_python.contains(python_requirement.installed()) {
return Some(IncompatibleDist::Source( return Some(IncompatibleDist::Source(
IncompatibleSource::RequiresPython( IncompatibleSource::RequiresPython(
requires_python.clone(), requires_python.clone(),
@ -960,7 +992,7 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
.requires_python .requires_python
.as_ref() .as_ref()
.and_then(|requires_python| { .and_then(|requires_python| {
if let Some(target) = self.python_requirement.target() { if let Some(target) = python_requirement.target() {
if !target.is_compatible_with(requires_python) { if !target.is_compatible_with(requires_python) {
return Some(IncompatibleDist::Wheel( return Some(IncompatibleDist::Wheel(
IncompatibleWheel::RequiresPython( IncompatibleWheel::RequiresPython(
@ -970,7 +1002,7 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
)); ));
} }
} else { } else {
if !requires_python.contains(self.python_requirement.installed()) { if !requires_python.contains(python_requirement.installed()) {
return Some(IncompatibleDist::Wheel( return Some(IncompatibleDist::Wheel(
IncompatibleWheel::RequiresPython( IncompatibleWheel::RequiresPython(
requires_python.clone(), requires_python.clone(),
@ -1034,8 +1066,9 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
version: &Version, version: &Version,
fork_urls: &ForkUrls, fork_urls: &ForkUrls,
markers: &MarkerTree, markers: &MarkerTree,
requires_python: Option<&MarkerTree>,
) -> Result<ForkedDependencies, ResolveError> { ) -> Result<ForkedDependencies, ResolveError> {
let result = self.get_dependencies(package, version, fork_urls, markers); let result = self.get_dependencies(package, version, fork_urls, markers, requires_python);
if self.markers.is_some() { if self.markers.is_some() {
return result.map(|deps| match deps { return result.map(|deps| match deps {
Dependencies::Available(deps) => ForkedDependencies::Unforked(deps), Dependencies::Available(deps) => ForkedDependencies::Unforked(deps),
@ -1053,6 +1086,7 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
version: &Version, version: &Version,
fork_urls: &ForkUrls, fork_urls: &ForkUrls,
markers: &MarkerTree, markers: &MarkerTree,
requires_python: Option<&MarkerTree>,
) -> Result<Dependencies, ResolveError> { ) -> Result<Dependencies, ResolveError> {
let url = package.name().and_then(|name| fork_urls.get(name)); let url = package.name().and_then(|name| fork_urls.get(name));
let dependencies = match &**package { let dependencies = match &**package {
@ -1065,6 +1099,7 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
None, None,
None, None,
markers, markers,
requires_python,
); );
requirements requirements
@ -1193,6 +1228,7 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
dev.as_ref(), dev.as_ref(),
Some(name), Some(name),
markers, markers,
requires_python,
); );
let mut dependencies = requirements let mut dependencies = requirements
@ -1313,6 +1349,7 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
dev: Option<&'a GroupName>, dev: Option<&'a GroupName>,
name: Option<&PackageName>, name: Option<&PackageName>,
markers: &'a MarkerTree, markers: &'a MarkerTree,
requires_python: Option<&'a MarkerTree>,
) -> Vec<Cow<'a, Requirement>> { ) -> Vec<Cow<'a, Requirement>> {
// Start with the requirements for the current extra of the package (for an extra // Start with the requirements for the current extra of the package (for an extra
// requirement) or the non-extra (regular) dependencies (if extra is None), plus // requirement) or the non-extra (regular) dependencies (if extra is None), plus
@ -1323,7 +1360,12 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
Either::Right(dependencies.iter()) Either::Right(dependencies.iter())
}; };
let mut requirements = self let mut requirements = self
.requirements_for_extra(regular_and_dev_dependencies, extra, markers) .requirements_for_extra(
regular_and_dev_dependencies,
extra,
markers,
requires_python,
)
.collect::<Vec<_>>(); .collect::<Vec<_>>();
// Check if there are recursive self inclusions and we need to go into the expensive branch. // Check if there are recursive self inclusions and we need to go into the expensive branch.
@ -1346,7 +1388,9 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
if !seen.insert(extra.clone()) { if !seen.insert(extra.clone()) {
continue; continue;
} }
for requirement in self.requirements_for_extra(dependencies, Some(&extra), markers) { for requirement in
self.requirements_for_extra(dependencies, Some(&extra), markers, requires_python)
{
if name == Some(&requirement.name) { if name == Some(&requirement.name) {
// Add each transitively included extra. // Add each transitively included extra.
queue.extend(requirement.extras.iter().cloned()); queue.extend(requirement.extras.iter().cloned());
@ -1370,6 +1414,7 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
dependencies: impl IntoIterator<Item = &'data Requirement> + 'parameters, dependencies: impl IntoIterator<Item = &'data Requirement> + 'parameters,
extra: Option<&'parameters ExtraName>, extra: Option<&'parameters ExtraName>,
markers: &'parameters MarkerTree, markers: &'parameters MarkerTree,
requires_python: Option<&'parameters MarkerTree>,
) -> impl Iterator<Item = Cow<'data, Requirement>> + 'parameters ) -> impl Iterator<Item = Cow<'data, Requirement>> + 'parameters
where where
'data: 'parameters, 'data: 'parameters,
@ -1379,12 +1424,12 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
.filter(move |requirement| { .filter(move |requirement| {
// If the requirement would not be selected with any Python version // If the requirement would not be selected with any Python version
// supported by the root, skip it. // supported by the root, skip it.
if !satisfies_requires_python(self.requires_python.as_ref(), requirement) { if !satisfies_requires_python(requires_python, requirement) {
trace!( trace!(
"skipping {requirement} because of Requires-Python {requires_python}", "skipping {requirement} because of Requires-Python {requires_python}",
// OK because this filter only applies when there is a present // OK because this filter only applies when there is a present
// Requires-Python specifier. // Requires-Python specifier.
requires_python = self.requires_python.as_ref().unwrap() requires_python = requires_python.unwrap()
); );
return false; return false;
} }
@ -1428,10 +1473,10 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
.into_iter() .into_iter()
.flatten() .flatten()
.filter(move |constraint| { .filter(move |constraint| {
if !satisfies_requires_python(self.requires_python.as_ref(), constraint) { if !satisfies_requires_python(requires_python, constraint) {
trace!( trace!(
"skipping {constraint} because of Requires-Python {requires_python}", "skipping {constraint} because of Requires-Python {requires_python}",
requires_python = self.requires_python.as_ref().unwrap() requires_python = requires_python.unwrap()
); );
return false; return false;
} }
@ -1793,6 +1838,21 @@ struct ForkState {
/// that the marker expression that provoked the fork is true), then that /// that the marker expression that provoked the fork is true), then that
/// dependency is completely ignored. /// dependency is completely ignored.
markers: MarkerTree, markers: MarkerTree,
/// The Python requirement for this fork. Defaults to the Python requirement for
/// the resolution, but may be narrowed if a `python_version` marker is present
/// in a given fork.
///
/// For example, in:
/// ```text
/// numpy >=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<MarkerTree>,
} }
impl ForkState { impl ForkState {

View file

@ -1,5 +1,3 @@
use std::collections::Bound;
use anstream::eprint; use anstream::eprint;
use distribution_types::UnresolvedRequirementSpecification; use distribution_types::UnresolvedRequirementSpecification;
@ -129,7 +127,7 @@ pub(super) async fn do_lock(
let requires_python = find_requires_python(workspace)?; let requires_python = find_requires_python(workspace)?;
let requires_python = if let Some(requires_python) = requires_python { let requires_python = if let Some(requires_python) = requires_python {
if matches!(requires_python.bound(), Bound::Unbounded) { if requires_python.is_unbounded() {
let default = let default =
RequiresPython::greater_than_equal_version(interpreter.python_minor_version()); 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}`)."); 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}`).");

View file

@ -6564,6 +6564,41 @@ fn universal_multi_version() -> Result<()> {
Ok(()) 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 /// Resolve a package from a `requirements.in` file, with a `constraints.txt` file pinning one of
/// its transitive dependencies to a specific version. /// its transitive dependencies to a specific version.
#[test] #[test]