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
144pub fn package_moniker(project: &[u8], import_root: &str) -> crate::core::moniker::Moniker {
145 let mut b = crate::core::moniker::MonikerBuilder::new();
146 b.project(project);
147 b.segment(crate::lang::kinds::EXTERNAL_PKG, import_root.as_bytes());
148 b.build()
149}
150
151#[cfg(test)]
152mod tests {
153 use super::*;
154
155 #[test]
156 fn parse_pep621_project_emits_package_row() {
157 let src = r#"
158 [project]
159 name = "demo"
160 version = "0.2.0"
161 "#;
162 let deps = parse(src).unwrap();
163 assert!(deps.contains(&Dep {
164 name: "demo".into(),
165 version: Some("0.2.0".into()),
166 dep_kind: "package".into(),
167 import_root: "demo".into(),
168 }));
169 }
170
171 #[test]
172 fn parse_pep621_dependencies_keeps_version_constraints() {
173 let src = r#"
174 [project]
175 name = "demo"
176 version = "0.1.0"
177 dependencies = ["httpx==0.27.2", "anyio>=3.7"]
178 "#;
179 let deps = parse(src).unwrap();
180 let httpx = deps.iter().find(|d| d.name == "httpx").unwrap();
181 assert_eq!(httpx.version.as_deref(), Some("==0.27.2"));
182 assert_eq!(httpx.dep_kind, "normal");
183 let anyio = deps.iter().find(|d| d.name == "anyio").unwrap();
184 assert_eq!(anyio.version.as_deref(), Some(">=3.7"));
185 }
186
187 #[test]
188 fn parse_pep621_strips_extras_marker_and_environment() {
189 let src = r#"
190 [project]
191 name = "demo"
192 dependencies = ["requests[security]>=2.31; python_version >= '3.8'"]
193 "#;
194 let deps = parse(src).unwrap();
195 let req = deps.iter().find(|d| d.name == "requests").unwrap();
196 assert_eq!(req.version.as_deref(), Some(">=2.31"));
197 }
198
199 #[test]
200 fn parse_pep621_optional_dependencies_emit_grouped_kind() {
201 let src = r#"
202 [project]
203 name = "demo"
204 [project.optional-dependencies]
205 test = ["pytest>=7.0"]
206 docs = ["sphinx"]
207 "#;
208 let deps = parse(src).unwrap();
209 assert!(
210 deps.iter()
211 .any(|d| d.name == "pytest" && d.dep_kind == "optional:test")
212 );
213 assert!(
214 deps.iter()
215 .any(|d| d.name == "sphinx" && d.dep_kind == "optional:docs")
216 );
217 }
218
219 #[test]
220 fn parse_poetry_dependencies_skip_python_marker() {
221 let src = r#"
222 [tool.poetry]
223 name = "demo"
224 version = "0.1.0"
225 [tool.poetry.dependencies]
226 python = "^3.10"
227 httpx = "^0.27"
228 [tool.poetry.dev-dependencies]
229 pytest = "^7.0"
230 "#;
231 let deps = parse(src).unwrap();
232 assert!(!deps.iter().any(|d| d.name == "python"));
233 assert!(
234 deps.iter()
235 .any(|d| d.name == "httpx" && d.dep_kind == "normal")
236 );
237 assert!(
238 deps.iter()
239 .any(|d| d.name == "pytest" && d.dep_kind == "dev")
240 );
241 }
242
243 #[test]
244 fn parse_poetry_table_uses_version_field() {
245 let src = r#"
246 [tool.poetry]
247 name = "demo"
248 [tool.poetry.dependencies]
249 sqlalchemy = { version = "^2.0", extras = ["asyncio"] }
250 "#;
251 let deps = parse(src).unwrap();
252 let sa = deps.iter().find(|d| d.name == "sqlalchemy").unwrap();
253 assert_eq!(sa.version.as_deref(), Some("^2.0"));
254 }
255
256 #[test]
257 fn parse_normalizes_hyphenated_import_root_to_underscore_lowercase() {
258 let src = r#"
259 [project]
260 name = "Some-Project"
261 dependencies = ["python-dateutil"]
262 "#;
263 let deps = parse(src).unwrap();
264 let proj = deps.iter().find(|d| d.dep_kind == "package").unwrap();
265 assert_eq!(proj.import_root, "some_project");
266 let pd = deps.iter().find(|d| d.name == "python-dateutil").unwrap();
267 assert_eq!(pd.import_root, "python_dateutil");
268 }
269
270 #[test]
271 fn parse_invalid_toml_returns_parse_error() {
272 assert!(matches!(parse("not toml ["), Err(PyprojectError::Parse(_))));
273 }
274}