Skip to main content

normalize_manifest/
pyproject.rs

1//! Parser for `pyproject.toml` files (PEP 621 / Poetry).
2
3use crate::{DeclaredDep, DepKind, ManifestError, ManifestParser, ParsedManifest};
4use toml::Value;
5
6/// Parser for `pyproject.toml` files.
7///
8/// Supports both PEP 621 (`[project.dependencies]`) and
9/// Poetry (`[tool.poetry.dependencies]`).
10pub struct PyprojectParser;
11
12impl ManifestParser for PyprojectParser {
13    fn filename(&self) -> &'static str {
14        "pyproject.toml"
15    }
16
17    fn parse(&self, content: &str) -> Result<ParsedManifest, ManifestError> {
18        let toml: Value = content
19            .parse::<Value>()
20            .map_err(|e| ManifestError(e.to_string()))?;
21
22        // PEP 621: [project]
23        let project = toml.get("project");
24        let mut name = project
25            .and_then(|p| p.get("name"))
26            .and_then(|v| v.as_str())
27            .map(|s| s.to_string());
28        let mut version = project
29            .and_then(|p| p.get("version"))
30            .and_then(|v| v.as_str())
31            .map(|s| s.to_string());
32
33        let mut deps = Vec::new();
34
35        // PEP 621: [project.dependencies] — array of PEP 508 strings
36        if let Some(arr) = project
37            .and_then(|p| p.get("dependencies"))
38            .and_then(|v| v.as_array())
39        {
40            for item in arr {
41                if let Some(s) = item.as_str()
42                    && let Some(dep) = parse_pep508_dep(s)
43                {
44                    deps.push(dep);
45                }
46            }
47        }
48
49        // Poetry: [tool.poetry]
50        let poetry = toml.get("tool").and_then(|t| t.get("poetry"));
51
52        if name.is_none() {
53            name = poetry
54                .and_then(|p| p.get("name"))
55                .and_then(|v| v.as_str())
56                .map(|s| s.to_string());
57        }
58        if version.is_none() {
59            version = poetry
60                .and_then(|p| p.get("version"))
61                .and_then(|v| v.as_str())
62                .map(|s| s.to_string());
63        }
64
65        // [tool.poetry.dependencies] — table of name → version/table
66        if let Some(poetry_deps) = poetry
67            .and_then(|p| p.get("dependencies"))
68            .and_then(|v| v.as_table())
69        {
70            for (dep_name, val) in poetry_deps {
71                if dep_name == "python" {
72                    continue; // python version constraint, not a package dep
73                }
74                let version_req = if let Some(s) = val.as_str() {
75                    Some(s.to_string())
76                } else if let Some(t) = val.as_table() {
77                    t.get("version")
78                        .and_then(|v| v.as_str())
79                        .map(|s| s.to_string())
80                } else {
81                    None
82                };
83                deps.push(DeclaredDep {
84                    name: dep_name.clone(),
85                    version_req,
86                    kind: DepKind::Normal,
87                });
88            }
89        }
90
91        // [tool.poetry.dev-dependencies]
92        if let Some(dev_deps) = poetry
93            .and_then(|p| p.get("dev-dependencies"))
94            .and_then(|v| v.as_table())
95        {
96            for (dep_name, val) in dev_deps {
97                let version_req = if let Some(s) = val.as_str() {
98                    Some(s.to_string())
99                } else if let Some(t) = val.as_table() {
100                    t.get("version")
101                        .and_then(|v| v.as_str())
102                        .map(|s| s.to_string())
103                } else {
104                    None
105                };
106                deps.push(DeclaredDep {
107                    name: dep_name.clone(),
108                    version_req,
109                    kind: DepKind::Dev,
110                });
111            }
112        }
113
114        Ok(ParsedManifest {
115            ecosystem: "python",
116            name,
117            version,
118            dependencies: deps,
119        })
120    }
121}
122
123/// Parse a PEP 508 dependency string (e.g., `"requests>=2.28,<3"`, `"flask"`).
124fn parse_pep508_dep(s: &str) -> Option<DeclaredDep> {
125    let s = s.trim();
126    if s.is_empty() {
127        return None;
128    }
129    // Strip environment markers after `;`
130    let s = match s.find(';') {
131        Some(idx) => s[..idx].trim(),
132        None => s,
133    };
134    // Split on version operators
135    const OPERATORS: &[&str] = &["===", "~=", "==", "!=", ">=", "<=", ">", "<"];
136    for op in OPERATORS {
137        if let Some(idx) = s.find(op) {
138            let name = s[..idx].trim().to_string();
139            if !name.is_empty() {
140                return Some(DeclaredDep {
141                    name,
142                    version_req: Some(s[idx..].trim().to_string()),
143                    kind: DepKind::Normal,
144                });
145            }
146        }
147    }
148    if !s.is_empty() {
149        return Some(DeclaredDep {
150            name: s.to_string(),
151            version_req: None,
152            kind: DepKind::Normal,
153        });
154    }
155    None
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161    use crate::ManifestParser;
162
163    #[test]
164    fn test_pep621() {
165        let content = r#"
166[project]
167name = "my-package"
168version = "1.0.0"
169dependencies = [
170    "requests>=2.28",
171    "flask",
172    "numpy==1.24.0",
173]
174"#;
175        let m = PyprojectParser.parse(content).unwrap();
176        assert_eq!(m.ecosystem, "python");
177        assert_eq!(m.name.as_deref(), Some("my-package"));
178        assert_eq!(m.version.as_deref(), Some("1.0.0"));
179        assert_eq!(m.dependencies.len(), 3);
180
181        let requests = m
182            .dependencies
183            .iter()
184            .find(|d| d.name == "requests")
185            .unwrap();
186        assert_eq!(requests.version_req.as_deref(), Some(">=2.28"));
187    }
188
189    #[test]
190    fn test_poetry() {
191        let content = r#"
192[tool.poetry]
193name = "poetry-app"
194version = "0.5.0"
195
196[tool.poetry.dependencies]
197python = "^3.9"
198requests = "^2.28"
199click = "^8.0"
200
201[tool.poetry.dev-dependencies]
202pytest = "^7.0"
203"#;
204        let m = PyprojectParser.parse(content).unwrap();
205        assert_eq!(m.ecosystem, "python");
206        assert_eq!(m.name.as_deref(), Some("poetry-app"));
207        assert_eq!(m.version.as_deref(), Some("0.5.0"));
208
209        let normal: Vec<_> = m
210            .dependencies
211            .iter()
212            .filter(|d| d.kind == DepKind::Normal)
213            .collect();
214        // python is skipped
215        assert_eq!(normal.len(), 2);
216
217        let dev: Vec<_> = m
218            .dependencies
219            .iter()
220            .filter(|d| d.kind == DepKind::Dev)
221            .collect();
222        assert_eq!(dev.len(), 1);
223        assert_eq!(dev[0].name, "pytest");
224    }
225}