mirror of
https://github.com/astral-sh/uv.git
synced 2025-09-26 20:19:08 +00:00
Fix handling of != intersections in requires-python
(#7897)
## Summary The issue here is that, if you user has a `requires-python` like `>= 3.7, != 3.8.5`, this gets expanded to the following bounds: - `[3.7, 3.8.5)` - `(3.8.5, ...` We then convert this to the specific `>= 3.7, < 3.8.5, > 3.8.5`. But the commas in that expression are conjunctions... So it's impossible to satisfy? No version is both `< 3.8.5` and `> 3.8.5`. Instead, we now preserve the input `requires-python` and just concatenate the terms, only using PubGrub to compute the _bounds_. Closes https://github.com/astral-sh/uv/issues/7862.
This commit is contained in:
parent
36fedf7ac7
commit
77ea9d9626
3 changed files with 114 additions and 51 deletions
|
@ -1,8 +1,7 @@
|
||||||
use std::cmp::Ordering;
|
use std::cmp::Ordering;
|
||||||
use std::collections::Bound;
|
use std::collections::{BTreeSet, Bound};
|
||||||
use std::ops::Deref;
|
use std::ops::Deref;
|
||||||
|
|
||||||
use itertools::Itertools;
|
|
||||||
use pubgrub::Range;
|
use pubgrub::Range;
|
||||||
|
|
||||||
use uv_distribution_filename::WheelFilename;
|
use uv_distribution_filename::WheelFilename;
|
||||||
|
@ -70,37 +69,39 @@ impl RequiresPython {
|
||||||
pub fn intersection<'a>(
|
pub fn intersection<'a>(
|
||||||
specifiers: impl Iterator<Item = &'a VersionSpecifiers>,
|
specifiers: impl Iterator<Item = &'a VersionSpecifiers>,
|
||||||
) -> Result<Option<Self>, RequiresPythonError> {
|
) -> Result<Option<Self>, RequiresPythonError> {
|
||||||
// Convert to PubGrub range and perform an intersection.
|
let mut combined: BTreeSet<VersionSpecifier> = BTreeSet::new();
|
||||||
let range = specifiers
|
let mut lower_bound: LowerBound = LowerBound(Bound::Unbounded);
|
||||||
.into_iter()
|
let mut upper_bound: UpperBound = UpperBound(Bound::Unbounded);
|
||||||
.map(crate::pubgrub::PubGrubSpecifier::from_release_specifiers)
|
|
||||||
.fold_ok(None, |range: Option<Range<Version>>, requires_python| {
|
for specifier in specifiers {
|
||||||
if let Some(range) = range {
|
// Convert to PubGrub range and perform an intersection.
|
||||||
Some(range.intersection(&requires_python.into()))
|
let requires_python =
|
||||||
} else {
|
crate::pubgrub::PubGrubSpecifier::from_release_specifiers(specifier)?;
|
||||||
Some(requires_python.into())
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let Some(range) = range else {
|
// Track all specifiers for the final result.
|
||||||
|
combined.extend(specifier.iter().cloned());
|
||||||
|
}
|
||||||
|
|
||||||
|
if combined.is_empty() {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
}
|
||||||
|
|
||||||
// Extract the bounds.
|
// Compute the intersection by combining the specifiers.
|
||||||
let (lower_bound, upper_bound) = range
|
let specifiers = combined.into_iter().collect();
|
||||||
.bounding_range()
|
|
||||||
.map(|(lower_bound, upper_bound)| (lower_bound.cloned(), upper_bound.cloned()))
|
|
||||||
.unwrap_or((Bound::Unbounded, Bound::Unbounded));
|
|
||||||
|
|
||||||
// Convert back to PEP 440 specifiers.
|
|
||||||
let specifiers = range
|
|
||||||
.iter()
|
|
||||||
.flat_map(VersionSpecifier::from_release_only_bounds)
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Ok(Some(Self {
|
Ok(Some(Self {
|
||||||
specifiers,
|
specifiers,
|
||||||
range: RequiresPythonRange(LowerBound(lower_bound), UpperBound(upper_bound)),
|
range: RequiresPythonRange(lower_bound, upper_bound),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -231,29 +232,10 @@ impl RequiresPython {
|
||||||
.map(|(lower, _)| lower)
|
.map(|(lower, _)| lower)
|
||||||
.unwrap_or(&Bound::Unbounded);
|
.unwrap_or(&Bound::Unbounded);
|
||||||
|
|
||||||
// We want, e.g., `requires_python_lower` to be `>=3.8` and `version_lower` to be
|
// We want, e.g., `self.range.lower()` to be `>=3.8` and `target` to be `>=3.7`.
|
||||||
// `>=3.7`.
|
|
||||||
//
|
//
|
||||||
// That is: `version_lower` should be less than or equal to `requires_python_lower`.
|
// That is: `target` should be less than or equal to `self.range.lower()`.
|
||||||
match (target, self.range.lower().as_ref()) {
|
*self.range.lower() >= LowerBound(target.clone())
|
||||||
(Bound::Included(target_lower), Bound::Included(requires_python_lower)) => {
|
|
||||||
target_lower <= requires_python_lower
|
|
||||||
}
|
|
||||||
(Bound::Excluded(target_lower), Bound::Included(requires_python_lower)) => {
|
|
||||||
target_lower < requires_python_lower
|
|
||||||
}
|
|
||||||
(Bound::Included(target_lower), Bound::Excluded(requires_python_lower)) => {
|
|
||||||
target_lower <= requires_python_lower
|
|
||||||
}
|
|
||||||
(Bound::Excluded(target_lower), Bound::Excluded(requires_python_lower)) => {
|
|
||||||
target_lower < requires_python_lower
|
|
||||||
}
|
|
||||||
// If the dependency has no lower bound, then it supports all versions.
|
|
||||||
(Bound::Unbounded, _) => true,
|
|
||||||
// If we have no lower bound, then there must be versions we support that the
|
|
||||||
// dependency does not.
|
|
||||||
(_, Bound::Unbounded) => false,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the [`VersionSpecifiers`] for the `Requires-Python` specifier.
|
/// Returns the [`VersionSpecifiers`] for the `Requires-Python` specifier.
|
||||||
|
|
|
@ -3814,6 +3814,87 @@ fn lock_requires_python_star() -> Result<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Lock a requirement from PyPI, respecting the `Requires-Python` metadata. In this case,
|
||||||
|
/// `Requires-Python` uses the != operator.
|
||||||
|
#[test]
|
||||||
|
fn lock_requires_python_not_equal() -> Result<()> {
|
||||||
|
let context = TestContext::new("3.11");
|
||||||
|
|
||||||
|
let lockfile = context.temp_dir.join("uv.lock");
|
||||||
|
|
||||||
|
let pyproject_toml = context.temp_dir.child("pyproject.toml");
|
||||||
|
pyproject_toml.write_str(
|
||||||
|
r#"
|
||||||
|
[project]
|
||||||
|
name = "project"
|
||||||
|
version = "0.1.0"
|
||||||
|
requires-python = ">3.10, !=3.10.9, <3.13"
|
||||||
|
dependencies = ["iniconfig"]
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=42"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
"#,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
uv_snapshot!(context.filters(), context.lock(), @r###"
|
||||||
|
success: true
|
||||||
|
exit_code: 0
|
||||||
|
----- stdout -----
|
||||||
|
|
||||||
|
----- stderr -----
|
||||||
|
Resolved 2 packages in [TIME]
|
||||||
|
"###);
|
||||||
|
|
||||||
|
let lock = fs_err::read_to_string(&lockfile).unwrap();
|
||||||
|
|
||||||
|
insta::with_settings!({
|
||||||
|
filters => context.filters(),
|
||||||
|
}, {
|
||||||
|
assert_snapshot!(
|
||||||
|
lock, @r###"
|
||||||
|
version = 1
|
||||||
|
requires-python = ">3.10, !=3.10.9, <3.13"
|
||||||
|
|
||||||
|
[options]
|
||||||
|
exclude-newer = "2024-03-25T00:00:00Z"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
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 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "project"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = { editable = "." }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "iniconfig" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata]
|
||||||
|
requires-dist = [{ name = "iniconfig" }]
|
||||||
|
"###
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Re-run with `--locked`.
|
||||||
|
uv_snapshot!(context.filters(), context.lock().arg("--locked"), @r###"
|
||||||
|
success: true
|
||||||
|
exit_code: 0
|
||||||
|
----- stdout -----
|
||||||
|
|
||||||
|
----- stderr -----
|
||||||
|
Resolved 2 packages in [TIME]
|
||||||
|
"###);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Lock a requirement from PyPI, respecting the `Requires-Python` metadata. In this case,
|
/// Lock a requirement from PyPI, respecting the `Requires-Python` metadata. In this case,
|
||||||
/// `Requires-Python` uses a pre-release specifier, but it's effectively ignored, as `>=3.11.0b1`
|
/// `Requires-Python` uses a pre-release specifier, but it's effectively ignored, as `>=3.11.0b1`
|
||||||
/// is interpreted as equivalent to `>=3.11.0`.
|
/// is interpreted as equivalent to `>=3.11.0`.
|
||||||
|
@ -3855,7 +3936,7 @@ fn lock_requires_python_pre() -> Result<()> {
|
||||||
assert_snapshot!(
|
assert_snapshot!(
|
||||||
lock, @r###"
|
lock, @r###"
|
||||||
version = 1
|
version = 1
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11b1"
|
||||||
|
|
||||||
[options]
|
[options]
|
||||||
exclude-newer = "2024-03-25T00:00:00Z"
|
exclude-newer = "2024-03-25T00:00:00Z"
|
||||||
|
@ -12873,7 +12954,7 @@ fn lock_simplified_environments() -> Result<()> {
|
||||||
assert_snapshot!(
|
assert_snapshot!(
|
||||||
lock, @r###"
|
lock, @r###"
|
||||||
version = 1
|
version = 1
|
||||||
requires-python = "==3.11.*"
|
requires-python = ">=3.11, <3.12"
|
||||||
resolution-markers = [
|
resolution-markers = [
|
||||||
"sys_platform == 'darwin'",
|
"sys_platform == 'darwin'",
|
||||||
"sys_platform != 'darwin'",
|
"sys_platform != 'darwin'",
|
||||||
|
|
|
@ -378,7 +378,7 @@ fn mixed_requires_python() -> Result<()> {
|
||||||
|
|
||||||
----- stderr -----
|
----- stderr -----
|
||||||
Using CPython 3.8.[X] interpreter at: [PYTHON-3.8]
|
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.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.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 .`.
|
||||||
"###);
|
"###);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue