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.
This commit is contained in:
@@ -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<Candidate<'a>> {
|
||||
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>,
|
||||
version_maps: &'a [VersionMap],
|
||||
preferences: &'a Preferences,
|
||||
installed_packages: &'a InstalledPackages,
|
||||
exclusions: &'a Exclusions,
|
||||
markers: &ResolverMarkers,
|
||||
) -> Option<Candidate<'a>> {
|
||||
// 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>,
|
||||
version_maps: &'a [VersionMap],
|
||||
markers: &ResolverMarkers,
|
||||
) -> Option<Candidate> {
|
||||
tracing::trace!(
|
||||
"selecting candidate for package {package_name} with range {range:?} with {} remote versions",
|
||||
version_maps.iter().map(VersionMap::len).sum::<usize>(),
|
||||
);
|
||||
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 {
|
||||
|
||||
@@ -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}")?;
|
||||
}
|
||||
|
||||
@@ -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<PackageName>),
|
||||
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<PackageName>),
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -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<PackageName, UnavailablePackage>,
|
||||
incomplete_packages: &FxHashMap<PackageName, BTreeMap<Version, IncompletePackage>>,
|
||||
fork_urls: &ForkUrls,
|
||||
markers: &ResolverMarkers,
|
||||
) -> IndexSet<PubGrubHint> {
|
||||
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<Version>,
|
||||
selector: &CandidateSelector,
|
||||
markers: &ResolverMarkers,
|
||||
hints: &mut IndexSet<PubGrubHint>,
|
||||
) {
|
||||
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(),
|
||||
|
||||
@@ -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<Request>,
|
||||
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(),
|
||||
|
||||
@@ -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<T>(FxHashMap<PackageName, Vec<Entry<T>>>);
|
||||
|
||||
/// An entry in a [`ForkMap`].
|
||||
#[derive(Debug, Clone)]
|
||||
struct Entry<T> {
|
||||
value: T,
|
||||
marker: Option<MarkerTree>,
|
||||
}
|
||||
|
||||
impl<T> Default for ForkMap<T> {
|
||||
fn default() -> Self {
|
||||
Self(FxHashMap::default())
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> ForkMap<T> {
|
||||
/// 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<PackageName, Vec<(Option<MarkerTree>, Version)>>);
|
||||
pub(crate) struct Locals(ForkMap<Version>);
|
||||
|
||||
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<PackageName, Vec<_>> = 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
|
||||
|
||||
@@ -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<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
|
||||
&mut state.pins,
|
||||
&preferences,
|
||||
&state.fork_urls,
|
||||
&state.markers,
|
||||
&state.python_requirement,
|
||||
&mut visited,
|
||||
&request_sink,
|
||||
@@ -480,6 +483,7 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
|
||||
&request_sink,
|
||||
&self.index,
|
||||
&self.selector,
|
||||
&state.markers,
|
||||
)?;
|
||||
}
|
||||
|
||||
@@ -812,6 +816,7 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
|
||||
pins: &mut FilePins,
|
||||
preferences: &Preferences,
|
||||
fork_urls: &ForkUrls,
|
||||
fork_markers: &ResolverMarkers,
|
||||
python_requirement: &PythonRequirement,
|
||||
visited: &mut FxHashSet<PackageName>,
|
||||
request_sink: &Sender<Request>,
|
||||
@@ -840,6 +845,7 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
|
||||
range,
|
||||
package,
|
||||
preferences,
|
||||
fork_markers,
|
||||
python_requirement,
|
||||
pins,
|
||||
visited,
|
||||
@@ -953,6 +959,7 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
|
||||
range: &Range<Version>,
|
||||
package: &PubGrubPackage,
|
||||
preferences: &Preferences,
|
||||
fork_markers: &ResolverMarkers,
|
||||
python_requirement: &PythonRequirement,
|
||||
pins: &mut FilePins,
|
||||
visited: &mut FxHashSet<PackageName>,
|
||||
@@ -995,6 +1002,7 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
|
||||
preferences,
|
||||
&self.installed_packages,
|
||||
&self.exclusions,
|
||||
fork_markers,
|
||||
) else {
|
||||
// Short circuit: we couldn't find _any_ versions for a package.
|
||||
return Ok(None);
|
||||
@@ -1767,6 +1775,9 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
|
||||
&self.preferences,
|
||||
&self.installed_packages,
|
||||
&self.exclusions,
|
||||
// We don't have access to the fork state when prefetching, so assume that
|
||||
// pre-release versions are allowed.
|
||||
&ResolverMarkers::Universal,
|
||||
) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
@@ -6960,7 +6960,7 @@ fn universal_local_path_requirement() -> 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.
|
||||
///
|
||||
|
||||
Reference in New Issue
Block a user