use bombadil_core::model::PythonPin;
use bombadil_core::uv::results::Interpreter;
use std::cmp::Ordering;
#[derive(Debug, Clone, PartialEq, Eq)]
enum Clause {
AtLeast(Vec<u64>),
GreaterThan(Vec<u64>),
Exact(Vec<u64>),
Prefix(Vec<u64>),
Compatible(Vec<u64>),
}
impl Clause {
fn matches(&self, candidate: &[u64]) -> bool {
match self {
Clause::AtLeast(v) => cmp_segments(candidate, v) != Ordering::Less,
Clause::GreaterThan(v) => cmp_segments(candidate, v) == Ordering::Greater,
Clause::Exact(v) => cmp_segments(candidate, v) == Ordering::Equal,
Clause::Prefix(v) => starts_with(candidate, v),
Clause::Compatible(v) => {
v.len() >= 2
&& starts_with(candidate, &v[..v.len() - 1])
&& cmp_segments(candidate, v) != Ordering::Less
}
}
}
}
fn release_segments(s: &str) -> Vec<u64> {
s.split('.')
.map_while(|part| {
let digits: String = part.chars().take_while(|c| c.is_ascii_digit()).collect();
if digits.is_empty() {
None
} else {
digits.parse().ok()
}
})
.collect()
}
fn cmp_segments(a: &[u64], b: &[u64]) -> Ordering {
let len = a.len().max(b.len());
for i in 0..len {
let x = a.get(i).copied().unwrap_or(0);
let y = b.get(i).copied().unwrap_or(0);
match x.cmp(&y) {
Ordering::Equal => {}
other => return other,
}
}
Ordering::Equal
}
fn starts_with(candidate: &[u64], prefix: &[u64]) -> bool {
candidate.len() >= prefix.len() && candidate[..prefix.len()] == *prefix
}
fn non_empty(v: Vec<u64>) -> Option<Vec<u64>> {
(!v.is_empty()).then_some(v)
}
fn parse_clause(raw: &str) -> Option<Clause> {
let raw = raw.trim();
let (op, rest) = if let Some(rest) = raw.strip_prefix(">=") {
(">=", rest)
} else if let Some(rest) = raw.strip_prefix("==") {
("==", rest)
} else if let Some(rest) = raw.strip_prefix("~=") {
("~=", rest)
} else if let Some(rest) = raw.strip_prefix('>') {
(">", rest)
} else {
("", raw)
};
let rest = rest.trim();
match op {
">=" => Some(Clause::AtLeast(non_empty(release_segments(rest))?)),
">" => Some(Clause::GreaterThan(non_empty(release_segments(rest))?)),
"==" => match rest.strip_suffix(".*") {
Some(prefix) => Some(Clause::Prefix(non_empty(release_segments(prefix))?)),
None => Some(Clause::Exact(non_empty(release_segments(rest))?)),
},
"~=" => {
let segments = non_empty(release_segments(rest))?;
(segments.len() >= 2).then_some(Clause::Compatible(segments))
}
"" if rest.starts_with(|c: char| c.is_ascii_digit()) => {
Some(Clause::Prefix(non_empty(release_segments(rest))?))
}
_ => None,
}
}
fn parse_requirement(requires_python: &str) -> Option<Vec<Clause>> {
requires_python
.split(',')
.map(parse_clause)
.collect::<Option<Vec<_>>>()
.filter(|clauses| !clauses.is_empty())
}
pub fn version_satisfies(version: &str, requires_python: Option<&str>) -> bool {
match requires_python.and_then(parse_requirement) {
None => true,
Some(clauses) => {
let segments = release_segments(version);
clauses.iter().all(|clause| clause.matches(&segments))
}
}
}
pub fn satisfying<'a>(
interpreters: &'a [Interpreter],
requires_python: Option<&str>,
) -> Vec<&'a Interpreter> {
interpreters
.iter()
.filter(|i| version_satisfies(&i.version, requires_python))
.collect()
}
pub fn preselect<'a>(
interpreters: &'a [Interpreter],
requires_python: Option<&str>,
) -> Option<&'a Interpreter> {
satisfying(interpreters, requires_python)
.into_iter()
.max_by(|a, b| {
is_prerelease(&b.version)
.cmp(&is_prerelease(&a.version))
.then_with(|| {
cmp_segments(&release_segments(&a.version), &release_segments(&b.version))
})
})
}
pub fn satisfies_pin(version: &str, pin: &PythonPin) -> bool {
let PythonPin::Version(pinned) = pin else {
return true;
};
let (recorded, wanted) = (release_segments(version), release_segments(pinned));
if recorded.is_empty() || wanted.is_empty() {
return false;
}
let shared = recorded.len().min(wanted.len());
recorded[..shared] == wanted[..shared]
}
fn is_prerelease(version: &str) -> bool {
version.chars().any(|c| !c.is_ascii_digit() && c != '.')
}
pub fn suggested_install_version(requires_python: Option<&str>) -> Option<String> {
let first = requires_python?.split(',').next()?.trim();
let segments = match parse_clause(first)? {
Clause::AtLeast(v)
| Clause::GreaterThan(v)
| Clause::Exact(v)
| Clause::Prefix(v)
| Clause::Compatible(v) => v,
};
Some(
segments
.iter()
.map(u64::to_string)
.collect::<Vec<_>>()
.join("."),
)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn interp(version: &str) -> Interpreter {
Interpreter {
key: format!("cpython-{version}-x86_64-unknown-linux-gnu"),
version: version.to_string(),
path: Some(PathBuf::from(format!("/usr/bin/python{version}"))),
implementation: "cpython".to_string(),
}
}
fn downloadable(version: &str) -> Interpreter {
Interpreter {
path: None,
..interp(version)
}
}
#[test]
fn only_interpreters_satisfying_the_requirement_are_offered() {
let all = vec![interp("3.10.13"), interp("3.11.9"), interp("3.12.4")];
let got = satisfying(&all, Some(">=3.11"));
let versions: Vec<&str> = got.iter().map(|i| i.version.as_str()).collect();
assert_eq!(versions, vec!["3.11.9", "3.12.4"]);
}
#[test]
fn no_requirement_offers_everything() {
let all = vec![interp("3.10.13"), interp("3.12.4")];
assert_eq!(satisfying(&all, None).len(), 2);
}
#[test]
fn the_preselection_is_the_newest_satisfying_interpreter() {
let all = vec![interp("3.11.9"), interp("3.12.4"), interp("3.10.13")];
assert_eq!(
preselect(&all, Some(">=3.11")).map(|i| i.version.as_str()),
Some("3.12.4")
);
}
#[test]
fn nothing_satisfying_preselects_nothing_rather_than_the_wrong_one() {
let all = vec![interp("3.9.18")];
assert_eq!(preselect(&all, Some(">=3.11")), None);
}
#[test]
fn a_bare_version_is_a_prefix_match() {
let all = vec![interp("3.10.13"), interp("3.11.9")];
let versions: Vec<&str> = satisfying(&all, Some("3.11"))
.iter()
.map(|i| i.version.as_str())
.collect();
assert_eq!(versions, vec!["3.11.9"]);
}
#[test]
fn an_exact_operator_requires_the_release_segments_to_match() {
let all = vec![interp("3.11.8"), interp("3.11.9")];
let versions: Vec<&str> = satisfying(&all, Some("==3.11.9"))
.iter()
.map(|i| i.version.as_str())
.collect();
assert_eq!(versions, vec!["3.11.9"]);
}
#[test]
fn an_equals_wildcard_behaves_like_a_prefix() {
let all = vec![interp("3.10.13"), interp("3.11.9"), interp("3.11.2")];
let versions: Vec<&str> = satisfying(&all, Some("==3.11.*"))
.iter()
.map(|i| i.version.as_str())
.collect();
assert_eq!(versions, vec!["3.11.9", "3.11.2"]);
}
#[test]
fn compatible_release_allows_patch_drift_but_not_minor_drift() {
let all = vec![interp("3.11.1"), interp("3.11.9"), interp("3.12.0")];
let versions: Vec<&str> = satisfying(&all, Some("~=3.11.2"))
.iter()
.map(|i| i.version.as_str())
.collect();
assert_eq!(versions, vec!["3.11.9"]);
}
#[test]
fn comma_separated_clauses_are_all_required() {
let all = vec![interp("3.10.13"), interp("3.11.9"), interp("3.13.0")];
let versions: Vec<&str> = satisfying(&all, Some(">=3.11,<3.13"))
.iter()
.map(|i| i.version.as_str())
.collect();
assert_eq!(versions, vec!["3.10.13", "3.11.9", "3.13.0"]);
}
#[test]
fn an_unparseable_requirement_falls_back_to_offering_everything() {
let all = vec![interp("3.10.13"), interp("3.12.4")];
assert_eq!(satisfying(&all, Some("<3.5")).len(), 2);
assert_eq!(satisfying(&all, Some("not a specifier")).len(), 2);
assert_eq!(satisfying(&all, Some("")).len(), 2);
}
#[test]
fn a_preleased_suffixed_interpreter_still_compares_by_its_release_numbers() {
let all = vec![interp("3.15.0b4")];
let versions: Vec<&str> = satisfying(&all, Some(">=3.13"))
.iter()
.map(|i| i.version.as_str())
.collect();
assert_eq!(versions, vec!["3.15.0b4"]);
}
#[test]
fn a_downloadable_interpreter_can_still_be_preselected() {
let all = vec![downloadable("3.12.4")];
assert_eq!(
preselect(&all, Some(">=3.11")).map(|i| i.version.as_str()),
Some("3.12.4")
);
}
#[test]
fn an_empty_interpreter_list_preselects_nothing() {
assert_eq!(preselect(&[], Some(">=3.11")), None);
assert_eq!(preselect(&[], None), None);
}
#[test]
fn a_pin_is_satisfied_by_a_version_that_only_records_fewer_segments() {
assert!(satisfies_pin(
"3.12",
&PythonPin::Version("3.12.13".to_string())
));
}
#[test]
fn a_pin_is_satisfied_by_a_version_that_records_more_segments() {
assert!(satisfies_pin(
"3.13.14",
&PythonPin::Version("3.13".to_string())
));
}
#[test]
fn a_pin_is_not_satisfied_by_a_different_minor_version() {
assert!(!satisfies_pin(
"3.13.14",
&PythonPin::Version("3.12".to_string())
));
assert!(!satisfies_pin(
"3.12.13",
&PythonPin::Version("3.12.14".to_string())
));
}
#[test]
fn an_unpinned_project_is_satisfied_by_anything() {
assert!(satisfies_pin("3.9.1", &PythonPin::Unpinned));
assert!(satisfies_pin("", &PythonPin::Unpinned));
}
#[test]
fn a_version_that_parses_to_nothing_does_not_satisfy_a_pin() {
assert!(!satisfies_pin(
"not-a-version",
&PythonPin::Version("3.12".to_string())
));
}
#[test]
fn a_stable_release_is_preselected_over_a_newer_prerelease() {
let all = vec![interp("3.12.13"), interp("3.15.0b4"), interp("3.11.9")];
assert_eq!(
preselect(&all, Some(">=3.12.0")).map(|i| i.version.as_str()),
Some("3.12.13")
);
}
#[test]
fn the_newest_stable_still_wins_among_stables() {
let all = vec![interp("3.12.13"), interp("3.13.2"), interp("3.11.9")];
assert_eq!(
preselect(&all, Some(">=3.11")).map(|i| i.version.as_str()),
Some("3.13.2")
);
}
#[test]
fn a_prerelease_is_still_offered_when_it_is_the_only_thing_that_fits() {
let all = vec![interp("3.11.9"), interp("3.15.0b4")];
assert_eq!(
preselect(&all, Some(">=3.13")).map(|i| i.version.as_str()),
Some("3.15.0b4")
);
}
#[test]
fn every_shape_of_prerelease_is_recognised() {
for version in ["3.15.0b4", "3.14.0rc1", "3.16.0a1", "3.13.0-dev"] {
assert!(
is_prerelease(version),
"{version} must read as a prerelease"
);
}
for version in ["3.12.13", "3.11", "3"] {
assert!(!is_prerelease(version), "{version} must read as stable");
}
}
#[test]
fn the_suggested_install_version_is_the_floor_of_the_first_clause() {
assert_eq!(
suggested_install_version(Some(">=3.11")),
Some("3.11".to_string())
);
assert_eq!(
suggested_install_version(Some(">=3.11,<4")),
Some("3.11".to_string())
);
assert_eq!(
suggested_install_version(Some("~=3.10.2")),
Some("3.10.2".to_string())
);
assert_eq!(
suggested_install_version(Some("==3.12.*")),
Some("3.12".to_string())
);
}
#[test]
fn no_requirement_has_no_suggested_install_version() {
assert_eq!(suggested_install_version(None), None);
}
#[test]
fn an_unparseable_requirement_has_no_suggested_install_version() {
assert_eq!(suggested_install_version(Some("<3.13")), None);
}
#[test]
fn version_satisfies_agrees_with_satisfying_on_a_single_candidate() {
assert!(version_satisfies("3.12.4", Some(">=3.11")));
assert!(!version_satisfies("3.9.18", Some(">=3.11")));
}
#[test]
fn version_satisfies_falls_back_to_true_when_the_requirement_is_unparseable() {
assert!(version_satisfies("3.9.18", Some("<3.5")));
assert!(version_satisfies("3.9.18", Some("not a specifier")));
assert!(version_satisfies("3.9.18", None));
}
}