diff --git a/Cargo.toml b/Cargo.toml index 75ff0ce..e6197cd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ license = "MIT" keywords = ["pyproject", "pep517", "pep518", "pep621", "pep639"] readme = "README.md" repository = "https://github.com/PyO3/pyproject-toml-rs.git" -rust-version = "1.64" +rust-version = "1.74" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/src/lib.rs b/src/lib.rs index 440e0d9..2972e26 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,14 +1,15 @@ #[cfg(feature = "pep639-glob")] mod pep639_glob; +mod resolution; #[cfg(feature = "pep639-glob")] pub use pep639_glob::{check_pep639_glob, parse_pep639_glob, Pep639GlobError}; - -pub mod pep735_resolve; +pub use resolution::ResolveError; use indexmap::IndexMap; use pep440_rs::{Version, VersionSpecifiers}; use pep508_rs::Requirement; +use resolution::resolve; use serde::{Deserialize, Serialize}; use std::ops::Deref; use std::path::PathBuf; @@ -42,6 +43,7 @@ pub struct PyProjectToml { #[serde(rename_all = "kebab-case")] pub struct Project { /// The name of the project + // TODO: This should become a `PackageName` in the next breaking release. pub name: String, /// The version of the project as supported by PEP 440 pub version: Option, @@ -83,6 +85,7 @@ pub struct Project { /// Project dependencies pub dependencies: Option>, /// Optional dependencies + // TODO: The `String` should become a `ExtraName` in the next breaking release. pub optional_dependencies: Option>>, /// Specifies which fields listed by PEP 621 were intentionally unspecified /// so another tool can/will provide such metadata dynamically. @@ -198,8 +201,9 @@ impl Contact { } /// The `[dependency-groups]` section of pyproject.toml, as specified in PEP 735 -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq)] #[serde(transparent)] +// TODO: The `String` should become a `ExtraName` in the next breaking release. pub struct DependencyGroups(pub IndexMap>); impl Deref for DependencyGroups { @@ -225,11 +229,50 @@ pub enum DependencyGroupSpecifier { }, } +/// Optional dependencies and dependency groups resolved into flat lists of requirements that are +/// not self-referential +/// +/// Note that `project.name` is required to resolve self-referential optional dependencies +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ResolvedDependencies { + pub optional_dependencies: IndexMap>, + pub dependency_groups: IndexMap>, +} + impl PyProjectToml { /// Parse `pyproject.toml` content pub fn new(content: &str) -> Result { toml::de::from_str(content) } + + /// Resolve the optional dependencies (extras) and dependency groups into flat lists of + /// requirements. + /// + /// This function will recursively resolve all optional dependency groups and dependency groups, + /// including those that reference other groups. It will return an error if + /// - there is a cycle in the groups, or + /// - a group references another group that does not exist. + /// + /// Resolving self-referential optional dependencies requires `project.name` to be set. + /// + /// Note: This method makes no guarantee about the order of items and whether duplicates are + /// removed or not. + pub fn resolve(&self) -> Result { + let self_reference_name = self.project.as_ref().map(|p| p.name.as_str()); + let optional_dependencies = self + .project + .as_ref() + .and_then(|p| p.optional_dependencies.as_ref()); + let dependency_groups = self.dependency_groups.as_ref(); + + let resolved_dependencies = resolve( + self_reference_name, + optional_dependencies, + dependency_groups, + )?; + + Ok(resolved_dependencies) + } } #[cfg(test)] diff --git a/src/pep735_resolve.rs b/src/pep735_resolve.rs deleted file mode 100644 index 60e6d45..0000000 --- a/src/pep735_resolve.rs +++ /dev/null @@ -1,147 +0,0 @@ -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.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`") - ) - } -} diff --git a/src/resolution.rs b/src/resolution.rs new file mode 100644 index 0000000..9cc665f --- /dev/null +++ b/src/resolution.rs @@ -0,0 +1,463 @@ +use crate::{DependencyGroupSpecifier, DependencyGroups, ResolvedDependencies}; +use indexmap::IndexMap; +use pep508_rs::Requirement; +use std::fmt::Display; +use thiserror::Error; + +#[derive(Debug, Error)] +#[error(transparent)] +pub struct ResolveError(#[from] ResolveErrorKind); + +#[derive(Debug, Error)] +pub enum ResolveErrorKind { + #[error("Failed to find optional dependency `{name}` included by {included_by}")] + OptionalDependencyNotFound { name: String, included_by: Item }, + #[error("Failed to find dependency group `{name}` included by {included_by}")] + DependencyGroupNotFound { name: String, included_by: Item }, + #[error("Cycles are not supported: {0}")] + DependencyGroupCycle(Cycle), +} + +/// A cycle in the recursion. +#[derive(Debug)] +pub struct Cycle(Vec); + +/// Display a cycle, e.g., `a -> b -> c -> a`. +impl Display for Cycle { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let Some((first, rest)) = self.0.split_first() else { + return Ok(()); + }; + write!(f, "{first}")?; + for group in rest { + write!(f, " -> {group}")?; + } + write!(f, " -> {first}")?; + Ok(()) + } +} + +/// A reference to either an optional dependency or a dependency group. +#[derive(Debug, Clone, Eq, PartialEq)] +pub enum Item { + Extra(String), + Group(String), +} + +impl Display for Item { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Item::Extra(extra) => write!(f, "extra:{extra}",), + Item::Group(group) => { + write!(f, "group:{group}") + } + } + } +} + +pub(crate) fn resolve( + self_reference_name: Option<&str>, + optional_dependencies: Option<&IndexMap>>, + dependency_groups: Option<&DependencyGroups>, +) -> Result { + let mut resolved_dependencies = ResolvedDependencies::default(); + + // Resolve optional dependencies, which may only reference optional dependencies. + if let Some(optional_dependencies) = optional_dependencies { + for extra in optional_dependencies.keys() { + resolve_optional_dependency( + extra, + optional_dependencies, + &mut resolved_dependencies, + &mut Vec::new(), + self_reference_name, + )?; + } + } + + // Resolve dependency groups, which may reference dependency groups and optional dependencies. + if let Some(dependency_groups) = dependency_groups { + for group in dependency_groups.keys() { + // It's a reference to other groups. Recurse into them + resolve_dependency_group( + group, + optional_dependencies.unwrap_or(&IndexMap::default()), + dependency_groups, + &mut resolved_dependencies, + &mut Vec::new(), + self_reference_name, + )?; + } + } + + Ok(resolved_dependencies) +} + +/// Resolves a single optional dependency. +fn resolve_optional_dependency( + extra: &str, + optional_dependencies: &IndexMap>, + resolved: &mut ResolvedDependencies, + parents: &mut Vec, + project_name: Option<&str>, +) -> Result, ResolveError> { + if let Some(requirements) = resolved.optional_dependencies.get(extra) { + return Ok(requirements.clone()); + } + + let Some(unresolved_requirements) = optional_dependencies.get(extra) else { + let parent = parents + .iter() + .last() + .expect("missing optional dependency must have parent") + .clone(); + return Err(ResolveErrorKind::OptionalDependencyNotFound { + name: extra.to_string(), + included_by: parent, + } + .into()); + }; + + // Check for cycles + let item = Item::Extra(extra.to_string()); + if parents.contains(&item) { + return Err(ResolveErrorKind::DependencyGroupCycle(Cycle(parents.clone())).into()); + } + parents.push(item); + + // Recurse into references, and add their resolved requirements to our own requirements. + let mut resolved_requirements = Vec::with_capacity(unresolved_requirements.len()); + for unresolved_requirement in unresolved_requirements.iter() { + // TODO: This should become a `PackageName` in the next breaking release. + if project_name + .is_some_and(|project_name| project_name == unresolved_requirement.name.to_string()) + { + // Resolve each extra individually, as each refers to a different optional + // dependency entry. + for extra in &unresolved_requirement.extras { + let extra_string = extra.to_string(); + resolved_requirements.extend(resolve_optional_dependency( + &extra_string, + optional_dependencies, + resolved, + parents, + project_name, + )?); + } + } else { + resolved_requirements.push(unresolved_requirement.clone()) + } + } + resolved + .optional_dependencies + .insert(extra.to_string(), resolved_requirements.clone()); + parents.pop(); + Ok(resolved_requirements) +} + +/// Resolves a single dependency group. +fn resolve_dependency_group( + dep_group: &String, + optional_dependencies: &IndexMap>, + dependency_groups: &DependencyGroups, + resolved: &mut ResolvedDependencies, + parents: &mut Vec, + project_name: Option<&str>, +) -> Result, ResolveError> { + if let Some(requirements) = resolved.dependency_groups.get(dep_group) { + return Ok(requirements.clone()); + } + + let Some(unresolved_requirements) = dependency_groups.get(dep_group) else { + let parent = parents + .iter() + .last() + .expect("missing optional dependency must have parent") + .clone(); + return Err(ResolveErrorKind::DependencyGroupNotFound { + name: dep_group.to_string(), + included_by: parent, + } + .into()); + }; + + // Check for cycles + let item = Item::Group(dep_group.to_string()); + if parents.contains(&item) { + return Err(ResolveErrorKind::DependencyGroupCycle(Cycle(parents.clone())).into()); + } + parents.push(item); + + // Otherwise, perform recursion, as required, on the dependency group's specifiers + let mut resolved_requirements = Vec::with_capacity(unresolved_requirements.len()); + for unresolved_requirement in unresolved_requirements.iter() { + match unresolved_requirement { + DependencyGroupSpecifier::String(spec) => { + if project_name.is_some_and(|project_name| project_name == spec.name.to_string()) { + for extra in &spec.extras { + resolved_requirements.extend(resolve_optional_dependency( + extra.as_ref(), + optional_dependencies, + resolved, + parents, + project_name, + )?); + } + } else { + resolved_requirements.push(spec.clone()) + } + } + DependencyGroupSpecifier::Table { include_group } => { + resolved_requirements.extend(resolve_dependency_group( + include_group, + optional_dependencies, + dependency_groups, + resolved, + parents, + project_name, + )?); + } + } + } + // Add the resolved group to IndexMap + resolved + .dependency_groups + .insert(dep_group.to_string(), resolved_requirements.clone()); + parents.pop(); + Ok(resolved_requirements) +} + +#[cfg(test)] +mod tests { + use pep508_rs::Requirement; + use std::str::FromStr; + + use crate::resolution::{resolve_optional_dependency, Item}; + use crate::{PyProjectToml, ResolvedDependencies}; + + #[test] + fn parse_pyproject_toml_optional_dependencies_resolve() { + let source = r#"[project] + name = "spam" + + [project.optional-dependencies] + alpha = ["beta", "gamma", "delta"] + epsilon = ["eta<2.0", "theta==2024.09.01"] + iota = ["spam[alpha]"] + "#; + let pyproject_toml = PyProjectToml::new(source).unwrap(); + let resolved_dependencies = pyproject_toml.resolve().unwrap(); + + assert_eq!( + resolved_dependencies.optional_dependencies["iota"], + vec![ + Requirement::from_str("beta").unwrap(), + Requirement::from_str("gamma").unwrap(), + Requirement::from_str("delta").unwrap() + ] + ); + } + + #[test] + fn parse_pyproject_toml_optional_dependencies_cycle() { + let source = r#" + [project] + name = "spam" + + [project.optional-dependencies] + alpha = ["spam[iota]"] + iota = ["spam[alpha]"] + "#; + let pyproject_toml = PyProjectToml::new(source).unwrap(); + assert_eq!( + pyproject_toml.resolve().unwrap_err().to_string(), + "Cycles are not supported: extra:alpha -> extra:iota -> extra:alpha" + ) + } + + #[test] + fn parse_pyproject_toml_optional_dependencies_missing_include() { + let source = r#" + [project] + name = "spam" + + [project.optional-dependencies] + iota = ["spam[alpha]"] + "#; + let pyproject_toml = PyProjectToml::new(source).unwrap(); + assert_eq!( + pyproject_toml.resolve().unwrap_err().to_string(), + "Failed to find optional dependency `alpha` included by extra:iota" + ) + } + + #[test] + fn parse_pyproject_toml_optional_dependencies_missing_top_level() { + let source = r#" + [project] + name = "spam" + + [project.optional-dependencies] + alpha = ["beta"] + "#; + let pyproject_toml = PyProjectToml::new(source).unwrap(); + let mut resolved = ResolvedDependencies::default(); + let err = resolve_optional_dependency( + "foo", + pyproject_toml + .project + .as_ref() + .unwrap() + .optional_dependencies + .as_ref() + .unwrap(), + &mut resolved, + &mut vec![Item::Extra("bar".to_string())], + Some("spam"), + ) + .unwrap_err(); + assert_eq!( + err.to_string(), + "Failed to find optional dependency `foo` included by extra:bar" + ); + } + + #[test] + fn 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 pyproject_toml = PyProjectToml::new(source).unwrap(); + let resolved_dependencies = pyproject_toml.resolve().unwrap(); + + assert_eq!( + resolved_dependencies.dependency_groups["iota"], + vec![ + Requirement::from_str("beta").unwrap(), + Requirement::from_str("gamma").unwrap(), + Requirement::from_str("delta").unwrap() + ] + ); + } + + #[test] + fn parse_pyproject_toml_dependency_groups_cycle() { + let source = r#" + [dependency-groups] + alpha = [{include-group = "iota"}] + iota = [{include-group = "alpha"}] + "#; + let pyproject_toml = PyProjectToml::new(source).unwrap(); + assert_eq!( + pyproject_toml.resolve().unwrap_err().to_string(), + "Cycles are not supported: group:alpha -> group:iota -> group:alpha" + ) + } + + #[test] + fn parse_pyproject_toml_dependency_groups_missing_include() { + let source = r#" + [dependency-groups] + iota = [{include-group = "alpha"}] + "#; + let pyproject_toml = PyProjectToml::new(source).unwrap(); + assert_eq!( + pyproject_toml.resolve().unwrap_err().to_string(), + "Failed to find dependency group `alpha` included by group:iota" + ) + } + + #[test] + fn parse_pyproject_toml_dependency_groups_with_optional_dependencies() { + let source = r#" + [project] + name = "spam" + + [project.optional-dependencies] + test = ["pytest"] + + [dependency-groups] + dev = ["spam[test]"] + "#; + let pyproject_toml = PyProjectToml::new(source).unwrap(); + let resolved_dependencies = pyproject_toml.resolve().unwrap(); + assert_eq!( + resolved_dependencies.dependency_groups["dev"], + vec![Requirement::from_str("pytest").unwrap()] + ); + } + + #[test] + fn name_collision() { + let source = r#" + [project] + name = "spam" + + [project.optional-dependencies] + dev = ["pytest"] + + [dependency-groups] + dev = ["ruff"] + "#; + let pyproject_toml = PyProjectToml::new(source).unwrap(); + let resolved_dependencies = pyproject_toml.resolve().unwrap(); + assert_eq!( + resolved_dependencies.optional_dependencies["dev"], + vec![Requirement::from_str("pytest").unwrap()] + ); + assert_eq!( + resolved_dependencies.dependency_groups["dev"], + vec![Requirement::from_str("ruff").unwrap()] + ); + } + + #[test] + fn optional_dependencies_are_not_dependency_groups() { + let source = r#" + [project] + name = "spam" + + [project.optional-dependencies] + test = ["pytest"] + + [dependency-groups] + dev = ["spam[test]"] + "#; + let pyproject_toml = PyProjectToml::new(source).unwrap(); + let resolved_dependencies = pyproject_toml.resolve().unwrap(); + assert!(resolved_dependencies + .optional_dependencies + .contains_key("test")); + assert!(!resolved_dependencies.dependency_groups.contains_key("test")); + assert!(resolved_dependencies.dependency_groups.contains_key("dev")); + } + + #[test] + fn mixed_resolution() { + let source = r#" + [project] + name = "spam" + + [project.optional-dependencies] + test = ["pytest"] + numpy = ["numpy"] + + [dependency-groups] + dev = ["spam[test]"] + test = ["spam[numpy]"] + "#; + let pyproject_toml = PyProjectToml::new(source).unwrap(); + let resolved_dependencies = pyproject_toml.resolve().unwrap(); + assert_eq!( + resolved_dependencies.dependency_groups["dev"], + vec![Requirement::from_str("pytest").unwrap()] + ); + assert_eq!( + resolved_dependencies.dependency_groups["test"], + vec![Requirement::from_str("numpy").unwrap()] + ); + } +}