From 4438e471cc041fb2fc6a5851c421c1e779d78491 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Mon, 1 Jul 2024 16:20:38 -0400 Subject: [PATCH 1/4] Move requires-python incompatibilities out of version map --- crates/uv-resolver/src/resolver/mod.rs | 78 +++++++++++++++++++-- crates/uv-resolver/src/resolver/provider.rs | 5 -- crates/uv-resolver/src/version_map.rs | 59 +--------------- 3 files changed, 77 insertions(+), 65 deletions(-) diff --git a/crates/uv-resolver/src/resolver/mod.rs b/crates/uv-resolver/src/resolver/mod.rs index 2e69202057a7..e1e3fa3b7221 100644 --- a/crates/uv-resolver/src/resolver/mod.rs +++ b/crates/uv-resolver/src/resolver/mod.rs @@ -23,9 +23,9 @@ use tokio_stream::wrappers::ReceiverStream; use tracing::{debug, enabled, instrument, trace, warn, Level}; use distribution_types::{ - BuiltDist, Dist, DistributionMetadata, IncompatibleDist, IncompatibleSource, IncompatibleWheel, - InstalledDist, PythonRequirementKind, RemoteSource, ResolvedDist, ResolvedDistRef, SourceDist, - VersionOrUrlRef, + BuiltDist, CompatibleDist, Dist, DistributionMetadata, IncompatibleDist, IncompatibleSource, + IncompatibleWheel, InstalledDist, PythonRequirementKind, RemoteSource, ResolvedDist, + ResolvedDistRef, SourceDist, VersionOrUrlRef, }; pub(crate) use locals::Locals; use pep440_rs::{Version, MIN_VERSION}; @@ -155,7 +155,6 @@ impl<'a, Context: BuildContext, InstalledPackages: InstalledPackagesProvider> database, flat_index, tags, - python_requirement.clone(), AllowedYanks::from_manifest(&manifest, markers, options.dependency_mode), hasher, options.exclude_newer, @@ -922,6 +921,77 @@ impl ResolverState None, + CompatibleDist::SourceDist { sdist, .. } + | CompatibleDist::IncompatibleWheel { sdist, .. } => { + // Source distributions must meet both the _target_ Python version and the + // _installed_ Python version (to build successfully). + sdist + .file + .requires_python + .as_ref() + .and_then(|requires_python| { + if let Some(target) = self.python_requirement.target() { + if !target.is_compatible_with(requires_python) { + return Some(IncompatibleDist::Source( + IncompatibleSource::RequiresPython( + requires_python.clone(), + PythonRequirementKind::Target, + ), + )); + } + } + if !requires_python.contains(self.python_requirement.installed()) { + return Some(IncompatibleDist::Source( + IncompatibleSource::RequiresPython( + requires_python.clone(), + PythonRequirementKind::Installed, + ), + )); + } + None + }) + } + CompatibleDist::CompatibleWheel { wheel, .. } => { + // Wheels must meet the _target_ Python version. + wheel + .file + .requires_python + .as_ref() + .and_then(|requires_python| { + if let Some(target) = self.python_requirement.target() { + if !target.is_compatible_with(requires_python) { + return Some(IncompatibleDist::Wheel( + IncompatibleWheel::RequiresPython( + requires_python.clone(), + PythonRequirementKind::Target, + ), + )); + } + } else { + if !requires_python.contains(self.python_requirement.installed()) { + return Some(IncompatibleDist::Wheel( + IncompatibleWheel::RequiresPython( + requires_python.clone(), + PythonRequirementKind::Installed, + ), + )); + } + } + None + }) + } + }; + + // The version is incompatible due to its Python requirement. + if let Some(incompatibility) = incompatibility { + return Ok(Some(ResolverVersion::Unavailable( + candidate.version().clone(), + UnavailableVersion::IncompatibleDist(incompatibility), + ))); + } + let filename = match dist.for_installation() { ResolvedDistRef::InstallableRegistrySourceDist { sdist, .. } => sdist .filename() diff --git a/crates/uv-resolver/src/resolver/provider.rs b/crates/uv-resolver/src/resolver/provider.rs index 3279b32ebcda..e87ccfd827d7 100644 --- a/crates/uv-resolver/src/resolver/provider.rs +++ b/crates/uv-resolver/src/resolver/provider.rs @@ -8,7 +8,6 @@ use uv_normalize::PackageName; use uv_types::{BuildContext, HashStrategy}; use crate::flat_index::FlatIndex; -use crate::python_requirement::PythonRequirement; use crate::version_map::VersionMap; use crate::yanks::AllowedYanks; use crate::ExcludeNewer; @@ -77,7 +76,6 @@ pub struct DefaultResolverProvider<'a, Context: BuildContext> { /// These are the entries from `--find-links` that act as overrides for index responses. flat_index: FlatIndex, tags: Option, - python_requirement: PythonRequirement, allowed_yanks: AllowedYanks, hasher: HashStrategy, exclude_newer: Option, @@ -90,7 +88,6 @@ impl<'a, Context: BuildContext> DefaultResolverProvider<'a, Context> { fetcher: DistributionDatabase<'a, Context>, flat_index: &'a FlatIndex, tags: Option<&'a Tags>, - python_requirement: PythonRequirement, allowed_yanks: AllowedYanks, hasher: &'a HashStrategy, exclude_newer: Option, @@ -100,7 +97,6 @@ impl<'a, Context: BuildContext> DefaultResolverProvider<'a, Context> { fetcher, flat_index: flat_index.clone(), tags: tags.cloned(), - python_requirement, allowed_yanks, hasher: hasher.clone(), exclude_newer, @@ -131,7 +127,6 @@ impl<'a, Context: BuildContext> ResolverProvider for DefaultResolverProvider<'a, package_name, &index, self.tags.as_ref(), - &self.python_requirement, &self.allowed_yanks, &self.hasher, self.exclude_newer.as_ref(), diff --git a/crates/uv-resolver/src/version_map.rs b/crates/uv-resolver/src/version_map.rs index 1ba22eb73a79..8a720bbea199 100644 --- a/crates/uv-resolver/src/version_map.rs +++ b/crates/uv-resolver/src/version_map.rs @@ -8,10 +8,9 @@ use tracing::instrument; use distribution_filename::{DistFilename, WheelFilename}; use distribution_types::{ HashComparison, IncompatibleSource, IncompatibleWheel, IndexUrl, PrioritizedDist, - PythonRequirementKind, RegistryBuiltWheel, RegistrySourceDist, SourceDistCompatibility, - WheelCompatibility, + RegistryBuiltWheel, RegistrySourceDist, SourceDistCompatibility, WheelCompatibility, }; -use pep440_rs::{Version, VersionSpecifiers}; +use pep440_rs::Version; use platform_tags::{TagCompatibility, Tags}; use pypi_types::{HashDigest, Yanked}; use uv_client::{OwnedArchive, SimpleMetadata, VersionFiles}; @@ -21,7 +20,7 @@ use uv_types::HashStrategy; use uv_warnings::warn_user_once; use crate::flat_index::FlatDistributions; -use crate::{python_requirement::PythonRequirement, yanks::AllowedYanks, ExcludeNewer}; +use crate::{yanks::AllowedYanks, ExcludeNewer}; /// A map from versions to distributions. #[derive(Debug)] @@ -45,7 +44,6 @@ impl VersionMap { package_name: &PackageName, index: &IndexUrl, tags: Option<&Tags>, - python_requirement: &PythonRequirement, allowed_yanks: &AllowedYanks, hasher: &HashStrategy, exclude_newer: Option<&ExcludeNewer>, @@ -107,7 +105,6 @@ impl VersionMap { no_build: build_options.no_build_package(package_name), index: index.clone(), tags: tags.cloned(), - python_requirement: python_requirement.clone(), exclude_newer: exclude_newer.copied(), allowed_yanks, required_hashes, @@ -285,10 +282,6 @@ struct VersionMapLazy { /// The set of compatibility tags that determines whether a wheel is usable /// in the current environment. tags: Option, - /// The version of Python active in the current environment. This is used - /// to determine whether a package's Python version constraint (if one - /// exists) is satisfied or not. - python_requirement: PythonRequirement, /// Whether files newer than this timestamp should be excluded or not. exclude_newer: Option, /// Which yanked versions are allowed @@ -369,7 +362,6 @@ impl VersionMapLazy { // Prioritize amongst all available files. let version = filename.version().clone(); - let requires_python = file.requires_python.clone(); let yanked = file.yanked.clone(); let hashes = file.hashes.clone(); match filename { @@ -377,7 +369,6 @@ impl VersionMapLazy { let compatibility = self.wheel_compatibility( &filename, &version, - requires_python, &hashes, yanked, excluded, @@ -393,7 +384,6 @@ impl VersionMapLazy { DistFilename::SourceDistFilename(filename) => { let compatibility = self.source_dist_compatibility( &version, - requires_python, &hashes, yanked, excluded, @@ -422,7 +412,6 @@ impl VersionMapLazy { fn source_dist_compatibility( &self, version: &Version, - requires_python: Option, hashes: &[HashDigest], yanked: Option, excluded: bool, @@ -447,28 +436,6 @@ impl VersionMapLazy { } } - // Check if Python version is supported - // Source distributions must meet both the _target_ Python version and the - // _installed_ Python version (to build successfully) - if let Some(requires_python) = requires_python { - if let Some(target) = self.python_requirement.target() { - if !target.is_compatible_with(&requires_python) { - return SourceDistCompatibility::Incompatible( - IncompatibleSource::RequiresPython( - requires_python, - PythonRequirementKind::Target, - ), - ); - } - } - if !requires_python.contains(self.python_requirement.installed()) { - return SourceDistCompatibility::Incompatible(IncompatibleSource::RequiresPython( - requires_python, - PythonRequirementKind::Installed, - )); - } - } - // Check if hashes line up. If hashes aren't required, they're considered matching. let hash = if self.required_hashes.is_empty() { HashComparison::Matched @@ -492,7 +459,6 @@ impl VersionMapLazy { &self, filename: &WheelFilename, version: &Version, - requires_python: Option, hashes: &[HashDigest], yanked: Option, excluded: bool, @@ -515,25 +481,6 @@ impl VersionMapLazy { } } - // Check for a Python version incompatibility - if let Some(requires_python) = requires_python { - if let Some(target) = self.python_requirement.target() { - if !target.is_compatible_with(&requires_python) { - return WheelCompatibility::Incompatible(IncompatibleWheel::RequiresPython( - requires_python, - PythonRequirementKind::Target, - )); - } - } else { - if !requires_python.contains(self.python_requirement.installed()) { - return WheelCompatibility::Incompatible(IncompatibleWheel::RequiresPython( - requires_python, - PythonRequirementKind::Installed, - )); - } - } - } - // Determine a compatibility for the wheel based on tags. let priority = match &self.tags { Some(tags) => match filename.compatibility(tags) { From 7922cbbb7d6607ff20ac1306de7f4c45fe1c9d64 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Mon, 1 Jul 2024 13:03:52 -0400 Subject: [PATCH 2/4] Make dyanmic Respect fork markers Also narrow on dependencies --- crates/pep440-rs/src/version_specifier.rs | 4 +- crates/uv-resolver/src/lib.rs | 2 +- crates/uv-resolver/src/marker.rs | 40 ++++- crates/uv-resolver/src/pubgrub/package.rs | 7 +- crates/uv-resolver/src/python_requirement.rs | 14 +- crates/uv-resolver/src/requires_python.rs | 75 ++++++++-- crates/uv-resolver/src/resolver/mod.rs | 148 +++++++++++++++---- req.in | 2 + 8 files changed, 238 insertions(+), 54 deletions(-) create mode 100644 req.in diff --git a/crates/pep440-rs/src/version_specifier.rs b/crates/pep440-rs/src/version_specifier.rs index a462fb770745..09f589ab516d 100644 --- a/crates/pep440-rs/src/version_specifier.rs +++ b/crates/pep440-rs/src/version_specifier.rs @@ -436,7 +436,7 @@ impl VersionSpecifier { } /// Returns a version specifier representing the given lower bound. - fn from_lower_bound(bound: &Bound) -> Option { + pub fn from_lower_bound(bound: &Bound) -> Option { 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) -> Option { + pub fn from_upper_bound(bound: &Bound) -> Option { match bound { Bound::Included(version) => Some( VersionSpecifier::from_version(Operator::LessThanEqual, version.clone()).unwrap(), diff --git a/crates/uv-resolver/src/lib.rs b/crates/uv-resolver/src/lib.rs index 6c1be8ffe5f4..353305bd1472 100644 --- a/crates/uv-resolver/src/lib.rs +++ b/crates/uv-resolver/src/lib.rs @@ -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::{ diff --git a/crates/uv-resolver/src/marker.rs b/crates/uv-resolver/src/marker.rs index 3c836779d3ec..182f3188a837 100644 --- a/crates/uv-resolver/src/marker.rs +++ b/crates/uv-resolver/src/marker.rs @@ -1,5 +1,6 @@ #![allow(clippy::enum_glob_use)] +use std::cmp::Ordering; use std::collections::HashMap; use std::ops::Bound::{self, *}; use std::ops::RangeBounds; @@ -11,7 +12,8 @@ use pep508_rs::{ }; use crate::pubgrub::PubGrubSpecifier; -use pubgrub::range::Range as PubGrubRange; +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. @@ -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 { + 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 = Range::from(specifier); + let (lower, _) = range.iter().next()?; + + // Extract the lower bound. + Some(RequiresPythonBound::new(lower.clone())) + } + MarkerTree::And(trees) => { + // Take the minimum of any nested expressions. + trees.iter().filter_map(requires_python_marker).min() + } + 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), + }; + } + version + } + MarkerTree::Expression(_) => None, + } +} + /// Normalizes this marker tree. /// /// This function does a number of operations to normalize a marker tree recursively: diff --git a/crates/uv-resolver/src/pubgrub/package.rs b/crates/uv-resolver/src/pubgrub/package.rs index 350960148325..e677b4d0d4be 100644 --- a/crates/uv-resolver/src/pubgrub/package.rs +++ b/crates/uv-resolver/src/pubgrub/package.rs @@ -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) } } diff --git a/crates/uv-resolver/src/python_requirement.rs b/crates/uv-resolver/src/python_requirement.rs index bc262f9ff9fe..ad8709b119ca 100644 --- a/crates/uv-resolver/src/python_requirement.rs +++ b/crates/uv-resolver/src/python_requirement.rs @@ -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,18 @@ impl PythonRequirement { } } + /// Narrow the [`PythonRequirement`] to the given version. + pub fn narrow(&self, target: RequiresPythonBound) -> Option { + 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 diff --git a/crates/uv-resolver/src/requires_python.rs b/crates/uv-resolver/src/requires_python.rs index 48a4588babe0..51d917389517 100644 --- a/crates/uv-resolver/src/requires_python.rs +++ b/crates/uv-resolver/src/requires_python.rs @@ -1,3 +1,4 @@ +use std::cmp::Ordering; use std::collections::Bound; use itertools::Itertools; @@ -24,17 +25,27 @@ pub enum RequiresPythonError { #[derive(Debug, Clone, Eq, PartialEq, Hash)] pub struct RequiresPython { specifiers: VersionSpecifiers, - bound: Bound, + bound: RequiresPythonBound, } 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 { specifiers: VersionSpecifiers::from(VersionSpecifier::greater_than_equal_version( version.clone(), )), - bound: Bound::Included(version), + bound: RequiresPythonBound(Bound::Included(version)), } } @@ -61,11 +72,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 @@ -140,7 +153,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.0) { (Bound::Included(target_lower), Bound::Included(requires_python_lower)) => { target_lower.release() <= requires_python_lower.release() } @@ -168,7 +181,7 @@ impl RequiresPython { /// Returns the lower [`Bound`] for the `Requires-Python` specifier. pub fn bound(&self) -> &Bound { - &self.bound + &self.bound.0 } /// Returns this `Requires-Python` specifier as an equivalent marker @@ -184,7 +197,7 @@ 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.0 { // 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 @@ -247,12 +260,42 @@ impl serde::Serialize for RequiresPython { impl<'de> serde::Deserialize<'de> for RequiresPython { fn deserialize>(deserializer: D) -> Result { 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); + +impl RequiresPythonBound { + pub fn new(bound: Bound) -> Self { + Self(bound) + } +} + +impl PartialOrd for RequiresPythonBound { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for RequiresPythonBound { + fn cmp(&self, other: &Self) -> Ordering { + match (&self.0, &other.0) { + (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, + } + } +} diff --git a/crates/uv-resolver/src/resolver/mod.rs b/crates/uv-resolver/src/resolver/mod.rs index e1e3fa3b7221..e06c12c931bd 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, + ResolvedDistRef, SourceDist, VersionOrUrlRef, WheelCompatibility, }; pub(crate) use locals::Locals; use pep440_rs::{Version, MIN_VERSION}; @@ -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 ResolverState, request_sink: Sender, ) -> Result { + 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 ResolverState ResolverState ResolverState ResolverState { @@ -547,7 +576,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 @@ -562,10 +591,32 @@ impl ResolverState ResolverState, request_sink: &Sender, ) -> Result, ResolveError> { @@ -746,13 +798,14 @@ impl ResolverState { 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 +822,7 @@ impl ResolverState, url: &VerbatimParsedUrl, + python_requirement: &PythonRequirement, ) -> Result, ResolveError> { debug!( "Searching for a compatible version of {name} @ {} ({range})", @@ -826,8 +880,9 @@ impl ResolverState ResolverState ResolverState, package: &PubGrubPackage, preferences: &Preferences, + python_requirement: &PythonRequirement, pins: &mut FilePins, visited: &mut FxHashSet, request_sink: &Sender, @@ -932,7 +988,7 @@ impl ResolverState ResolverState ResolverState ResolverState ResolverState, ) -> Result { - 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 +1110,7 @@ impl ResolverState, ) -> Result { let url = package.name().and_then(|name| fork_urls.get(name)); let dependencies = match &**package { @@ -1065,6 +1123,7 @@ impl ResolverState ResolverState ResolverState, name: Option<&PackageName>, markers: &'a MarkerTree, + requires_python: Option<&'a MarkerTree>, ) -> Vec> { // 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 +1384,12 @@ impl ResolverState>(); // Check if there are recursive self inclusions and we need to go into the expensive branch. @@ -1346,7 +1412,9 @@ impl ResolverState ResolverState + 'parameters, extra: Option<&'parameters ExtraName>, markers: &'parameters MarkerTree, + requires_python: Option<&'parameters MarkerTree>, ) -> impl Iterator> + 'parameters where 'data: 'parameters, @@ -1379,12 +1448,12 @@ impl ResolverState ResolverState, } impl ForkState { @@ -2362,7 +2434,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(); @@ -2540,7 +2612,18 @@ impl<'a> PossibleForks<'a> { let PossibleForks::PossiblyForking(ref fork_groups) = *self else { return false; }; - fork_groups.forks.len() > 1 + 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 } /// Consumes this possible set of forks and converts a "possibly forking" @@ -2553,7 +2636,14 @@ impl<'a> PossibleForks<'a> { let PossibleForks::PossiblyForking(ref fork_groups) = self else { return self; }; - if fork_groups.forks.len() == 1 { + 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()) + }) + { self.make_no_forks_possible(); return self; } diff --git a/req.in b/req.in new file mode 100644 index 000000000000..f9a855472488 --- /dev/null +++ b/req.in @@ -0,0 +1,2 @@ +numpy >=1.26; python_version>="3.9" +numpy <1.26; python_version<"3.9" From b3797db96ed60b391abfb3908e852a70b51040c6 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Mon, 1 Jul 2024 16:29:43 -0400 Subject: [PATCH 3/4] Remove extra forking --- crates/uv-resolver/src/marker.rs | 26 +++--- crates/uv-resolver/src/python_requirement.rs | 5 +- crates/uv-resolver/src/requires_python.rs | 47 ++++++---- crates/uv-resolver/src/resolver/mod.rs | 98 +++++++------------- crates/uv/src/commands/project/lock.rs | 4 +- crates/uv/tests/pip_compile.rs | 35 +++++++ req.in | 2 - 7 files changed, 113 insertions(+), 104 deletions(-) delete mode 100644 req.in 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" From 135fe791f74b22fb5bba50bed0c7bce25d3b8415 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Mon, 1 Jul 2024 17:23:43 -0400 Subject: [PATCH 4/4] Fork resolution when Python requirement is narrowed --- crates/uv-resolver/src/resolver/mod.rs | 25 +++++++++++++++---- crates/uv/tests/branching_urls.rs | 26 +++----------------- crates/uv/tests/pip_compile.rs | 34 +++++++++++++++++++++++++- 3 files changed, 57 insertions(+), 28 deletions(-) diff --git a/crates/uv-resolver/src/resolver/mod.rs b/crates/uv-resolver/src/resolver/mod.rs index 411e190f5a01..c1b9f8f2d68a 100644 --- a/crates/uv-resolver/src/resolver/mod.rs +++ b/crates/uv-resolver/src/resolver/mod.rs @@ -555,7 +555,7 @@ impl ResolverState= 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 @@ -2422,7 +2422,6 @@ impl Dependencies { continue; } }; - 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(); @@ -2600,7 +2599,7 @@ impl<'a> PossibleForks<'a> { let PossibleForks::PossiblyForking(ref fork_groups) = *self else { return false; }; - fork_groups.forks.len() > 1 + fork_groups.has_fork() } /// Consumes this possible set of forks and converts a "possibly forking" @@ -2613,9 +2612,8 @@ impl<'a> PossibleForks<'a> { let PossibleForks::PossiblyForking(ref fork_groups) = self else { return self; }; - if fork_groups.forks.len() == 1 { + if !fork_groups.has_fork() { self.make_no_forks_possible(); - return self; } self } @@ -2678,6 +2676,23 @@ impl<'a> PossibleForkGroups<'a> { .iter_mut() .find(|fork| fork.is_overlapping(marker)) } + + /// Returns `true` if the fork group has a fork. + fn has_fork(&self) -> bool { + if self.forks.len() > 1 { + return true; + } + + if self.forks.iter().any(|fork| { + fork.packages + .iter() + .any(|(_, markers)| requires_python_marker(markers).is_some()) + }) { + return true; + } + + false + } } /// Intermediate state representing a single possible fork. diff --git a/crates/uv/tests/branching_urls.rs b/crates/uv/tests/branching_urls.rs index c0858105fabd..6e7518243856 100644 --- a/crates/uv/tests/branching_urls.rs +++ b/crates/uv/tests/branching_urls.rs @@ -90,7 +90,7 @@ fn branching_urls_overlapping() -> Result<()> { ----- stdout ----- ----- stderr ----- - error: Requirements contain conflicting URLs for package `iniconfig`: + error: Requirements contain conflicting URLs for package `iniconfig` in split `python_version < '3.12' and python_version >= '3.11'`: - https://files.pythonhosted.org/packages/9b/dd/b3c12c6d707058fa947864b67f0c4e0c39ef8610988d7baea9578f3c48f3/iniconfig-1.1.1-py2.py3-none-any.whl - https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl "### @@ -381,7 +381,7 @@ fn root_package_splits_other_dependencies_too() -> Result<()> { ----- stdout ----- ----- stderr ----- - Resolved 9 packages in [TIME] + Resolved 7 packages in [TIME] "### ); @@ -397,7 +397,6 @@ fn root_package_splits_other_dependencies_too() -> Result<()> { { name = "anyio", version = "4.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_version < '3.12'" }, { name = "anyio", version = "4.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_version >= '3.12'" }, { name = "b1", marker = "python_version < '3.12'" }, - { name = "b2", marker = "python_version >= '3.12'" }, ] [[distribution]] @@ -431,15 +430,7 @@ fn root_package_splits_other_dependencies_too() -> Result<()> { version = "0.1.0" source = { directory = "b1" } dependencies = [ - { name = "iniconfig", version = "1.1.1", source = { registry = "https://pypi.org/simple" } }, - ] - - [[distribution]] - name = "b2" - version = "0.1.0" - source = { directory = "b2" } - dependencies = [ - { name = "iniconfig", version = "2.0.0", source = { registry = "https://pypi.org/simple" } }, + { name = "iniconfig" }, ] [[distribution]] @@ -460,15 +451,6 @@ fn root_package_splits_other_dependencies_too() -> Result<()> { { url = "https://files.pythonhosted.org/packages/9b/dd/b3c12c6d707058fa947864b67f0c4e0c39ef8610988d7baea9578f3c48f3/iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3", size = 4990 }, ] - [[distribution]] - name = "iniconfig" - version = "2.0.0" - source = { registry = "https://pypi.org/simple" } - sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 } - wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 }, - ] - [[distribution]] name = "sniffio" version = "1.3.1" @@ -637,7 +619,7 @@ fn branching_urls_of_different_sources_conflict() -> Result<()> { ----- stdout ----- ----- stderr ----- - error: Requirements contain conflicting URLs for package `iniconfig`: + error: Requirements contain conflicting URLs for package `iniconfig` in split `python_version < '3.12' and python_version >= '3.11'`: - git+https://github.com/pytest-dev/iniconfig@93f5930e668c0d1ddf4597e38dd0dea4e2665e7a - https://files.pythonhosted.org/packages/9b/dd/b3c12c6d707058fa947864b67f0c4e0c39ef8610988d7baea9578f3c48f3/iniconfig-1.1.1-py2.py3-none-any.whl "### diff --git a/crates/uv/tests/pip_compile.rs b/crates/uv/tests/pip_compile.rs index cc557a6a6f05..6cdf82c83d84 100644 --- a/crates/uv/tests/pip_compile.rs +++ b/crates/uv/tests/pip_compile.rs @@ -6567,7 +6567,7 @@ fn universal_multi_version() -> Result<()> { /// Perform a universal resolution that requires narrowing the supported Python range in one of the /// fork branches. #[test] -fn universal_requires_python() -> Result<()> { +fn universal_requires_python_fork() -> Result<()> { let context = TestContext::new("3.12"); let requirements_in = context.temp_dir.child("requirements.in"); requirements_in.write_str(indoc::indoc! {r" @@ -6599,6 +6599,38 @@ fn universal_requires_python() -> Result<()> { Ok(()) } +/// Perform a universal resolution that requires narrowing the supported Python range without +/// forking. +#[test] +fn universal_requires_python_non_fork() -> 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' + "})?; + + 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.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 1 package 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]