pyproject_toml/
pep735_resolve.rs

1use indexmap::IndexMap;
2use pep508_rs::Requirement;
3use thiserror::Error;
4
5use crate::{DependencyGroupSpecifier, DependencyGroups};
6
7#[derive(Debug, Error)]
8pub enum Pep735Error {
9    #[error("Failed to find group `{0}` included by `{1}`")]
10    GroupNotFound(String, String),
11    #[error("Detected a cycle in `dependency-groups`: {0}")]
12    DependencyGroupCycle(Cycle),
13}
14
15/// A cycle in the `dependency-groups` table.
16#[derive(Debug)]
17pub struct Cycle(Vec<String>);
18
19/// Display a cycle, e.g., `a -> b -> c -> a`.
20impl std::fmt::Display for Cycle {
21    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
22        let [first, rest @ ..] = self.0.as_slice() else {
23            return Ok(());
24        };
25        write!(f, "`{first}`")?;
26        for group in rest {
27            write!(f, " -> `{group}`")?;
28        }
29        write!(f, " -> `{first}`")?;
30        Ok(())
31    }
32}
33
34impl DependencyGroups {
35    /// Resolve dependency groups (which may contain references to other groups) into concrete
36    /// lists of requirements.
37    pub fn resolve(&self) -> Result<IndexMap<String, Vec<Requirement>>, Pep735Error> {
38        // Helper function to resolves a single group
39        fn resolve_single<'a>(
40            groups: &'a DependencyGroups,
41            group: &'a str,
42            resolved: &mut IndexMap<String, Vec<Requirement>>,
43            parents: &mut Vec<&'a str>,
44        ) -> Result<(), Pep735Error> {
45            let Some(specifiers) = groups.get(group) else {
46                // If the group included in another group does not exist, return an error
47                let parent = parents.iter().last().expect("should have a parent");
48                return Err(Pep735Error::GroupNotFound(
49                    group.to_string(),
50                    parent.to_string(),
51                ));
52            };
53            // If there is a cycle in dependency groups, return an error
54            if parents.contains(&group) {
55                return Err(Pep735Error::DependencyGroupCycle(Cycle(
56                    parents.iter().map(|s| s.to_string()).collect(),
57                )));
58            }
59            // If the dependency group has already been resolved, exit early
60            if resolved.get(group).is_some() {
61                return Ok(());
62            }
63            // Otherwise, perform recursion, as required, on the dependency group's specifiers
64            parents.push(group);
65            let mut requirements = Vec::with_capacity(specifiers.len());
66            for spec in specifiers.iter() {
67                match spec {
68                    // It's a requirement. Just add it to the Vec of resolved requirements
69                    DependencyGroupSpecifier::String(requirement) => {
70                        requirements.push(requirement.clone())
71                    }
72                    // It's a reference to another group. Recurse into it
73                    DependencyGroupSpecifier::Table { include_group } => {
74                        resolve_single(groups, include_group, resolved, parents)?;
75                        requirements
76                            .extend(resolved.get(include_group).into_iter().flatten().cloned());
77                    }
78                }
79            }
80            // Add the resolved group to IndexMap
81            resolved.insert(group.to_string(), requirements.clone());
82            parents.pop();
83            Ok(())
84        }
85
86        let mut resolved = IndexMap::new();
87        for group in self.keys() {
88            resolve_single(self, group, &mut resolved, &mut Vec::new())?;
89        }
90        Ok(resolved)
91    }
92}
93
94#[cfg(test)]
95mod tests {
96    use pep508_rs::Requirement;
97    use std::str::FromStr;
98
99    use crate::PyProjectToml;
100
101    #[test]
102    fn test_parse_pyproject_toml_dependency_groups_resolve() {
103        let source = r#"[dependency-groups]
104alpha = ["beta", "gamma", "delta"]
105epsilon = ["eta<2.0", "theta==2024.09.01"]
106iota = [{include-group = "alpha"}]
107"#;
108        let project_toml = PyProjectToml::new(source).unwrap();
109        let dependency_groups = project_toml.dependency_groups.as_ref().unwrap();
110
111        assert_eq!(
112            dependency_groups.resolve().unwrap()["iota"],
113            vec![
114                Requirement::from_str("beta").unwrap(),
115                Requirement::from_str("gamma").unwrap(),
116                Requirement::from_str("delta").unwrap()
117            ]
118        );
119    }
120
121    #[test]
122    fn test_parse_pyproject_toml_dependency_groups_cycle() {
123        let source = r#"[dependency-groups]
124alpha = [{include-group = "iota"}]
125iota = [{include-group = "alpha"}]
126"#;
127        let project_toml = PyProjectToml::new(source).unwrap();
128        let dependency_groups = project_toml.dependency_groups.as_ref().unwrap();
129        assert_eq!(
130            dependency_groups.resolve().unwrap_err().to_string(),
131            String::from("Detected a cycle in `dependency-groups`: `alpha` -> `iota` -> `alpha`")
132        )
133    }
134
135    #[test]
136    fn test_parse_pyproject_toml_dependency_groups_missing_include() {
137        let source = r#"[dependency-groups]
138iota = [{include-group = "alpha"}]
139"#;
140        let project_toml = PyProjectToml::new(source).unwrap();
141        let dependency_groups = project_toml.dependency_groups.as_ref().unwrap();
142        assert_eq!(
143            dependency_groups.resolve().unwrap_err().to_string(),
144            String::from("Failed to find group `alpha` included by `iota`")
145        )
146    }
147}