Skip to main content

check_updates/
selection.rs

1//! Filter dependency occurrences and choose releases without dependency resolution.
2
3use 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
58// pep440_rs 0.7 compares wildcard prefixes without zero-padding short versions.
59// PEP 440 requires that 2.0 matches 2.0.0.*, but not 2.0.1.*.
60fn 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
87/// Use the declared inclusive Python floor. Never use the host interpreter.
88pub 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
133/// Pick the highest newer release allowed by bounds, target, and Python metadata.
134/// Release discovery supplies stable, non-yanked releases.
135pub 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                // Invalid metadata cannot prove compatibility with the Python floor.
187                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        // A single compatible-release constraint moves its implicit upper bound.
284        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}