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
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}