Skip to main content

code_moniker_core/lang/python/
build.rs

1#[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}