Skip to content

Commit

Permalink
Add a method to resolve dependency groups into concrete lists of depe…
Browse files Browse the repository at this point in the history
…ndencies (#26)
  • Loading branch information
olivier-lacroix authored Oct 28, 2024
1 parent 1d8328f commit 8af5247
Show file tree
Hide file tree
Showing 3 changed files with 153 additions and 4 deletions.
4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
6 changes: 4 additions & 2 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -219,7 +221,7 @@ pub enum DependencyGroupSpecifier {
#[serde(rename_all = "kebab-case")]
Table {
/// The name of the group to include
include_group: Option<String>,
include_group: String,
},
}

Expand Down Expand Up @@ -494,7 +496,7 @@ iota = [{include-group = "alpha"}]
assert_eq!(
dependency_groups["iota"],
vec![DependencyGroupSpecifier::Table {
include_group: Some("alpha".to_string())
include_group: "alpha".to_string()
}]
);
}
Expand Down
147 changes: 147 additions & 0 deletions src/pep735_resolve.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
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<String>);

/// 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<IndexMap<String, Vec<Requirement>>, Pep735Error> {
// Helper function to resolves a single group
fn resolve_single<'a>(
groups: &'a DependencyGroups,
group: &'a str,
resolved: &mut IndexMap<String, Vec<Requirement>>,
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.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.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`")
)
}
}

0 comments on commit 8af5247

Please sign in to comment.