diff --git a/Cargo.toml b/Cargo.toml index 763756a..f1f179e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,12 +17,12 @@ indexmap = { version = "2.0.0", features = ["serde"] } pep440_rs = { version = "0.7.1" } pep508_rs = { version = "0.8.0", features = ["serde"] } serde = { version = "1.0.125", features = ["derive"] } -thiserror = { version = "1.0.61", optional = true } +thiserror = { version = "1.0.61"} toml = { version = "0.8.0", default-features = false, features = ["parse"] } [features] tracing = ["pep440_rs/tracing", "pep508_rs/tracing"] -pep639-glob = ["glob", "thiserror"] +pep639-glob = ["glob"] [dev-dependencies] insta = "1.40.0" diff --git a/src/lib.rs b/src/lib.rs index b58c88e..cd550f6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,6 +4,8 @@ mod pep639_glob; #[cfg(feature = "pep639-glob")] pub use pep639_glob::{parse_pep639_glob, Pep639GlobError}; +pub mod pep735_resolve; + use indexmap::IndexMap; use pep440_rs::{Version, VersionSpecifiers}; use pep508_rs::Requirement; @@ -201,7 +203,7 @@ pub enum DependencyGroupSpecifier { #[serde(rename_all = "kebab-case")] Table { /// The name of the group to include - include_group: Option, + include_group: String, }, } @@ -476,7 +478,7 @@ iota = [{include-group = "alpha"}] assert_eq!( dependency_groups["iota"], vec![DependencyGroupSpecifier::Table { - include_group: Some("alpha".to_string()) + include_group: "alpha".to_string() }] ); } diff --git a/src/pep735_resolve.rs b/src/pep735_resolve.rs new file mode 100644 index 0000000..ff98182 --- /dev/null +++ b/src/pep735_resolve.rs @@ -0,0 +1,146 @@ +use indexmap::IndexMap; +use pep508_rs::Requirement; +use thiserror::Error; + +use crate::{DependencyGroupSpecifier, DependencyGroups}; + +#[derive(Debug, Error)] +pub enum Pep735Error { + #[error("Failed to find group `{0}` included by `{1}`")] + GroupNotFound(String, String), + #[error("Detected a cycle in `dependency-groups`: {0}")] + DependencyGroupCycle(Cycle), +} + +/// A cycle in the `dependency-groups` table. +#[derive(Debug)] +pub struct Cycle(Vec); + +/// Display a cycle, e.g., `a -> b -> c -> a`. +impl std::fmt::Display for Cycle { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let [first, rest @ ..] = self.0.as_slice() else { + return Ok(()); + }; + write!(f, "`{first}`")?; + for group in rest { + write!(f, " -> `{group}`")?; + } + write!(f, " -> `{first}`")?; + Ok(()) + } +} +impl DependencyGroups { + /// Resolve dependency groups (which may contain references to other groups) into concrete + /// lists of requirements. + pub fn resolve(&self) -> Result>, Pep735Error> { + // Helper function to resolves a single group + fn resolve_single<'a>( + groups: &'a DependencyGroups, + group: &'a str, + resolved: &mut IndexMap>, + parents: &mut Vec<&'a str>, + ) -> Result<(), Pep735Error> { + let Some(specifiers) = groups.get(group) else { + // If the group included in another group does not exist, return an error + let parent = parents.into_iter().last().expect("should have a parent"); + return Err(Pep735Error::GroupNotFound( + group.to_string(), + parent.to_string(), + )); + }; + // If there is a cycle in dependency groups, return an error + if parents.contains(&group) { + return Err(Pep735Error::DependencyGroupCycle(Cycle( + parents.iter().map(|s| s.to_string()).collect(), + ))); + } + // If the dependency group has already been resolved, exit early + if resolved.get(group).is_some() { + return Ok(()); + } + // Otherwise, perform recursion, as required, on the dependency group's specifiers + parents.push(group); + let mut requirements = Vec::with_capacity(specifiers.len()); + for spec in specifiers.into_iter() { + match spec { + // It's a requirement. Just add it to the Vec of resolved requirements + DependencyGroupSpecifier::String(requirement) => { + requirements.push(requirement.clone()) + } + // It's a reference to another group. Recurse into it + DependencyGroupSpecifier::Table { include_group } => { + resolve_single(groups, include_group, resolved, parents)?; + requirements + .extend(resolved.get(include_group).into_iter().flatten().cloned()); + } + } + } + // Add the resolved group to IndexMap + resolved.insert(group.to_string(), requirements.clone()); + parents.pop(); + Ok(()) + } + + let mut resolved = IndexMap::new(); + for group in self.keys() { + resolve_single(self, group, &mut resolved, &mut Vec::new())?; + } + Ok(resolved) + } +} + +#[cfg(test)] +mod tests { + use pep508_rs::Requirement; + use std::str::FromStr; + + use crate::PyProjectToml; + + #[test] + fn test_parse_pyproject_toml_dependency_groups_resolve() { + let source = r#"[dependency-groups] +alpha = ["beta", "gamma", "delta"] +epsilon = ["eta<2.0", "theta==2024.09.01"] +iota = [{include-group = "alpha"}] +"#; + let project_toml = PyProjectToml::new(source).unwrap(); + let dependency_groups = project_toml.dependency_groups.as_ref().unwrap(); + + assert_eq!( + dependency_groups.resolve().unwrap()["iota"], + vec![ + Requirement::from_str("beta").unwrap(), + Requirement::from_str("gamma").unwrap(), + Requirement::from_str("delta").unwrap() + ] + ); + } + + #[test] + fn test_parse_pyproject_toml_dependency_groups_cycle() { + let source = r#"[dependency-groups] +alpha = [{include-group = "iota"}] +iota = [{include-group = "alpha"}] +"#; + let project_toml = PyProjectToml::new(source).unwrap(); + let dependency_groups = project_toml.dependency_groups.as_ref().unwrap(); + assert_eq!( + dependency_groups.resolve().unwrap_err().to_string(), + String::from("Detected a cycle in `dependency-groups`: `alpha` -> `iota` -> `alpha`") + ) + } + + #[test] + fn test_parse_pyproject_toml_dependency_groups_missing_include() { + let source = r#"[dependency-groups] +iota = [{include-group = "alpha"}] +"#; + let project_toml = PyProjectToml::new(source).unwrap(); + let dependency_groups = project_toml.dependency_groups.as_ref().unwrap(); + assert_eq!( + dependency_groups.resolve().unwrap_err().to_string(), + String::from("Failed to find group `alpha` included by `iota`") + ) + } +}