normalize_manifest/
pyproject.rs1use crate::{DeclaredDep, DepKind, ManifestError, ManifestParser, ParsedManifest};
4use toml::Value;
5
6pub 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 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 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 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 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; }
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 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
123fn parse_pep508_dep(s: &str) -> Option<DeclaredDep> {
125 let s = s.trim();
126 if s.is_empty() {
127 return None;
128 }
129 let s = match s.find(';') {
131 Some(idx) => s[..idx].trim(),
132 None => s,
133 };
134 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 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}