code_moniker_core/lang/python/
build.rs1#[derive(Clone, Debug, Eq, PartialEq)]
2pub struct Dep {
3 pub name: String,
4 pub version: Option<String>,
5 pub dep_kind: String,
6 pub import_root: String,
7}
8
9#[derive(Debug)]
10pub enum PyprojectError {
11 Parse(toml::de::Error),
12 Schema(String),
13}
14
15impl std::fmt::Display for PyprojectError {
16 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
17 match self {
18 Self::Parse(e) => write!(f, "pyproject.toml parse error: {e}"),
19 Self::Schema(s) => write!(f, "pyproject.toml schema error: {s}"),
20 }
21 }
22}
23
24impl std::error::Error for PyprojectError {}
25
26pub fn parse(content: &str) -> Result<Vec<Dep>, PyprojectError> {
27 let value: toml::Value = toml::from_str(content).map_err(PyprojectError::Parse)?;
28 let mut out = Vec::new();
29
30 if let Some(project) = value.get("project").and_then(|v| v.as_table()) {
31 if let Some(name) = project.get("name").and_then(|v| v.as_str()) {
32 let version = project
33 .get("version")
34 .and_then(|v| v.as_str())
35 .map(str::to_string);
36 out.push(Dep {
37 name: name.to_string(),
38 version,
39 dep_kind: "package".to_string(),
40 import_root: python_import_root(name),
41 });
42 }
43 if let Some(deps) = project.get("dependencies").and_then(|v| v.as_array()) {
44 for spec in deps.iter().filter_map(|v| v.as_str()) {
45 if let Some(dep) = parse_pep508(spec, "normal") {
46 out.push(dep);
47 }
48 }
49 }
50 if let Some(opt) = project
51 .get("optional-dependencies")
52 .and_then(|v| v.as_table())
53 {
54 for (group, list) in opt {
55 let Some(arr) = list.as_array() else { continue };
56 let kind = format!("optional:{group}");
57 for spec in arr.iter().filter_map(|v| v.as_str()) {
58 if let Some(dep) = parse_pep508(spec, &kind) {
59 out.push(dep);
60 }
61 }
62 }
63 }
64 }
65
66 if let Some(poetry) = value
67 .get("tool")
68 .and_then(|t| t.get("poetry"))
69 .and_then(|p| p.as_table())
70 {
71 if out.iter().all(|d| d.dep_kind != "package")
72 && let Some(name) = poetry.get("name").and_then(|v| v.as_str())
73 {
74 let version = poetry
75 .get("version")
76 .and_then(|v| v.as_str())
77 .map(str::to_string);
78 out.push(Dep {
79 name: name.to_string(),
80 version,
81 dep_kind: "package".to_string(),
82 import_root: python_import_root(name),
83 });
84 }
85 for (table_key, kind_label) in [("dependencies", "normal"), ("dev-dependencies", "dev")] {
86 let Some(table) = poetry.get(table_key).and_then(|v| v.as_table()) else {
87 continue;
88 };
89 for (name, spec) in table {
90 if name == "python" {
91 continue;
92 }
93 let version = crate::lang::rs::build::extract_version(spec);
94 out.push(Dep {
95 name: name.clone(),
96 version,
97 dep_kind: kind_label.to_string(),
98 import_root: python_import_root(name),
99 });
100 }
101 }
102 }
103
104 Ok(out)
105}
106
107fn parse_pep508(spec: &str, dep_kind: &str) -> Option<Dep> {
108 let trimmed = spec.split(';').next()?.trim();
109 if trimmed.is_empty() {
110 return None;
111 }
112 let mut name_end = trimmed.len();
113 for (i, ch) in trimmed.char_indices() {
114 if matches!(ch, '=' | '<' | '>' | '!' | '~' | ' ' | '\t' | '[' | '(') {
115 name_end = i;
116 break;
117 }
118 }
119 let name = trimmed[..name_end].trim();
120 if name.is_empty() {
121 return None;
122 }
123 let after_extras = match trimmed[name_end..].find(']') {
124 Some(close) => trimmed[name_end + close + 1..].trim(),
125 None => trimmed[name_end..].trim(),
126 };
127 let version = if after_extras.is_empty() {
128 None
129 } else {
130 Some(after_extras.trim().to_string())
131 };
132 Some(Dep {
133 name: name.to_string(),
134 version,
135 dep_kind: dep_kind.to_string(),
136 import_root: python_import_root(name),
137 })
138}
139
140pub(crate) fn python_import_root(name: &str) -> String {
141 name.replace('-', "_").to_ascii_lowercase()
142}
143
144#[cfg(test)]
145mod tests {
146 use super::*;
147
148 #[test]
149 fn parse_pep621_project_emits_package_row() {
150 let src = r#"
151 [project]
152 name = "demo"
153 version = "0.2.0"
154 "#;
155 let deps = parse(src).unwrap();
156 assert!(deps.contains(&Dep {
157 name: "demo".into(),
158 version: Some("0.2.0".into()),
159 dep_kind: "package".into(),
160 import_root: "demo".into(),
161 }));
162 }
163
164 #[test]
165 fn parse_pep621_dependencies_keeps_version_constraints() {
166 let src = r#"
167 [project]
168 name = "demo"
169 version = "0.1.0"
170 dependencies = ["httpx==0.27.2", "anyio>=3.7"]
171 "#;
172 let deps = parse(src).unwrap();
173 let httpx = deps.iter().find(|d| d.name == "httpx").unwrap();
174 assert_eq!(httpx.version.as_deref(), Some("==0.27.2"));
175 assert_eq!(httpx.dep_kind, "normal");
176 let anyio = deps.iter().find(|d| d.name == "anyio").unwrap();
177 assert_eq!(anyio.version.as_deref(), Some(">=3.7"));
178 }
179
180 #[test]
181 fn parse_pep621_strips_extras_marker_and_environment() {
182 let src = r#"
183 [project]
184 name = "demo"
185 dependencies = ["requests[security]>=2.31; python_version >= '3.8'"]
186 "#;
187 let deps = parse(src).unwrap();
188 let req = deps.iter().find(|d| d.name == "requests").unwrap();
189 assert_eq!(req.version.as_deref(), Some(">=2.31"));
190 }
191
192 #[test]
193 fn parse_pep621_optional_dependencies_emit_grouped_kind() {
194 let src = r#"
195 [project]
196 name = "demo"
197 [project.optional-dependencies]
198 test = ["pytest>=7.0"]
199 docs = ["sphinx"]
200 "#;
201 let deps = parse(src).unwrap();
202 assert!(
203 deps.iter()
204 .any(|d| d.name == "pytest" && d.dep_kind == "optional:test")
205 );
206 assert!(
207 deps.iter()
208 .any(|d| d.name == "sphinx" && d.dep_kind == "optional:docs")
209 );
210 }
211
212 #[test]
213 fn parse_poetry_dependencies_skip_python_marker() {
214 let src = r#"
215 [tool.poetry]
216 name = "demo"
217 version = "0.1.0"
218 [tool.poetry.dependencies]
219 python = "^3.10"
220 httpx = "^0.27"
221 [tool.poetry.dev-dependencies]
222 pytest = "^7.0"
223 "#;
224 let deps = parse(src).unwrap();
225 assert!(!deps.iter().any(|d| d.name == "python"));
226 assert!(
227 deps.iter()
228 .any(|d| d.name == "httpx" && d.dep_kind == "normal")
229 );
230 assert!(
231 deps.iter()
232 .any(|d| d.name == "pytest" && d.dep_kind == "dev")
233 );
234 }
235
236 #[test]
237 fn parse_poetry_table_uses_version_field() {
238 let src = r#"
239 [tool.poetry]
240 name = "demo"
241 [tool.poetry.dependencies]
242 sqlalchemy = { version = "^2.0", extras = ["asyncio"] }
243 "#;
244 let deps = parse(src).unwrap();
245 let sa = deps.iter().find(|d| d.name == "sqlalchemy").unwrap();
246 assert_eq!(sa.version.as_deref(), Some("^2.0"));
247 }
248
249 #[test]
250 fn parse_normalizes_hyphenated_import_root_to_underscore_lowercase() {
251 let src = r#"
252 [project]
253 name = "Some-Project"
254 dependencies = ["python-dateutil"]
255 "#;
256 let deps = parse(src).unwrap();
257 let proj = deps.iter().find(|d| d.dep_kind == "package").unwrap();
258 assert_eq!(proj.import_root, "some_project");
259 let pd = deps.iter().find(|d| d.name == "python-dateutil").unwrap();
260 assert_eq!(pd.import_root, "python_dateutil");
261 }
262
263 #[test]
264 fn parse_invalid_toml_returns_parse_error() {
265 assert!(matches!(parse("not toml ["), Err(PyprojectError::Parse(_))));
266 }
267}