1use anyhow::{Context, Result, bail, ensure};
4use pep440_rs::{Operator, Version, VersionSpecifier, VersionSpecifiers};
5use pep508_rs::Requirement;
6use std::collections::HashSet;
7
8use crate::dependencies::{Dependency, baseline, normalize, specifiers};
9use crate::indexes::Release;
10
11#[derive(Clone, Debug)]
12pub struct Selection {
13 pub target: String,
14 pub include: HashSet<String>,
15 pub exclude: HashSet<String>,
16 pub groups: HashSet<String>,
17 pub extras: HashSet<String>,
18 pub runtime: bool,
19}
20
21impl Default for Selection {
22 fn default() -> Self {
23 Self {
24 target: "latest".into(),
25 include: HashSet::new(),
26 exclude: HashSet::new(),
27 groups: HashSet::new(),
28 extras: HashSet::new(),
29 runtime: false,
30 }
31 }
32}
33
34impl Selection {
35 pub fn accepts(&self, dependency: &Dependency, requirement: &Requirement) -> bool {
36 fn contains(names: &HashSet<String>, name: &str) -> bool {
37 names.iter().any(|candidate| normalize(candidate) == name)
38 }
39 let name = normalize(requirement.name.as_ref());
40 if contains(&self.exclude, &name)
41 || (!self.include.is_empty() && !contains(&self.include, &name))
42 {
43 return false;
44 }
45 if self.groups.is_empty() && self.extras.is_empty() && !self.runtime {
46 return true;
47 }
48 let section: Vec<&str> = dependency.section.iter().map(String::as_str).collect();
49 match section.as_slice() {
50 ["project", "dependencies"] => self.runtime,
51 ["dependency-groups", name] => contains(&self.groups, &normalize(name)),
52 ["project", "optional-dependencies", name] => contains(&self.extras, &normalize(name)),
53 _ => false,
54 }
55 }
56}
57
58fn specifier_contains(spec: &VersionSpecifier, version: &Version) -> bool {
61 if matches!(
62 spec.operator(),
63 Operator::EqualStar | Operator::NotEqualStar
64 ) {
65 let prefix = spec.version();
66 let matches = prefix.epoch() == version.epoch()
67 && prefix
68 .release()
69 .iter()
70 .enumerate()
71 .all(|(index, component)| {
72 *component == version.release().get(index).copied().unwrap_or(0)
73 });
74 return if *spec.operator() == Operator::EqualStar {
75 matches
76 } else {
77 !matches
78 };
79 }
80 spec.contains(version)
81}
82
83fn specifiers_contain(specs: &VersionSpecifiers, version: &Version) -> bool {
84 specs.iter().all(|spec| specifier_contains(spec, version))
85}
86
87pub fn python_baseline(value: Option<&str>) -> Result<Option<Version>> {
89 let Some(value) = value.filter(|value| !value.is_empty()) else {
90 return Ok(None);
91 };
92 let specs = value
93 .parse::<VersionSpecifiers>()
94 .context("Invalid requires-python range")?;
95 let floor = specs
96 .iter()
97 .filter(|spec| {
98 matches!(
99 spec.operator(),
100 Operator::GreaterThanEqual | Operator::TildeEqual
101 )
102 })
103 .map(|spec| spec.version())
104 .max();
105 if floor.is_none()
106 || specs.iter().any(|spec| {
107 !matches!(
108 spec.operator(),
109 Operator::GreaterThanEqual
110 | Operator::TildeEqual
111 | Operator::LessThan
112 | Operator::LessThanEqual
113 | Operator::NotEqual
114 | Operator::NotEqualStar
115 )
116 })
117 {
118 bail!(
119 "requires-python filtering needs an inclusive >= or ~= floor; unsupported Python range."
120 );
121 }
122 let floor = floor.unwrap();
123 ensure!(
124 !floor.any_prerelease()
125 && !floor.is_post()
126 && !floor.is_local()
127 && specifiers_contain(&specs, floor),
128 "requires-python floor must be a final version admitted by the declared range."
129 );
130 Ok(Some(floor.clone()))
131}
132
133pub fn choose_version(
136 requirement: &Requirement,
137 releases: &[Release],
138 target: &str,
139 python: Option<&Version>,
140) -> Option<Version> {
141 let spec = baseline(requirement);
142 let before = spec.map(|spec| spec.version());
143 releases
144 .iter()
145 .filter(|release| {
146 let version = &release.version;
147 if before.is_some_and(|before| version <= before)
148 || (version.is_local()
149 && spec.is_none_or(|spec| *spec.operator() != Operator::Equal))
150 {
151 return false;
152 }
153 if !specifiers(requirement)
154 .iter()
155 .filter(|spec| {
156 matches!(
157 spec.operator(),
158 Operator::LessThan
159 | Operator::LessThanEqual
160 | Operator::NotEqual
161 | Operator::NotEqualStar
162 )
163 })
164 .all(|spec| specifier_contains(spec, version))
165 {
166 return false;
167 }
168 if target != "latest" {
169 let Some(before) = before else {
170 return false;
171 };
172 if before.epoch() != version.epoch() {
173 return false;
174 }
175 let width = if target == "patch" { 2 } else { 1 };
176 if (0..width).any(|index| {
177 before.release().get(index).copied().unwrap_or(0)
178 != version.release().get(index).copied().unwrap_or(0)
179 }) {
180 return false;
181 }
182 }
183 if let (Some(python), Some(metadata)) = (python, &release.requires_python)
184 && !metadata.is_empty()
185 {
186 return metadata
188 .parse::<VersionSpecifiers>()
189 .is_ok_and(|specs| specifiers_contain(&specs, python));
190 }
191 true
192 })
193 .map(|release| &release.version)
194 .max()
195 .cloned()
196}
197
198#[cfg(test)]
199mod tests {
200 use super::*;
201 use crate::dependencies::{replacement, unsupported_reason};
202
203 fn version(value: &str) -> Version {
204 value.parse().unwrap()
205 }
206
207 fn releases(values: &[&str]) -> Vec<Release> {
208 values
209 .iter()
210 .map(|value| Release {
211 version: version(value),
212 requires_python: None,
213 })
214 .collect()
215 }
216
217 fn choose(requirement: &str, values: &[&str], target: &str) -> Option<Version> {
218 choose_version(
219 &requirement.parse().unwrap(),
220 &releases(values),
221 target,
222 None,
223 )
224 }
225
226 #[test]
227 fn targets_choose_highest_allowed_not_global_latest() {
228 for (target, expected) in [("patch", "1.2.9"), ("minor", "1.9"), ("latest", "2.0")] {
229 assert_eq!(
230 choose("demo>=1.2.3", &["1.2.9", "1.9", "2.0"], target),
231 Some(version(expected))
232 );
233 }
234 }
235
236 #[test]
237 fn target_edge_cases() {
238 for (requirement, target, values, expected) in [
239 ("demo", "patch", vec!["2.0"], None),
240 ("demo", "minor", vec!["2.0"], None),
241 ("demo", "latest", vec!["2.0"], Some("2.0")),
242 ("demo>=1.2.3", "patch", vec!["2.0"], None),
243 ("demo==2.0", "latest", vec!["2.0"], None),
244 ("demo>=3.0", "latest", vec!["2.0"], None),
245 ("demo>=1!1.2", "minor", vec!["2.0"], None),
246 ("demo>=0.2.0", "minor", vec!["0.9", "1.0"], Some("0.9")),
247 ("demo>=1.2", "patch", vec!["1.2.9", "1.3"], Some("1.2.9")),
248 (
249 "demo>=1!1.2",
250 "patch",
251 vec!["1!1.2.1", "2!1.2.2"],
252 Some("1!1.2.1"),
253 ),
254 ("demo>=1", "patch", vec!["1.0.2", "1.1"], Some("1.0.2")),
255 ] {
256 assert_eq!(
257 choose(requirement, &values, target),
258 expected.map(version),
259 "{requirement} {target}"
260 );
261 }
262 assert_eq!(choose("demo", &[], "latest"), None);
263 }
264
265 #[test]
266 fn upper_bounds_and_exclusions() {
267 for (requirement, expected) in [
268 ("demo>=1.0,<2", "1.9"),
269 ("demo>=1.0,<=2", "2"),
270 ("demo>=1.0,<2,!=1.9", "1.8"),
271 ("demo>=1.0,<2,!=1.9.*", "1.8"),
272 ] {
273 assert_eq!(unsupported_reason(&requirement.parse().unwrap()), None);
274 assert_eq!(
275 choose(requirement, &["1.8", "1.9", "2", "3"], "latest"),
276 Some(version(expected))
277 );
278 }
279 assert_eq!(
280 choose("demo>=1,<2,!=1.9.*", &["1.9", "1.9.1", "2"], "latest"),
281 None
282 );
283 assert_eq!(
285 choose("demo~=1.2.3", &["1.9", "2"], "latest"),
286 Some(version("2"))
287 );
288 }
289
290 #[test]
291 fn python_uses_per_file_metadata_and_all_release_files() {
292 let mut values = vec![
293 Release {
294 version: version("1.8"),
295 requires_python: Some(">=3.10".into()),
296 },
297 Release {
298 version: version("2"),
299 requires_python: Some(">=3.12".into()),
300 },
301 Release {
302 version: version("3"),
303 requires_python: Some("nonsense".into()),
304 },
305 ];
306 let req = "demo>=1".parse().unwrap();
307 assert_eq!(
308 choose_version(&req, &values, "latest", Some(&version("3.10"))),
309 Some(version("1.8"))
310 );
311 assert_eq!(
312 choose_version(&req, &values, "latest", Some(&version("3.12"))),
313 Some(version("2"))
314 );
315 values.push(Release {
316 version: version("2"),
317 requires_python: Some(">=3.10".into()),
318 });
319 assert_eq!(
320 choose_version(&req, &values, "latest", Some(&version("3.10"))),
321 Some(version("2"))
322 );
323 assert_eq!(
324 choose_version(&req, &releases(&["2"]), "latest", Some(&version("3.10"))),
325 Some(version("2"))
326 );
327 assert_eq!(
328 choose_version(&req, &values[1..2], "latest", Some(&version("3.10"))),
329 None
330 );
331 assert_eq!(
332 choose_version(&req, &values, "latest", None),
333 Some(version("3"))
334 );
335 }
336
337 #[test]
338 fn python_floor() {
339 for (value, expected) in [
340 (None, None),
341 (Some(""), None),
342 (Some(">=3.10,<4"), Some("3.10")),
343 (Some("~=3.10"), Some("3.10")),
344 (Some(">=3.10,>=3.11"), Some("3.11")),
345 (Some(">=3.10,!=3.11.*"), Some("3.10")),
346 ] {
347 assert_eq!(python_baseline(value).unwrap(), expected.map(version));
348 }
349 }
350
351 #[test]
352 fn wildcard_prefixes_zero_pad_short_versions() {
353 assert_eq!(
354 choose("demo>=1,!=2.0.1.*", &["2.0", "2.0.1", "2.0.1.1"], "latest"),
355 Some(version("2.0"))
356 );
357 assert_eq!(choose("demo>=1,!=2.0.0.*", &["2.0"], "latest"), None);
358 assert_eq!(
359 python_baseline(Some(">=3.10,!=3.10.1.*")).unwrap(),
360 Some(version("3.10"))
361 );
362 assert!(python_baseline(Some(">=3.10,!=3.10.0.*")).is_err());
363 let req = "demo>=1".parse().unwrap();
364 let releases = vec![Release {
365 version: version("2.0"),
366 requires_python: Some(">=3.10,!=3.10.1.*".into()),
367 }];
368 assert_eq!(
369 choose_version(&req, &releases, "latest", Some(&version("3.10"))),
370 Some(version("2.0"))
371 );
372 }
373
374 #[test]
375 fn python_floor_never_guesses() {
376 for value in [
377 ">3.10",
378 "<4",
379 "==3.10.*",
380 ">=3.10,!=3.10.*",
381 ">=3.10rc1",
382 ">=3.10,<3.9",
383 ">=3.10.dev1",
384 ">=3.10.post1",
385 "nonsense",
386 ] {
387 assert!(python_baseline(Some(value)).is_err(), "{value}");
388 }
389 }
390
391 #[test]
392 fn local_versions_cannot_enter_ordered_requirements() {
393 for original in ["demo", "demo>=1.2.3", "demo~=1.2.3", "demo>=1.2.3,<2"] {
394 let req: Requirement = original.parse().unwrap();
395 let found = choose(original, &["1.2.4", "1.2.5+internal"], "latest").unwrap();
396 assert_eq!(found, version("1.2.4"));
397 let updated = replacement(&req, original, &found).unwrap();
398 assert!(updated.parse::<Requirement>().is_ok());
399 assert_eq!(choose(original, &["1.2.5+internal"], "latest"), None);
400 }
401 }
402
403 #[test]
404 fn local_exact_pins_work_with_all_targets() {
405 let original = "demo==1.2.3+old";
406 let req: Requirement = original.parse().unwrap();
407 for target in ["patch", "minor", "latest"] {
408 let found = choose(original, &["1.2.4", "1.2.5+internal"], target).unwrap();
409 assert_eq!(found, version("1.2.5+internal"));
410 let updated = replacement(&req, original, &found).unwrap();
411 assert_eq!(updated, "demo==1.2.5+internal");
412 assert!(updated.parse::<Requirement>().is_ok());
413 }
414 }
415
416 fn dependency(section: &[&str]) -> Dependency {
417 Dependency {
418 requirement: "Demo_Pkg>=1".into(),
419 section: section.iter().map(|value| (*value).into()).collect(),
420 index: 0,
421 }
422 }
423
424 fn set(values: &[&str]) -> HashSet<String> {
425 values.iter().map(|value| (*value).into()).collect()
426 }
427
428 #[test]
429 fn package_filters_apply_before_section_filters() {
430 let entry = dependency(&["project", "dependencies"]);
431 let req = entry.requirement.parse().unwrap();
432 let mut selection = Selection::default();
433 assert!(selection.accepts(&entry, &req));
434 selection.include = set(&["DEMO.pkg"]);
435 assert!(selection.accepts(&entry, &req));
436 selection.exclude = set(&["demo_pkg"]);
437 assert!(!selection.accepts(&entry, &req));
438 selection.exclude.clear();
439 selection.include = set(&["absent"]);
440 assert!(!selection.accepts(&entry, &req));
441 }
442
443 #[test]
444 fn section_filters_are_unions_and_normalize_both_names() {
445 for declared in ["qa-tools", "qa_tools", "QA.Tools"] {
446 for selector in ["qa-tools", "qa_tools", "QA.Tools", "QA.._-TOOLS"] {
447 let runtime = dependency(&["project", "dependencies"]);
448 let group = dependency(&["dependency-groups", declared]);
449 let extra = dependency(&["project", "optional-dependencies", declared]);
450 let other = dependency(&["dependency-groups", "other"]);
451 let req = runtime.requirement.parse().unwrap();
452 let mut selection = Selection {
453 groups: set(&[selector]),
454 ..Selection::default()
455 };
456 assert!(selection.accepts(&group, &req));
457 assert!(!selection.accepts(&runtime, &req));
458 assert!(!selection.accepts(&extra, &req));
459 assert!(!selection.accepts(&other, &req));
460 selection.extras = set(&[selector]);
461 assert!(selection.accepts(&extra, &req));
462 selection.runtime = true;
463 assert!(selection.accepts(&runtime, &req));
464 assert!(!selection.accepts(&other, &req));
465 }
466 }
467 }
468}