Skip to main content

normalize_manifest/
setup_cfg.rs

1//! Parser for `setup.cfg` files (Python / setuptools).
2//!
3//! Handles:
4//! - `[metadata]` section: `name`, `version`
5//! - `[options]` section: `install_requires` (multi-line list)
6//! - `[options.extras_require]` section: optional dep groups → `DepKind::Optional`
7
8use crate::pip::parse_pip_requirement;
9use crate::{DeclaredDep, DepKind, ManifestError, ManifestParser, ParsedManifest};
10
11/// Parser for `setup.cfg` files.
12pub struct SetupCfgParser;
13
14impl ManifestParser for SetupCfgParser {
15    fn filename(&self) -> &'static str {
16        "setup.cfg"
17    }
18
19    fn parse(&self, content: &str) -> Result<ParsedManifest, ManifestError> {
20        let mut name = None;
21        let mut version = None;
22        let mut deps: Vec<DeclaredDep> = Vec::new();
23
24        #[derive(PartialEq)]
25        enum Section {
26            Other,
27            Metadata,
28            Options,
29            Extras,
30        }
31
32        let mut section = Section::Other;
33        let mut collecting_requires = false;
34        let mut current_extras_kind = DepKind::Optional;
35
36        for line in content.lines() {
37            let trimmed = line.trim();
38
39            // Skip comments and blank lines (but blank lines reset continuation)
40            if trimmed.is_empty() {
41                collecting_requires = false;
42                continue;
43            }
44            if trimmed.starts_with('#') || trimmed.starts_with(';') {
45                continue;
46            }
47
48            // Section header
49            if trimmed.starts_with('[') && trimmed.ends_with(']') {
50                collecting_requires = false;
51                let sec = &trimmed[1..trimmed.len() - 1];
52                section = match sec {
53                    "metadata" => Section::Metadata,
54                    "options" => Section::Options,
55                    s if s.starts_with("options.extras_require") => {
56                        current_extras_kind = DepKind::Optional;
57                        Section::Extras
58                    }
59                    _ => Section::Other,
60                };
61                continue;
62            }
63
64            // Continuation line (indented) — belongs to previous key
65            if line.starts_with([' ', '\t']) && collecting_requires {
66                let kind = if section == Section::Extras {
67                    current_extras_kind
68                } else {
69                    DepKind::Normal
70                };
71                if let Some(dep) = parse_pip_requirement(trimmed) {
72                    deps.push(DeclaredDep { kind, ..dep });
73                }
74                continue;
75            }
76
77            // Key = value
78            if let Some(eq_idx) = trimmed.find('=') {
79                collecting_requires = false;
80                let key = trimmed[..eq_idx].trim();
81                let value = trimmed[eq_idx + 1..].trim();
82
83                match section {
84                    Section::Metadata => match key {
85                        "name" => name = Some(value.to_string()),
86                        "version" => version = Some(value.to_string()),
87                        _ => {}
88                    },
89                    Section::Options => {
90                        if key == "install_requires" {
91                            collecting_requires = true;
92                            // Inline value (uncommon but possible)
93                            if !value.is_empty()
94                                && let Some(dep) = parse_pip_requirement(value)
95                            {
96                                deps.push(dep);
97                            }
98                        }
99                    }
100                    Section::Extras => {
101                        // extras_require section: each key is a group name (e.g. `dev =`)
102                        // The value may be inline or multi-line
103                        collecting_requires = true;
104                        if !value.is_empty()
105                            && let Some(dep) = parse_pip_requirement(value)
106                        {
107                            deps.push(DeclaredDep {
108                                kind: DepKind::Optional,
109                                ..dep
110                            });
111                        }
112                    }
113                    Section::Other => {}
114                }
115            }
116        }
117
118        Ok(ParsedManifest {
119            ecosystem: "python",
120            name,
121            version,
122            dependencies: deps,
123        })
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130    use crate::ManifestParser;
131
132    #[test]
133    fn test_parse_setup_cfg() {
134        let content = r#"[metadata]
135name = my-package
136version = 1.2.3
137
138[options]
139install_requires =
140    requests>=2.28
141    flask>=2.0
142    numpy
143
144[options.extras_require]
145dev =
146    pytest
147    black
148"#;
149        let m = SetupCfgParser.parse(content).unwrap();
150        assert_eq!(m.ecosystem, "python");
151        assert_eq!(m.name.as_deref(), Some("my-package"));
152        assert_eq!(m.version.as_deref(), Some("1.2.3"));
153
154        let normal: Vec<_> = m
155            .dependencies
156            .iter()
157            .filter(|d| d.kind == DepKind::Normal)
158            .collect();
159        assert_eq!(normal.len(), 3);
160        assert!(normal.iter().any(|d| d.name == "requests"));
161        assert!(normal.iter().any(|d| d.name == "flask"));
162        assert!(normal.iter().any(|d| d.name == "numpy"));
163
164        let optional: Vec<_> = m
165            .dependencies
166            .iter()
167            .filter(|d| d.kind == DepKind::Optional)
168            .collect();
169        assert_eq!(optional.len(), 2);
170    }
171}