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.
fn from_lower_bound(bound: &Bound<Version>) -> Option<VersionSpecifier> {
pub fn from_lower_bound(bound: &Bound<Version>) -> Option<VersionSpecifier> {
match bound {
Bound::Included(version) => Some(
VersionSpecifier::from_version(Operator::GreaterThanEqual, version.clone())
@ -450,7 +450,7 @@ impl VersionSpecifier {
}
/// 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 {
Bound::Included(version) => Some(
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 pubgrub::{PubGrubSpecifier, PubGrubSpecifierError};
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_mode::ResolutionMode;
pub use resolver::{

View file

@ -4,6 +4,8 @@ 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,
@ -11,7 +13,7 @@ use pep508_rs::{
};
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.
/// the expression `first and second` is always false.
@ -80,6 +82,42 @@ fn string_is_disjoint(this: &MarkerExpression, other: &MarkerExpression) -> bool
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.
///
/// 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::sync::Arc;
@ -17,9 +16,9 @@ impl Deref for PubGrubPackage {
}
}
impl Display for PubGrubPackage {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
Display::fmt(&self.0, f)
impl std::fmt::Display for PubGrubPackage {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
std::fmt::Display::fmt(&self.0, f)
}
}

View file

@ -2,7 +2,7 @@ use pep440_rs::VersionSpecifiers;
use pep508_rs::{MarkerTree, StringVersion};
use uv_toolchain::{Interpreter, PythonVersion};
use crate::RequiresPython;
use crate::{RequiresPython, RequiresPythonBound};
#[derive(Debug, Clone, Eq, PartialEq)]
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.
pub fn installed(&self) -> &StringVersion {
&self.installed

View file

@ -1,4 +1,6 @@
use std::cmp::Ordering;
use std::collections::Bound;
use std::ops::Deref;
use itertools::Itertools;
use pubgrub::range::Range;
@ -24,7 +26,7 @@ pub enum RequiresPythonError {
#[derive(Debug, Clone, Eq, PartialEq, Hash)]
pub struct RequiresPython {
specifiers: VersionSpecifiers,
bound: Bound<Version>,
bound: RequiresPythonBound,
}
impl RequiresPython {
@ -34,7 +36,7 @@ impl RequiresPython {
specifiers: VersionSpecifiers::from(VersionSpecifier::greater_than_equal_version(
version.clone(),
)),
bound: Bound::Included(version),
bound: RequiresPythonBound(Bound::Included(version)),
}
}
@ -61,11 +63,13 @@ impl RequiresPython {
};
// Extract the lower bound.
let bound = range
.iter()
.next()
.map(|(lower, _)| lower.clone())
.unwrap_or(Bound::Unbounded);
let bound = RequiresPythonBound(
range
.iter()
.next()
.map(|(lower, _)| lower.clone())
.unwrap_or(Bound::Unbounded),
);
// Convert back to PEP 440 specifiers.
let specifiers = range
@ -76,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<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.
pub fn contains(&self, version: &Version) -> bool {
self.specifiers.contains(version)
@ -140,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) {
match (target, self.bound.as_ref()) {
(Bound::Included(target_lower), Bound::Included(requires_python_lower)) => {
target_lower.release() <= requires_python_lower.release()
}
@ -166,9 +180,9 @@ impl RequiresPython {
&self.specifiers
}
/// Returns the lower [`Bound`] for the `Requires-Python` specifier.
pub fn bound(&self) -> &Bound<Version> {
&self.bound
/// 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
@ -184,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 {
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())
}
};
@ -247,12 +259,50 @@ impl serde::Serialize for RequiresPython {
impl<'de> serde::Deserialize<'de> for RequiresPython {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let specifiers = VersionSpecifiers::deserialize(deserializer)?;
let bound = crate::pubgrub::PubGrubSpecifier::try_from(&specifiers)
.map_err(serde::de::Error::custom)?
.iter()
.next()
.map(|(lower, _)| lower.clone())
.unwrap_or(Bound::Unbounded);
let bound = RequiresPythonBound(
crate::pubgrub::PubGrubSpecifier::try_from(&specifiers)
.map_err(serde::de::Error::custom)?
.iter()
.next()
.map(|(lower, _)| lower.clone())
.unwrap_or(Bound::Unbounded),
);
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::fork_urls::ForkUrls;
use crate::manifest::Manifest;
use crate::marker::normalize;
use crate::marker::{normalize, requires_python_marker};
use crate::pins::FilePins;
use crate::preferences::Preferences;
use crate::pubgrub::{
@ -312,6 +312,14 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
visited: &mut FxHashSet<PackageName>,
request_sink: Sender<Request>,
) -> 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 mut prefetcher = BatchPrefetcher::default();
let state = ForkState {
@ -322,22 +330,20 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
priorities: PubGrubPriorities::default(),
added_dependencies: FxHashMap::default(),
markers: MarkerTree::And(vec![]),
python_requirement: self.python_requirement.clone(),
requires_python: self.requires_python.clone(),
};
let mut preferences = self.preferences.clone();
let mut forked_states = vec![state];
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() {
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();
loop {
@ -424,6 +430,7 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
&mut state.pins,
&preferences,
&state.fork_urls,
&state.python_requirement,
visited,
&request_sink,
)?;
@ -502,6 +509,7 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
&version,
&state.fork_urls,
&state.markers,
state.requires_python.as_ref(),
)?;
match forked_deps {
ForkedDependencies::Unavailable(reason) => {
@ -562,10 +570,29 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
if !is_last {
cur_state = Some(forked_state.clone());
}
forked_state.markers.and(fork.markers);
forked_state.markers = normalize(forked_state.markers)
.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(
for_package.as_deref(),
&version,
@ -726,6 +753,7 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
pins: &mut FilePins,
preferences: &Preferences,
fork_urls: &ForkUrls,
python_requirement: &PythonRequirement,
visited: &mut FxHashSet<PackageName>,
request_sink: &Sender<Request>,
) -> Result<Option<ResolverVersion>, ResolveError> {
@ -746,13 +774,14 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
| PubGrubPackageInner::Dev { name, .. }
| PubGrubPackageInner::Package { 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 {
self.choose_version_registry(
name,
range,
package,
preferences,
python_requirement,
pins,
visited,
request_sink,
@ -769,6 +798,7 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
name: &PackageName,
range: &Range<Version>,
url: &VerbatimParsedUrl,
python_requirement: &PythonRequirement,
) -> Result<Option<ResolverVersion>, ResolveError> {
debug!(
"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.
// STOPSHIP(charlie): Merge markers into `python_requirement`.
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) {
return Ok(Some(ResolverVersion::Unavailable(
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(
version.clone(),
UnavailableVersion::IncompatibleDist(IncompatibleDist::Source(
@ -864,6 +895,7 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
range: &Range<Version>,
package: &PubGrubPackage,
preferences: &Preferences,
python_requirement: &PythonRequirement,
pins: &mut FilePins,
visited: &mut FxHashSet<PackageName>,
request_sink: &Sender<Request>,
@ -932,7 +964,7 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
.requires_python
.as_ref()
.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) {
return Some(IncompatibleDist::Source(
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(
IncompatibleSource::RequiresPython(
requires_python.clone(),
@ -960,7 +992,7 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
.requires_python
.as_ref()
.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) {
return Some(IncompatibleDist::Wheel(
IncompatibleWheel::RequiresPython(
@ -970,7 +1002,7 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
));
}
} else {
if !requires_python.contains(self.python_requirement.installed()) {
if !requires_python.contains(python_requirement.installed()) {
return Some(IncompatibleDist::Wheel(
IncompatibleWheel::RequiresPython(
requires_python.clone(),
@ -1034,8 +1066,9 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
version: &Version,
fork_urls: &ForkUrls,
markers: &MarkerTree,
requires_python: Option<&MarkerTree>,
) -> 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() {
return result.map(|deps| match deps {
Dependencies::Available(deps) => ForkedDependencies::Unforked(deps),
@ -1053,6 +1086,7 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
version: &Version,
fork_urls: &ForkUrls,
markers: &MarkerTree,
requires_python: Option<&MarkerTree>,
) -> Result<Dependencies, ResolveError> {
let url = package.name().and_then(|name| fork_urls.get(name));
let dependencies = match &**package {
@ -1065,6 +1099,7 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
None,
None,
markers,
requires_python,
);
requirements
@ -1193,6 +1228,7 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
dev.as_ref(),
Some(name),
markers,
requires_python,
);
let mut dependencies = requirements
@ -1313,6 +1349,7 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
dev: Option<&'a GroupName>,
name: Option<&PackageName>,
markers: &'a MarkerTree,
requires_python: Option<&'a MarkerTree>,
) -> Vec<Cow<'a, Requirement>> {
// 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
@ -1323,7 +1360,12 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
Either::Right(dependencies.iter())
};
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<_>>();
// 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()) {
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) {
// Add each transitively included extra.
queue.extend(requirement.extras.iter().cloned());
@ -1370,6 +1414,7 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
dependencies: impl IntoIterator<Item = &'data Requirement> + 'parameters,
extra: Option<&'parameters ExtraName>,
markers: &'parameters MarkerTree,
requires_python: Option<&'parameters MarkerTree>,
) -> impl Iterator<Item = Cow<'data, Requirement>> + 'parameters
where
'data: 'parameters,
@ -1379,12 +1424,12 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
.filter(move |requirement| {
// If the requirement would not be selected with any Python version
// supported by the root, skip it.
if !satisfies_requires_python(self.requires_python.as_ref(), requirement) {
if !satisfies_requires_python(requires_python, requirement) {
trace!(
"skipping {requirement} because of Requires-Python {requires_python}",
// OK because this filter only applies when there is a present
// Requires-Python specifier.
requires_python = self.requires_python.as_ref().unwrap()
requires_python = requires_python.unwrap()
);
return false;
}
@ -1428,10 +1473,10 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
.into_iter()
.flatten()
.filter(move |constraint| {
if !satisfies_requires_python(self.requires_python.as_ref(), constraint) {
if !satisfies_requires_python(requires_python, constraint) {
trace!(
"skipping {constraint} because of Requires-Python {requires_python}",
requires_python = self.requires_python.as_ref().unwrap()
requires_python = requires_python.unwrap()
);
return false;
}
@ -1793,6 +1838,21 @@ struct ForkState {
/// that the marker expression that provoked the fork is true), then that
/// dependency is completely ignored.
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 {

View file

@ -1,5 +1,3 @@
use std::collections::Bound;
use anstream::eprint;
use distribution_types::UnresolvedRequirementSpecification;
@ -129,7 +127,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}`).");

View file

@ -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]