From c8ac8ee57aa51d544d00af42d8ea96a282116532 Mon Sep 17 00:00:00 2001 From: Ibraheem Ahmed Date: Tue, 23 Jul 2024 11:57:14 -0400 Subject: [PATCH] Allow conflicting prerelease strategies when forking (#5150) ## Summary Similar to https://github.com/astral-sh/uv/pull/5232, we should also track prerelease strategies per-fork, instead of globally per package. The common functionality for tracking locals and prerelease versions across forks is extracted into the `ForkMap` type. Resolves https://github.com/astral-sh/uv/issues/4579. This doesn't quite solve https://github.com/astral-sh/uv/issues/4959, as that issue relies on overlapping markers. --- crates/uv-resolver/src/candidate_selector.rs | 89 ++++--- crates/uv-resolver/src/error.rs | 1 + crates/uv-resolver/src/prerelease_mode.rs | 101 ++++---- crates/uv-resolver/src/pubgrub/report.rs | 15 +- .../src/resolver/batch_prefetch.rs | 7 +- crates/uv-resolver/src/resolver/fork_map.rs | 84 +++++++ crates/uv-resolver/src/resolver/locals.rs | 47 +--- crates/uv-resolver/src/resolver/mod.rs | 13 +- crates/uv/tests/pip_compile.rs | 226 +++++++++++++++++- 9 files changed, 450 insertions(+), 133 deletions(-) create mode 100644 crates/uv-resolver/src/resolver/fork_map.rs diff --git a/crates/uv-resolver/src/candidate_selector.rs b/crates/uv-resolver/src/candidate_selector.rs index 6071162372f0..061148bae19a 100644 --- a/crates/uv-resolver/src/candidate_selector.rs +++ b/crates/uv-resolver/src/candidate_selector.rs @@ -12,10 +12,10 @@ use uv_normalize::PackageName; use uv_types::InstalledPackagesProvider; use crate::preferences::Preferences; -use crate::prerelease_mode::PreReleaseStrategy; +use crate::prerelease_mode::{AllowPreRelease, PreReleaseStrategy}; use crate::resolution_mode::ResolutionStrategy; use crate::version_map::{VersionMap, VersionMapDistHandle}; -use crate::{Exclusions, Manifest, Options}; +use crate::{Exclusions, Manifest, Options, ResolverMarkers}; #[derive(Debug, Clone)] #[allow(clippy::struct_field_names)] @@ -68,13 +68,6 @@ impl CandidateSelector { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] -enum AllowPreRelease { - Yes, - No, - IfNecessary, -} - impl CandidateSelector { /// Select a [`Candidate`] from a set of candidate versions and files. /// @@ -89,35 +82,52 @@ impl CandidateSelector { preferences: &'a Preferences, installed_packages: &'a InstalledPackages, exclusions: &'a Exclusions, + markers: &ResolverMarkers, ) -> Option> { - if let Some(preferred) = Self::get_preferred( + if let Some(preferred) = self.get_preferred( package_name, range, version_maps, preferences, installed_packages, exclusions, + markers, ) { return Some(preferred); } - self.select_no_preference(package_name, range, version_maps) + self.select_no_preference(package_name, range, version_maps, markers) } /// Get a preferred version if one exists. This is the preference from a lockfile or a locally /// installed version. fn get_preferred<'a, InstalledPackages: InstalledPackagesProvider>( + &self, package_name: &'a PackageName, range: &Range, version_maps: &'a [VersionMap], preferences: &'a Preferences, installed_packages: &'a InstalledPackages, exclusions: &'a Exclusions, + markers: &ResolverMarkers, ) -> Option> { // If the package has a preference (e.g., an existing version from an existing lockfile), // and the preference satisfies the current range, use that. if let Some(version) = preferences.version(package_name) { - if range.contains(version) { + 'preference: { + // Respect the version range for this requirement. + if !range.contains(version) { + break 'preference; + } + + // Respect the pre-release strategy for this fork. + if version.any_prerelease() + && self.prerelease_strategy.allows(package_name, markers) + != AllowPreRelease::Yes + { + break 'preference; + } + // Check for a locally installed distribution that matches the preferred version if !exclusions.contains(package_name) { let installed_dists = installed_packages.get_packages(package_name); @@ -167,16 +177,27 @@ impl CandidateSelector { [] => {} [dist] => { let version = dist.version(); - if range.contains(version) { - debug!("Found installed version of {dist} that satisfies {range}"); - - return Some(Candidate { - name: package_name, - version, - dist: CandidateDist::Compatible(CompatibleDist::InstalledDist(dist)), - choice_kind: VersionChoiceKind::Installed, - }); + + // Respect the version range for this requirement. + if !range.contains(version) { + return None; } + + // Respect the pre-release strategy for this fork. + if version.any_prerelease() + && self.prerelease_strategy.allows(package_name, markers) + != AllowPreRelease::Yes + { + return None; + } + + debug!("Found installed version of {dist} that satisfies {range}"); + return Some(Candidate { + name: package_name, + version, + dist: CandidateDist::Compatible(CompatibleDist::InstalledDist(dist)), + choice_kind: VersionChoiceKind::Installed, + }); } // We do not consider installed distributions with multiple versions because // during installation these must be reinstalled from the remote @@ -189,29 +210,6 @@ impl CandidateSelector { None } - /// Determine the appropriate prerelease strategy for the current package. - fn allow_prereleases(&self, package_name: &PackageName) -> AllowPreRelease { - match &self.prerelease_strategy { - PreReleaseStrategy::Disallow => AllowPreRelease::No, - PreReleaseStrategy::Allow => AllowPreRelease::Yes, - PreReleaseStrategy::IfNecessary => AllowPreRelease::IfNecessary, - PreReleaseStrategy::Explicit(packages) => { - if packages.contains(package_name) { - AllowPreRelease::Yes - } else { - AllowPreRelease::No - } - } - PreReleaseStrategy::IfNecessaryOrExplicit(packages) => { - if packages.contains(package_name) { - AllowPreRelease::Yes - } else { - AllowPreRelease::IfNecessary - } - } - } - } - /// Select a [`Candidate`] without checking for version preference such as an existing /// lockfile. pub(crate) fn select_no_preference<'a>( @@ -219,13 +217,14 @@ impl CandidateSelector { package_name: &'a PackageName, range: &Range, version_maps: &'a [VersionMap], + markers: &ResolverMarkers, ) -> Option { tracing::trace!( "selecting candidate for package {package_name} with range {range:?} with {} remote versions", version_maps.iter().map(VersionMap::len).sum::(), ); let highest = self.use_highest_version(package_name); - let allow_prerelease = self.allow_prereleases(package_name); + let allow_prerelease = self.prerelease_strategy.allows(package_name, markers); if self.index_strategy == IndexStrategy::UnsafeBestMatch { if highest { diff --git a/crates/uv-resolver/src/error.rs b/crates/uv-resolver/src/error.rs index d869a7fd2996..b96b32633334 100644 --- a/crates/uv-resolver/src/error.rs +++ b/crates/uv-resolver/src/error.rs @@ -232,6 +232,7 @@ impl std::fmt::Display for NoSolutionError { &self.unavailable_packages, &self.incomplete_packages, &self.fork_urls, + &self.markers, ) { write!(f, "\n\n{hint}")?; } diff --git a/crates/uv-resolver/src/prerelease_mode.rs b/crates/uv-resolver/src/prerelease_mode.rs index a04b127f2c41..05578d773ecc 100644 --- a/crates/uv-resolver/src/prerelease_mode.rs +++ b/crates/uv-resolver/src/prerelease_mode.rs @@ -1,10 +1,10 @@ use pypi_types::RequirementSource; -use rustc_hash::FxHashSet; use pep508_rs::MarkerEnvironment; use uv_normalize::PackageName; -use crate::{DependencyMode, Manifest}; +use crate::resolver::ForkSet; +use crate::{DependencyMode, Manifest, ResolverMarkers}; #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, serde::Deserialize)] #[serde(deny_unknown_fields, rename_all = "kebab-case")] @@ -57,11 +57,11 @@ pub(crate) enum PreReleaseStrategy { /// Allow pre-release versions for first-party packages with explicit pre-release markers in /// their version requirements. - Explicit(FxHashSet), + Explicit(ForkSet), /// Allow pre-release versions if all versions of a package are pre-release, or if the package /// has an explicit pre-release marker in its version requirements. - IfNecessaryOrExplicit(FxHashSet), + IfNecessaryOrExplicit(ForkSet), } impl PreReleaseStrategy { @@ -71,51 +71,72 @@ impl PreReleaseStrategy { markers: Option<&MarkerEnvironment>, dependencies: DependencyMode, ) -> Self { + let mut packages = ForkSet::default(); + match mode { PreReleaseMode::Disallow => Self::Disallow, PreReleaseMode::Allow => Self::Allow, PreReleaseMode::IfNecessary => Self::IfNecessary, - PreReleaseMode::Explicit => Self::Explicit( - manifest - .requirements(markers, dependencies) - .filter(|requirement| { - let RequirementSource::Registry { specifier, .. } = &requirement.source - else { - return false; - }; - specifier - .iter() - .any(pep440_rs::VersionSpecifier::any_prerelease) - }) - .map(|requirement| requirement.name.clone()) - .collect(), - ), - PreReleaseMode::IfNecessaryOrExplicit => Self::IfNecessaryOrExplicit( - manifest - .requirements(markers, dependencies) - .filter(|requirement| { - let RequirementSource::Registry { specifier, .. } = &requirement.source - else { - return false; - }; - specifier - .iter() - .any(pep440_rs::VersionSpecifier::any_prerelease) - }) - .map(|requirement| requirement.name.clone()) - .collect(), - ), + _ => { + for requirement in manifest.requirements(markers, dependencies) { + let RequirementSource::Registry { specifier, .. } = &requirement.source else { + continue; + }; + + if specifier + .iter() + .any(pep440_rs::VersionSpecifier::any_prerelease) + { + packages.add(&requirement, ()); + } + } + + match mode { + PreReleaseMode::Explicit => Self::Explicit(packages), + PreReleaseMode::IfNecessaryOrExplicit => Self::IfNecessaryOrExplicit(packages), + _ => unreachable!(), + } + } } } /// Returns `true` if a [`PackageName`] is allowed to have pre-release versions. - pub(crate) fn allows(&self, package: &PackageName) -> bool { + pub(crate) fn allows( + &self, + package_name: &PackageName, + markers: &ResolverMarkers, + ) -> AllowPreRelease { match self { - Self::Disallow => false, - Self::Allow => true, - Self::IfNecessary => false, - Self::Explicit(packages) => packages.contains(package), - Self::IfNecessaryOrExplicit(packages) => packages.contains(package), + PreReleaseStrategy::Disallow => AllowPreRelease::No, + PreReleaseStrategy::Allow => AllowPreRelease::Yes, + PreReleaseStrategy::IfNecessary => AllowPreRelease::IfNecessary, + PreReleaseStrategy::Explicit(packages) => { + if packages.contains(package_name, markers) { + AllowPreRelease::Yes + } else { + AllowPreRelease::No + } + } + PreReleaseStrategy::IfNecessaryOrExplicit(packages) => { + if packages.contains(package_name, markers) { + AllowPreRelease::Yes + } else { + AllowPreRelease::IfNecessary + } + } } } } + +/// The pre-release strategy for a given package. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) enum AllowPreRelease { + /// Allow all pre-release versions. + Yes, + + /// Disallow all pre-release versions. + No, + + /// Allow pre-release versions if all versions of this package are pre-release. + IfNecessary, +} diff --git a/crates/uv-resolver/src/pubgrub/report.rs b/crates/uv-resolver/src/pubgrub/report.rs index 1747dffcb79a..d2fe410eca5c 100644 --- a/crates/uv-resolver/src/pubgrub/report.rs +++ b/crates/uv-resolver/src/pubgrub/report.rs @@ -18,9 +18,10 @@ use uv_normalize::PackageName; use crate::candidate_selector::CandidateSelector; use crate::fork_urls::ForkUrls; +use crate::prerelease_mode::AllowPreRelease; use crate::python_requirement::{PythonRequirement, PythonTarget}; use crate::resolver::{IncompletePackage, UnavailablePackage, UnavailableReason}; -use crate::RequiresPython; +use crate::{RequiresPython, ResolverMarkers}; use super::{PubGrubPackage, PubGrubPackageInner, PubGrubPython}; @@ -407,6 +408,7 @@ impl PubGrubReportFormatter<'_> { unavailable_packages: &FxHashMap, incomplete_packages: &FxHashMap>, fork_urls: &ForkUrls, + markers: &ResolverMarkers, ) -> IndexSet { let mut hints = IndexSet::default(); match derivation_tree { @@ -416,7 +418,9 @@ impl PubGrubReportFormatter<'_> { if let PubGrubPackageInner::Package { name, .. } = &**package { // Check for no versions due to pre-release options. if !fork_urls.contains_key(name) { - self.prerelease_available_hint(package, name, set, selector, &mut hints); + self.prerelease_available_hint( + package, name, set, selector, markers, &mut hints, + ); } } @@ -465,6 +469,7 @@ impl PubGrubReportFormatter<'_> { unavailable_packages, incomplete_packages, fork_urls, + markers, )); hints.extend(self.hints( &derived.cause2, @@ -473,6 +478,7 @@ impl PubGrubReportFormatter<'_> { unavailable_packages, incomplete_packages, fork_urls, + markers, )); } } @@ -569,6 +575,7 @@ impl PubGrubReportFormatter<'_> { name: &PackageName, set: &Range, selector: &CandidateSelector, + markers: &ResolverMarkers, hints: &mut IndexSet, ) { let any_prerelease = set.iter().any(|(start, end)| { @@ -587,7 +594,7 @@ impl PubGrubReportFormatter<'_> { if any_prerelease { // A pre-release marker appeared in the version requirements. - if !selector.prerelease_strategy().allows(name) { + if selector.prerelease_strategy().allows(name, markers) != AllowPreRelease::Yes { hints.insert(PubGrubHint::PreReleaseRequested { package: package.clone(), range: self.simplify_set(set, package).into_owned(), @@ -601,7 +608,7 @@ impl PubGrubReportFormatter<'_> { .find(|version| set.contains(version)) }) { // There are pre-release versions available for the package. - if !selector.prerelease_strategy().allows(name) { + if selector.prerelease_strategy().allows(name, markers) != AllowPreRelease::Yes { hints.insert(PubGrubHint::PreReleaseAvailable { package: package.clone(), version: version.clone(), diff --git a/crates/uv-resolver/src/resolver/batch_prefetch.rs b/crates/uv-resolver/src/resolver/batch_prefetch.rs index 24de36a7c504..67ed36f7f9a1 100644 --- a/crates/uv-resolver/src/resolver/batch_prefetch.rs +++ b/crates/uv-resolver/src/resolver/batch_prefetch.rs @@ -12,7 +12,7 @@ use pep440_rs::Version; use crate::candidate_selector::CandidateSelector; use crate::pubgrub::{PubGrubPackage, PubGrubPackageInner}; use crate::resolver::Request; -use crate::{InMemoryIndex, PythonRequirement, ResolveError, VersionsResponse}; +use crate::{InMemoryIndex, PythonRequirement, ResolveError, ResolverMarkers, VersionsResponse}; enum BatchPrefetchStrategy { /// Go through the next versions assuming the existing selection and its constraints @@ -53,6 +53,7 @@ impl BatchPrefetcher { request_sink: &Sender, index: &InMemoryIndex, selector: &CandidateSelector, + markers: &ResolverMarkers, ) -> anyhow::Result<(), ResolveError> { let PubGrubPackageInner::Package { name, @@ -92,7 +93,7 @@ impl BatchPrefetcher { previous, } => { if let Some(candidate) = - selector.select_no_preference(name, &compatible, version_map) + selector.select_no_preference(name, &compatible, version_map, markers) { let compatible = compatible.intersection( &Range::singleton(candidate.version().clone()).complement(), @@ -116,7 +117,7 @@ impl BatchPrefetcher { Range::strictly_higher_than(previous) }; if let Some(candidate) = - selector.select_no_preference(name, &range, version_map) + selector.select_no_preference(name, &range, version_map, markers) { phase = BatchPrefetchStrategy::InOrder { previous: candidate.version().clone(), diff --git a/crates/uv-resolver/src/resolver/fork_map.rs b/crates/uv-resolver/src/resolver/fork_map.rs new file mode 100644 index 000000000000..b61017d1a026 --- /dev/null +++ b/crates/uv-resolver/src/resolver/fork_map.rs @@ -0,0 +1,84 @@ +use pep508_rs::{MarkerTree, PackageName}; +use pypi_types::Requirement; +use rustc_hash::FxHashMap; + +use crate::marker::is_disjoint; +use crate::ResolverMarkers; + +/// A set of package names associated with a given fork. +pub(crate) type ForkSet = ForkMap<()>; + +/// A map from package names to their values for a given fork. +#[derive(Debug, Clone)] +pub(crate) struct ForkMap(FxHashMap>>); + +/// An entry in a [`ForkMap`]. +#[derive(Debug, Clone)] +struct Entry { + value: T, + marker: Option, +} + +impl Default for ForkMap { + fn default() -> Self { + Self(FxHashMap::default()) + } +} + +impl ForkMap { + /// Associate a value with the [`Requirement`] in a given fork. + pub(crate) fn add(&mut self, requirement: &Requirement, value: T) { + let entry = Entry { + value, + marker: requirement.marker.clone(), + }; + + self.0 + .entry(requirement.name.clone()) + .or_default() + .push(entry); + } + + /// Returns `true` if the map contains any values for a package that are compatible with the + /// given fork. + pub(crate) fn contains(&self, package_name: &PackageName, markers: &ResolverMarkers) -> bool { + !self.get(package_name, markers).is_empty() + } + + /// Returns a list of values associated with a package that are compatible with the given fork. + /// + /// Compatibility implies that the markers on the requirement that contained this value + /// are not disjoint with the given fork. Note that this does not imply that the requirement + /// diverged in the given fork - values from overlapping forks may be combined. + pub(crate) fn get(&self, package_name: &PackageName, markers: &ResolverMarkers) -> Vec<&T> { + let Some(values) = self.0.get(package_name) else { + return Vec::new(); + }; + + match markers { + // If we are solving for a specific environment we already filtered + // compatible requirements `from_manifest`. + ResolverMarkers::SpecificEnvironment(_) => values + .first() + .map(|entry| &entry.value) + .into_iter() + .collect(), + + // Return all values that were requested with markers that are compatible + // with the current fork, i.e. the markers are not disjoint. + ResolverMarkers::Fork(fork) => values + .iter() + .filter(|entry| { + !entry + .marker + .as_ref() + .is_some_and(|marker| is_disjoint(fork, marker)) + }) + .map(|entry| &entry.value) + .collect(), + + // If we haven't forked yet, all values are potentially compatible. + ResolverMarkers::Universal => values.iter().map(|entry| &entry.value).collect(), + } + } +} diff --git a/crates/uv-resolver/src/resolver/locals.rs b/crates/uv-resolver/src/resolver/locals.rs index 7e1a09a7681d..e2e034603535 100644 --- a/crates/uv-resolver/src/resolver/locals.rs +++ b/crates/uv-resolver/src/resolver/locals.rs @@ -3,15 +3,15 @@ use std::str::FromStr; use distribution_filename::{SourceDistFilename, WheelFilename}; use distribution_types::RemoteSource; use pep440_rs::{Operator, Version, VersionSpecifier, VersionSpecifierBuildError}; -use pep508_rs::{MarkerEnvironment, MarkerTree, PackageName}; +use pep508_rs::{MarkerEnvironment, PackageName}; use pypi_types::RequirementSource; -use rustc_hash::FxHashMap; -use crate::{marker::is_disjoint, DependencyMode, Manifest, ResolverMarkers}; +use crate::resolver::ForkMap; +use crate::{DependencyMode, Manifest, ResolverMarkers}; /// A map of package names to their associated, required local versions across all forks. #[derive(Debug, Default, Clone)] -pub(crate) struct Locals(FxHashMap, Version)>>); +pub(crate) struct Locals(ForkMap); impl Locals { /// Determine the set of permitted local versions in the [`Manifest`]. @@ -20,20 +20,17 @@ impl Locals { markers: Option<&MarkerEnvironment>, dependencies: DependencyMode, ) -> Self { - let mut required: FxHashMap> = FxHashMap::default(); + let mut locals = ForkMap::default(); // Add all direct requirements and constraints. There's no need to look for conflicts, // since conflicts will be enforced by the solver. for requirement in manifest.requirements(markers, dependencies) { if let Some(local) = from_source(&requirement.source) { - required - .entry(requirement.name.clone()) - .or_default() - .push((requirement.marker.clone(), local)); + locals.add(&requirement, local); } } - Self(required) + Self(locals) } /// Return a list of local versions that are compatible with a package in the given fork. @@ -42,35 +39,7 @@ impl Locals { package_name: &PackageName, markers: &ResolverMarkers, ) -> Vec<&Version> { - let Some(locals) = self.0.get(package_name) else { - return Vec::new(); - }; - - match markers { - // If we are solving for a specific environment we already filtered - // compatible requirements `from_manifest`. - ResolverMarkers::SpecificEnvironment(_) => { - locals.first().map(|(_, local)| local).into_iter().collect() - } - - // Return all locals that were requested with markers that are compatible - // with the current fork. - // - // Compatibility implies that the markers are not disjoint. The resolver will - // choose the most compatible local when it narrows to the specific fork. - ResolverMarkers::Fork(fork) => locals - .iter() - .filter(|(marker, _)| { - !marker - .as_ref() - .is_some_and(|marker| is_disjoint(fork, marker)) - }) - .map(|(_, local)| local) - .collect(), - - // If we haven't forked yet, all locals are potentially compatible. - ResolverMarkers::Universal => locals.iter().map(|(_, local)| local).collect(), - } + self.0.get(package_name, markers) } /// Given a specifier that may include the version _without_ a local segment, return a specifier diff --git a/crates/uv-resolver/src/resolver/mod.rs b/crates/uv-resolver/src/resolver/mod.rs index de33e65c6854..47e3028ba589 100644 --- a/crates/uv-resolver/src/resolver/mod.rs +++ b/crates/uv-resolver/src/resolver/mod.rs @@ -26,7 +26,8 @@ use distribution_types::{ IncompatibleWheel, IndexLocations, InstalledDist, PythonRequirementKind, RemoteSource, ResolvedDist, ResolvedDistRef, SourceDist, VersionOrUrlRef, }; -pub(crate) use locals::Locals; +pub(crate) use fork_map::{ForkMap, ForkSet}; +use locals::Locals; use pep440_rs::{Version, MIN_VERSION}; use pep508_rs::MarkerTree; use platform_tags::Tags; @@ -72,6 +73,7 @@ use crate::{DependencyMode, Exclusions, FlatIndex, Options}; mod availability; mod batch_prefetch; +mod fork_map; mod index; mod locals; mod provider; @@ -422,6 +424,7 @@ impl ResolverState ResolverState ResolverState, request_sink: &Sender, @@ -840,6 +845,7 @@ impl ResolverState ResolverState, package: &PubGrubPackage, preferences: &Preferences, + fork_markers: &ResolverMarkers, python_requirement: &PythonRequirement, pins: &mut FilePins, visited: &mut FxHashSet, @@ -995,6 +1002,7 @@ impl ResolverState ResolverState Result<()> { } /// If a dependency requests a local version with an overlapping marker expression, -/// we should prefer the local in all cases. +/// we should prefer the local in both forks. #[test] fn universal_overlapping_local_requirement() -> Result<()> { let context = TestContext::new("3.12"); @@ -7464,6 +7464,230 @@ fn universal_nested_disjoint_local_requirement() -> Result<()> { Ok(()) } +// Requested distinct prerelease strategies with disjoint markers. +#[test] +fn universal_disjoint_prereleases() -> Result<()> { + static EXCLUDE_NEWER: &str = "2024-07-17T00:00:00Z"; + + let context = TestContext::new("3.12"); + let requirements_in = context.temp_dir.child("requirements.in"); + requirements_in.write_str(indoc::indoc! {r" + cffi ; os_name == 'linux' + cffi >= 1.17.0rc1 ; os_name != 'linux' + "})?; + + uv_snapshot!(context.filters(), windows_filters=false, context.pip_compile() + .env("UV_EXCLUDE_NEWER", EXCLUDE_NEWER) + .arg("requirements.in") + .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 --universal + cffi==1.17.0rc1 ; os_name != 'linux' + # via -r requirements.in + cffi==1.16.0 ; os_name == 'linux' + # via -r requirements.in + pycparser==2.22 + # via cffi + + ----- stderr ----- + Resolved 3 packages in [TIME] + "### + ); + + Ok(()) +} + +// Requested distinct prerelease strategies with disjoint markers for a package +// that is also present as a transitive dependency. +#[test] +fn universal_transitive_disjoint_prerelease_requirement() -> Result<()> { + static EXCLUDE_NEWER: &str = "2024-07-17T00:00:00Z"; + + let context = TestContext::new("3.12"); + let requirements_in = context.temp_dir.child("requirements.in"); + requirements_in.write_str(indoc::indoc! {r" + cffi ; os_name == 'linux' + cffi >= 1.17.0rc1 ; os_name != 'linux' + cryptography + "})?; + + uv_snapshot!(context.filters(), windows_filters=false, context.pip_compile() + .env("UV_EXCLUDE_NEWER", EXCLUDE_NEWER) + .arg("requirements.in") + .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 --universal + cffi==1.17.0rc1 ; os_name != 'linux' or platform_python_implementation != 'PyPy' + # via + # -r requirements.in + # cryptography + cffi==1.16.0 ; os_name == 'linux' or platform_python_implementation != 'PyPy' + # via + # -r requirements.in + # cryptography + cryptography==42.0.8 + # via -r requirements.in + pycparser==2.22 + # via cffi + + ----- stderr ----- + Resolved 4 packages in [TIME] + "### + ); + + Ok(()) +} + +// Ensure that the global prerelease mode is respected across forks. +#[test] +fn universal_prerelease_mode() -> Result<()> { + static EXCLUDE_NEWER: &str = "2024-07-17T00:00:00Z"; + + let context = TestContext::new("3.12"); + let requirements_in = context.temp_dir.child("requirements.in"); + requirements_in.write_str(indoc::indoc! {r" + cffi ; os_name == 'linux' + cffi >= 1.17.0rc1 ; os_name != 'linux' + "})?; + + uv_snapshot!(context.filters(), windows_filters=false, context.pip_compile() + .env("UV_EXCLUDE_NEWER", EXCLUDE_NEWER) + .arg("--prerelease=allow") + .arg("requirements.in") + .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] --prerelease=allow requirements.in --universal + cffi==1.17.0rc1 + # via -r requirements.in + pycparser==2.22 + # via cffi + + ----- stderr ----- + Resolved 2 packages in [TIME] + "### + ); + + Ok(()) +} + +/// If a dependency requests a prerelease version with an overlapping marker expression, +/// we should prefer the prerelease version in both forks. +#[test] +fn universal_overlapping_prerelease_requirement() -> Result<()> { + static EXCLUDE_NEWER: &str = "2024-07-17T00:00:00Z"; + + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(indoc! {r#" + [project] + name = "example" + version = "0.0.0" + dependencies = [ + "cffi >= 1.17.0rc1 ; os_name == 'Linux'" + ] + requires-python = ">=3.11" + "#})?; + + let requirements_in = context.temp_dir.child("requirements.in"); + requirements_in.write_str(indoc! {" + cffi + . + "})?; + + uv_snapshot!(context.filters(), windows_filters=false, context.pip_compile() + .env("UV_EXCLUDE_NEWER", EXCLUDE_NEWER) + .arg("requirements.in") + .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 --universal + cffi==1.17.0rc1 + # via + # -r requirements.in + # example + . + # via -r requirements.in + pycparser==2.22 + # via cffi + + ----- stderr ----- + Resolved 3 packages in [TIME] + "### + ); + + Ok(()) +} + +/// If a dependency requests distinct prerelease strategies with disjoint marker expressions, +/// we should fork the root requirement. +#[test] +fn universal_disjoint_prerelease_requirement() -> Result<()> { + static EXCLUDE_NEWER: &str = "2024-07-17T00:00:00Z"; + + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(indoc! {r#" + [project] + name = "example" + version = "0.0.0" + dependencies = [ + "cffi >= 1.17.0rc1 ; os_name == 'Linux'", + "cffi==1.15.0 ; os_name != 'Linux'" + ] + requires-python = ">=3.11" + "#})?; + + let requirements_in = context.temp_dir.child("requirements.in"); + requirements_in.write_str(indoc! {" + cffi + . + "})?; + + // Some marker expressions on the output here are missing due to https://github.com/astral-sh/uv/issues/5086, + // but the prerelease versions are still respected correctly. + uv_snapshot!(context.filters(), windows_filters=false, context.pip_compile() + .env("UV_EXCLUDE_NEWER", EXCLUDE_NEWER) + .arg("requirements.in") + .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 --universal + cffi==1.17.0rc1 + # via + # -r requirements.in + # example + cffi==1.15.0 + # via + # -r requirements.in + # example + . + # via -r requirements.in + pycparser==2.22 + # via cffi + + ----- stderr ----- + Resolved 4 packages in [TIME] + "### + ); + + Ok(()) +} + /// Perform a universal resolution that requires narrowing the supported Python range in one of the /// fork branches. ///