Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ uv-metadata = { path = "crates/uv-metadata" }
uv-normalize = { path = "crates/uv-normalize" }
uv-once-map = { path = "crates/uv-once-map" }
uv-options-metadata = { path = "crates/uv-options-metadata" }
uv-pep440 = { path = "crates/uv-pep440" }
uv-pep440 = { path = "crates/uv-pep440", features = ["tracing"] }
uv-pep508 = { path = "crates/uv-pep508", features = ["non-pep508-extensions"] }
uv-platform-tags = { path = "crates/uv-platform-tags" }
uv-pubgrub = { path = "crates/uv-pubgrub" }
Expand Down
44 changes: 42 additions & 2 deletions crates/uv-pep440/src/version_specifier.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ use std::cmp::Ordering;
use std::ops::Bound;
use std::str::FromStr;

use serde::{de, Deserialize, Deserializer, Serialize, Serializer};

use crate::{
version, Operator, OperatorParseError, Version, VersionPattern, VersionPatternParseError,
};
use serde::{de, Deserialize, Deserializer, Serialize, Serializer};
use tracing::warn;

/// Sorted version specifiers, such as `>=2.1,<3`.
///
Expand Down Expand Up @@ -69,6 +69,46 @@ impl VersionSpecifiers {
specifiers.sort_by(|a, b| a.version().cmp(b.version()));
Self(specifiers)
}

/// Returns the [`VersionSpecifiers`] whose union represents the given range.
///
/// This function is not applicable to ranges involving pre-release versions.
pub fn from_release_only_bounds<'a>(
mut bounds: impl Iterator<Item = (&'a Bound<Version>, &'a Bound<Version>)>,
) -> Self {
let mut specifiers = Vec::new();

let Some((start, mut next)) = bounds.next() else {
return Self::empty();
};

// Add specifiers for the holes between the bounds.
for (lower, upper) in bounds {
match (next, lower) {
// Ex) [3.7, 3.8.5), (3.8.5, 3.9] -> >=3.7,!=3.8.5,<=3.9
(Bound::Excluded(prev), Bound::Excluded(lower)) if prev == lower => {
specifiers.push(VersionSpecifier::not_equals_version(prev.clone()));
}
// Ex) [3.7, 3.8), (3.8, 3.9] -> >=3.7,!=3.8.*,<=3.9
(Bound::Excluded(prev), Bound::Included(lower))
if prev.release().len() == 2
&& lower.release() == [prev.release()[0], prev.release()[1] + 1] =>
{
specifiers.push(VersionSpecifier::not_equals_star_version(prev.clone()));
}
_ => {
warn!("Ignoring unsupported gap in `requires-python` version: {next:?} -> {lower:?}");
}
}
next = upper;
}
let end = next;

// Add the specifiers for the bounding range.
specifiers.extend(VersionSpecifier::from_release_only_bounds((start, end)));

Self::from_unsorted(specifiers)
}
}

impl FromIterator<VersionSpecifier> for VersionSpecifiers {
Expand Down
79 changes: 39 additions & 40 deletions crates/uv-resolver/src/requires_python.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
use itertools::Itertools;
use pubgrub::Range;
use std::cmp::Ordering;
use std::collections::{BTreeSet, Bound};
use std::collections::Bound;
use std::ops::Deref;

use pubgrub::Range;

use uv_distribution_filename::WheelFilename;
use uv_pep440::{Version, VersionSpecifier, VersionSpecifiers};
use uv_pep508::{MarkerExpression, MarkerTree, MarkerValueVersion};
use uv_pubgrub::PubGrubSpecifier;

#[derive(thiserror::Error, Debug)]
pub enum RequiresPythonError {
Expand Down Expand Up @@ -52,11 +53,10 @@ impl RequiresPython {

/// Returns a [`RequiresPython`] from a version specifier.
pub fn from_specifiers(specifiers: &VersionSpecifiers) -> Result<Self, RequiresPythonError> {
let (lower_bound, upper_bound) =
crate::pubgrub::PubGrubSpecifier::from_release_specifiers(specifiers)?
.bounding_range()
.map(|(lower_bound, upper_bound)| (lower_bound.cloned(), upper_bound.cloned()))
.unwrap_or((Bound::Unbounded, Bound::Unbounded));
let (lower_bound, upper_bound) = PubGrubSpecifier::from_release_specifiers(specifiers)?
.bounding_range()
.map(|(lower_bound, upper_bound)| (lower_bound.cloned(), upper_bound.cloned()))
.unwrap_or((Bound::Unbounded, Bound::Unbounded));
Ok(Self {
specifiers: specifiers.clone(),
range: RequiresPythonRange(LowerBound(lower_bound), UpperBound(upper_bound)),
Expand All @@ -69,35 +69,35 @@ impl RequiresPython {
pub fn intersection<'a>(
specifiers: impl Iterator<Item = &'a VersionSpecifiers>,
) -> Result<Option<Self>, RequiresPythonError> {
let mut combined: BTreeSet<VersionSpecifier> = BTreeSet::new();
let mut lower_bound: LowerBound = LowerBound(Bound::Unbounded);
let mut upper_bound: UpperBound = UpperBound(Bound::Unbounded);

for specifier in specifiers {
// Convert to PubGrub range and perform an intersection.
let requires_python =
crate::pubgrub::PubGrubSpecifier::from_release_specifiers(specifier)?;
if let Some((lower, upper)) = requires_python.bounding_range() {
let lower = LowerBound(lower.cloned());
let upper = UpperBound(upper.cloned());
if lower > lower_bound {
lower_bound = lower;
}
if upper < upper_bound {
upper_bound = upper;
// Convert to PubGrub range and perform an intersection.
let range = specifiers
.into_iter()
.map(PubGrubSpecifier::from_release_specifiers)
.fold_ok(None, |range: Option<Range<Version>>, requires_python| {
if let Some(range) = range {
Some(range.intersection(&requires_python.into()))
} else {
Some(requires_python.into())
}
}
})?;

// Track all specifiers for the final result.
combined.extend(specifier.iter().cloned());
}

if combined.is_empty() {
let Some(range) = range else {
return Ok(None);
}
};

// Extract the bounds.
let (lower_bound, upper_bound) = range
.bounding_range()
.map(|(lower_bound, upper_bound)| {
(
LowerBound(lower_bound.cloned()),
UpperBound(upper_bound.cloned()),
)
})
.unwrap_or((LowerBound::default(), UpperBound::default()));

// Compute the intersection by combining the specifiers.
let specifiers = combined.into_iter().collect();
// Convert back to PEP 440 specifiers.
let specifiers = VersionSpecifiers::from_release_only_bounds(range.iter());

Ok(Some(Self {
specifiers,
Expand Down Expand Up @@ -223,7 +223,7 @@ impl RequiresPython {
/// provided range. However, `>=3.9` would not be considered compatible, as the
/// `Requires-Python` includes Python 3.8, but `>=3.9` does not.
pub fn is_contained_by(&self, target: &VersionSpecifiers) -> bool {
let Ok(target) = crate::pubgrub::PubGrubSpecifier::from_release_specifiers(target) else {
let Ok(target) = PubGrubSpecifier::from_release_specifiers(target) else {
return false;
};
let target = target
Expand Down Expand Up @@ -458,12 +458,11 @@ impl serde::Serialize for RequiresPython {
impl<'de> serde::Deserialize<'de> for RequiresPython {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let specifiers = VersionSpecifiers::deserialize(deserializer)?;
let (lower_bound, upper_bound) =
crate::pubgrub::PubGrubSpecifier::from_release_specifiers(&specifiers)
.map_err(serde::de::Error::custom)?
.bounding_range()
.map(|(lower_bound, upper_bound)| (lower_bound.cloned(), upper_bound.cloned()))
.unwrap_or((Bound::Unbounded, Bound::Unbounded));
let (lower_bound, upper_bound) = PubGrubSpecifier::from_release_specifiers(&specifiers)
.map_err(serde::de::Error::custom)?
.bounding_range()
.map(|(lower_bound, upper_bound)| (lower_bound.cloned(), upper_bound.cloned()))
.unwrap_or((Bound::Unbounded, Bound::Unbounded));
Ok(Self {
specifiers,
range: RequiresPythonRange(LowerBound(lower_bound), UpperBound(upper_bound)),
Expand Down
10 changes: 5 additions & 5 deletions crates/uv/tests/lock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3818,7 +3818,7 @@ fn lock_requires_python_star() -> Result<()> {
/// `Requires-Python` uses the != operator.
#[test]
fn lock_requires_python_not_equal() -> Result<()> {
let context = TestContext::new("3.11");
let context = TestContext::new("3.12");

let lockfile = context.temp_dir.join("uv.lock");

Expand All @@ -3828,7 +3828,7 @@ fn lock_requires_python_not_equal() -> Result<()> {
[project]
name = "project"
version = "0.1.0"
requires-python = ">3.10, !=3.10.9, <3.13"
requires-python = ">3.10, !=3.10.9, !=3.10.10, !=3.11.*, <3.13"
dependencies = ["iniconfig"]

[build-system]
Expand All @@ -3854,7 +3854,7 @@ fn lock_requires_python_not_equal() -> Result<()> {
assert_snapshot!(
lock, @r###"
version = 1
requires-python = ">3.10, !=3.10.9, <3.13"
requires-python = ">3.10, !=3.10.9, !=3.10.10, !=3.11.*, <3.13"

[options]
exclude-newer = "2024-03-25T00:00:00Z"
Expand Down Expand Up @@ -3936,7 +3936,7 @@ fn lock_requires_python_pre() -> Result<()> {
assert_snapshot!(
lock, @r###"
version = 1
requires-python = ">=3.11b1"
requires-python = ">=3.11"

[options]
exclude-newer = "2024-03-25T00:00:00Z"
Expand Down Expand Up @@ -12954,7 +12954,7 @@ fn lock_simplified_environments() -> Result<()> {
assert_snapshot!(
lock, @r###"
version = 1
requires-python = ">=3.11, <3.12"
requires-python = "==3.11.*"
resolution-markers = [
"sys_platform == 'darwin'",
"sys_platform != 'darwin'",
Expand Down
2 changes: 1 addition & 1 deletion crates/uv/tests/sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -378,7 +378,7 @@ fn mixed_requires_python() -> Result<()> {

----- stderr -----
Using CPython 3.8.[X] interpreter at: [PYTHON-3.8]
error: The requested interpreter resolved to Python 3.8.[X], which is incompatible with the project's Python requirement: `>=3.8, >=3.12`. However, a workspace member (`bird-feeder`) supports Python >=3.8. To install the workspace member on its own, navigate to `packages/bird-feeder`, then run `uv venv --python 3.8.[X]` followed by `uv pip install -e .`.
error: The requested interpreter resolved to Python 3.8.[X], which is incompatible with the project's Python requirement: `>=3.12`. However, a workspace member (`bird-feeder`) supports Python >=3.8. To install the workspace member on its own, navigate to `packages/bird-feeder`, then run `uv venv --python 3.8.[X]` followed by `uv pip install -e .`.
"###);

Ok(())
Expand Down