Skip to content

Commit bc8002e

Browse files
Avoid narrowing requires-python marker with disjunctions (#10704)
## Summary A bug in `requires_python` (which infers the Python requirement from a marker) was leading us to break an invariant around the relationship between the marker environment and the Python requirement. This, in turn, was leading us to drop parts of the environment space when solving. Specifically, in the linked example, we generated a fork for `python_full_version < '3.10' or platform_python_implementation != 'CPython'`, which was later split into `python_full_version == '3.8.*'` and `python_full_version == '3.9.*'`, losing the `platform_python_implementation != 'CPython'` portion. Closes #10669.
1 parent dce7b9d commit bc8002e

File tree

4 files changed

+172
-20
lines changed

4 files changed

+172
-20
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/uv-resolver/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ rustc-hash = { workspace = true }
5555
same-file = { workspace = true }
5656
schemars = { workspace = true, optional = true }
5757
serde = { workspace = true }
58+
smallvec = { workspace = true }
5859
textwrap = { workspace = true }
5960
thiserror = { workspace = true }
6061
tokio = { workspace = true }

crates/uv-resolver/src/marker.rs

Lines changed: 136 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,63 +1,85 @@
1-
use pubgrub::Range;
1+
use pubgrub::Ranges;
2+
use smallvec::SmallVec;
3+
use std::ops::Bound;
4+
25
use uv_pep440::Version;
36
use uv_pep508::{CanonicalMarkerValueVersion, MarkerTree, MarkerTreeKind};
47

58
use crate::requires_python::{LowerBound, RequiresPythonRange, UpperBound};
69

710
/// Returns the bounding Python versions that can satisfy the [`MarkerTree`], if it's constrained.
811
pub(crate) fn requires_python(tree: MarkerTree) -> Option<RequiresPythonRange> {
9-
fn collect_python_markers(tree: MarkerTree, markers: &mut Vec<Range<Version>>) {
12+
/// A small vector of Python version markers.
13+
type Markers = SmallVec<[Ranges<Version>; 3]>;
14+
15+
/// Collect the Python version markers from the tree.
16+
///
17+
/// Specifically, performs a DFS to collect all Python requirements on the path to every
18+
/// `MarkerTreeKind::True` node.
19+
fn collect_python_markers(tree: MarkerTree, markers: &mut Markers, range: &Ranges<Version>) {
1020
match tree.kind() {
11-
MarkerTreeKind::True | MarkerTreeKind::False => {}
21+
MarkerTreeKind::True => {
22+
markers.push(range.clone());
23+
}
24+
MarkerTreeKind::False => {}
1225
MarkerTreeKind::Version(marker) => match marker.key() {
1326
CanonicalMarkerValueVersion::PythonFullVersion => {
1427
for (range, tree) in marker.edges() {
15-
if !tree.is_false() {
16-
markers.push(range.clone());
17-
}
28+
collect_python_markers(tree, markers, range);
1829
}
1930
}
2031
CanonicalMarkerValueVersion::ImplementationVersion => {
2132
for (_, tree) in marker.edges() {
22-
collect_python_markers(tree, markers);
33+
collect_python_markers(tree, markers, range);
2334
}
2435
}
2536
},
2637
MarkerTreeKind::String(marker) => {
2738
for (_, tree) in marker.children() {
28-
collect_python_markers(tree, markers);
39+
collect_python_markers(tree, markers, range);
2940
}
3041
}
3142
MarkerTreeKind::In(marker) => {
3243
for (_, tree) in marker.children() {
33-
collect_python_markers(tree, markers);
44+
collect_python_markers(tree, markers, range);
3445
}
3546
}
3647
MarkerTreeKind::Contains(marker) => {
3748
for (_, tree) in marker.children() {
38-
collect_python_markers(tree, markers);
49+
collect_python_markers(tree, markers, range);
3950
}
4051
}
4152
MarkerTreeKind::Extra(marker) => {
4253
for (_, tree) in marker.children() {
43-
collect_python_markers(tree, markers);
54+
collect_python_markers(tree, markers, range);
4455
}
4556
}
4657
}
4758
}
4859

49-
let mut markers = Vec::new();
50-
collect_python_markers(tree, &mut markers);
60+
if tree.is_true() || tree.is_false() {
61+
return None;
62+
}
63+
64+
let mut markers = Markers::new();
65+
collect_python_markers(tree, &mut markers, &Ranges::full());
5166

52-
// Take the union of all Python version markers.
67+
// If there are no Python version markers, return `None`.
68+
if markers.iter().all(|range| {
69+
let Some((lower, upper)) = range.bounding_range() else {
70+
return true;
71+
};
72+
matches!((lower, upper), (Bound::Unbounded, Bound::Unbounded))
73+
}) {
74+
return None;
75+
}
76+
77+
// Take the union of the intersections of the Python version markers.
5378
let range = markers
5479
.into_iter()
55-
.fold(None, |acc: Option<Range<Version>>, range| {
56-
Some(match acc {
57-
Some(acc) => acc.union(&range),
58-
None => range.clone(),
59-
})
60-
})?;
80+
.fold(Ranges::empty(), |acc: Ranges<Version>, range| {
81+
acc.union(&range)
82+
});
6183

6284
let (lower, upper) = range.bounding_range()?;
6385

@@ -66,3 +88,97 @@ pub(crate) fn requires_python(tree: MarkerTree) -> Option<RequiresPythonRange> {
6688
UpperBound::new(upper.cloned()),
6789
))
6890
}
91+
92+
#[cfg(test)]
93+
mod tests {
94+
use std::ops::Bound;
95+
use std::str::FromStr;
96+
97+
use super::*;
98+
99+
#[test]
100+
fn test_requires_python() {
101+
// An exact version match.
102+
let tree = MarkerTree::from_str("python_full_version == '3.8.*'").unwrap();
103+
let range = requires_python(tree).unwrap();
104+
assert_eq!(
105+
*range.lower(),
106+
LowerBound::new(Bound::Included(Version::from_str("3.8").unwrap()))
107+
);
108+
assert_eq!(
109+
*range.upper(),
110+
UpperBound::new(Bound::Excluded(Version::from_str("3.9").unwrap()))
111+
);
112+
113+
// A version range with exclusive bounds.
114+
let tree =
115+
MarkerTree::from_str("python_full_version > '3.8' and python_full_version < '3.9'")
116+
.unwrap();
117+
let range = requires_python(tree).unwrap();
118+
assert_eq!(
119+
*range.lower(),
120+
LowerBound::new(Bound::Excluded(Version::from_str("3.8").unwrap()))
121+
);
122+
assert_eq!(
123+
*range.upper(),
124+
UpperBound::new(Bound::Excluded(Version::from_str("3.9").unwrap()))
125+
);
126+
127+
// A version range with inclusive bounds.
128+
let tree =
129+
MarkerTree::from_str("python_full_version >= '3.8' and python_full_version <= '3.9'")
130+
.unwrap();
131+
let range = requires_python(tree).unwrap();
132+
assert_eq!(
133+
*range.lower(),
134+
LowerBound::new(Bound::Included(Version::from_str("3.8").unwrap()))
135+
);
136+
assert_eq!(
137+
*range.upper(),
138+
UpperBound::new(Bound::Included(Version::from_str("3.9").unwrap()))
139+
);
140+
141+
// A version with a lower bound.
142+
let tree = MarkerTree::from_str("python_full_version >= '3.8'").unwrap();
143+
let range = requires_python(tree).unwrap();
144+
assert_eq!(
145+
*range.lower(),
146+
LowerBound::new(Bound::Included(Version::from_str("3.8").unwrap()))
147+
);
148+
assert_eq!(*range.upper(), UpperBound::new(Bound::Unbounded));
149+
150+
// A version with an upper bound.
151+
let tree = MarkerTree::from_str("python_full_version < '3.9'").unwrap();
152+
let range = requires_python(tree).unwrap();
153+
assert_eq!(*range.lower(), LowerBound::new(Bound::Unbounded));
154+
assert_eq!(
155+
*range.upper(),
156+
UpperBound::new(Bound::Excluded(Version::from_str("3.9").unwrap()))
157+
);
158+
159+
// A disjunction with a non-Python marker (i.e., an unbounded range).
160+
let tree =
161+
MarkerTree::from_str("python_full_version > '3.8' or sys_platform == 'win32'").unwrap();
162+
let range = requires_python(tree).unwrap();
163+
assert_eq!(*range.lower(), LowerBound::new(Bound::Unbounded));
164+
assert_eq!(*range.upper(), UpperBound::new(Bound::Unbounded));
165+
166+
// A complex mix of conjunctions and disjunctions.
167+
let tree = MarkerTree::from_str("(python_full_version >= '3.8' and python_full_version < '3.9') or (python_full_version >= '3.10' and python_full_version < '3.11')").unwrap();
168+
let range = requires_python(tree).unwrap();
169+
assert_eq!(
170+
*range.lower(),
171+
LowerBound::new(Bound::Included(Version::from_str("3.8").unwrap()))
172+
);
173+
assert_eq!(
174+
*range.upper(),
175+
UpperBound::new(Bound::Excluded(Version::from_str("3.11").unwrap()))
176+
);
177+
178+
// An unbounded range across two specifiers.
179+
let tree =
180+
MarkerTree::from_str("python_full_version > '3.8' or python_full_version <= '3.8'")
181+
.unwrap();
182+
assert_eq!(requires_python(tree), None);
183+
}
184+
}

crates/uv/tests/it/pip_compile.rs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14143,6 +14143,40 @@ fn compile_lowest_extra_unpinned_warning() -> Result<()> {
1414314143
Ok(())
1414414144
}
1414514145

14146+
#[test]
14147+
fn disjoint_requires_python() -> Result<()> {
14148+
let context = TestContext::new("3.8");
14149+
14150+
let requirements_in = context.temp_dir.child("requirements.in");
14151+
requirements_in.write_str(indoc::indoc! {r"
14152+
iniconfig ; platform_python_implementation == 'CPython' and python_version >= '3.10'
14153+
coverage
14154+
"})?;
14155+
14156+
uv_snapshot!(context.filters(), context.pip_compile()
14157+
.arg("--universal")
14158+
.arg(requirements_in.path())
14159+
.env_remove(EnvVars::UV_EXCLUDE_NEWER), @r###"
14160+
success: true
14161+
exit_code: 0
14162+
----- stdout -----
14163+
# This file was autogenerated by uv via the following command:
14164+
# uv pip compile --cache-dir [CACHE_DIR] --universal [TEMP_DIR]/requirements.in
14165+
coverage==7.6.1 ; python_full_version < '3.9'
14166+
# via -r requirements.in
14167+
coverage==7.6.10 ; python_full_version >= '3.9'
14168+
# via -r requirements.in
14169+
iniconfig==2.0.0 ; python_full_version >= '3.10' and platform_python_implementation == 'CPython'
14170+
# via -r requirements.in
14171+
14172+
----- stderr -----
14173+
Resolved 3 packages in [TIME]
14174+
"###
14175+
);
14176+
14177+
Ok(())
14178+
}
14179+
1414614180
/// Test that we use the version in the source distribution filename for compiling, even if the
1414714181
/// version is declared as dynamic.
1414814182
///

0 commit comments

Comments
 (0)