libpfu_fixers/python/
depsolver.rs

1use std::fmt::Display;
2
3use anyhow::Result;
4use kstring::KString;
5use libabbs::apml::value::array::StringArray;
6use libpfu::Session;
7use log::{debug, error};
8use serde::Deserialize;
9
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct Dependency {
12	pub name: KString,
13	pub build_dep: bool,
14	pub origin: DependencyOrigin,
15	pub raw_req: String,
16}
17
18impl Dependency {
19	/// Extracts the package name out from a Python dependency requirement.
20	pub fn extract_name_from_req(req: &str) -> Option<KString> {
21		// exclude windows and OSX-only dependencies
22		if let Some((_, cond)) = req.split_once(';') {
23			let cond = cond.to_ascii_lowercase();
24			if cond.contains("platform_system")
25				&& (cond.contains("windows") || cond.contains("darwin"))
26			{
27				return None;
28			}
29		}
30
31		// remove version specifier, platform specifier and feature specifiers
32		let req = req
33			.split_once([' ', '>', '<', '~', '=', ';', '['])
34			.map_or(req, |(req, _)| req);
35		Some(KString::from_ref(req))
36	}
37
38	/// Normalizes the package name for AOSC naming style.
39	pub fn guess_aosc_package_name(&self) -> String {
40		self.name.replace('_', "-")
41	}
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum DependencyOrigin {
46	RequirementsTxt,
47	Pep517Dependencies,
48	Pep517BuildRequires,
49	Pep517BuildBackend,
50}
51
52impl Display for DependencyOrigin {
53	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54		match self {
55			DependencyOrigin::RequirementsTxt => {
56				f.write_str("requirements.txt")
57			}
58			DependencyOrigin::Pep517Dependencies => {
59				f.write_str("project.dependencies from pyproject.toml")
60			}
61			DependencyOrigin::Pep517BuildRequires => {
62				f.write_str("build-system.requires from pyproject.toml")
63			}
64			DependencyOrigin::Pep517BuildBackend => {
65				f.write_str("build-system.build-backend from pyproject.toml")
66			}
67		}
68	}
69}
70
71pub async fn collect_deps(sess: &Session) -> Result<Vec<Dependency>> {
72	debug!("collecting Python dependencies of {:?}", sess.package);
73
74	if let Ok(pyproj_str) = sess.source_fs().await?.read("pyproject.toml").await
75	{
76		debug!("pyproject.toml found in {:?}", sess.package);
77		collect_from_pyproject(&String::from_utf8(pyproj_str.to_vec())?)
78	} else if let Ok(req_txt_str) =
79		sess.source_fs().await?.read("requirements.txt").await
80	{
81		debug!("requirements.txt found in {:?}", sess.package);
82		collect_from_requirementstxt(&String::from_utf8(req_txt_str.to_vec())?)
83	} else {
84		Ok(vec![])
85	}
86}
87
88fn collect_from_pyproject(pyproject_str: &str) -> Result<Vec<Dependency>> {
89	let pyproject = toml::from_str::<PyprojectToml>(pyproject_str)?;
90	debug!("Parsed pyproject.toml: {pyproject:?}");
91
92	let mut py_deps = vec![];
93	for raw_req in pyproject.project.dependencies {
94		if let Some(name) = Dependency::extract_name_from_req(&raw_req) {
95			py_deps.push(Dependency {
96				name,
97				build_dep: false,
98				origin: DependencyOrigin::Pep517Dependencies,
99				raw_req,
100			});
101		}
102	}
103	for raw_req in pyproject.build_system.requires {
104		if let Some(name) = Dependency::extract_name_from_req(&raw_req) {
105			py_deps.push(Dependency {
106				name,
107				build_dep: true,
108				origin: DependencyOrigin::Pep517BuildRequires,
109				raw_req,
110			});
111		}
112	}
113	if let Some(backend) = pyproject.build_system.build_backend {
114		py_deps.push(Dependency {
115			name: KString::from_ref(
116				backend.split_once('.').map_or(backend.as_str(), |(s, _)| s),
117			),
118			build_dep: true,
119			origin: DependencyOrigin::Pep517BuildBackend,
120			raw_req: backend,
121		});
122	}
123
124	Ok(py_deps)
125}
126
127fn collect_from_requirementstxt(req_txt_str: &str) -> Result<Vec<Dependency>> {
128	Ok(req_txt_str
129		.lines()
130		.map(|s| s.split_once('#').map_or(s, |(s, _)| s))
131		.map(|s| s.trim())
132		.filter(|s| !s.is_empty())
133		.filter_map(|raw_req| {
134			Dependency::extract_name_from_req(raw_req).map(|name| Dependency {
135				name,
136				build_dep: false,
137				origin: DependencyOrigin::RequirementsTxt,
138				raw_req: raw_req.to_string(),
139			})
140		})
141		.collect())
142}
143
144#[derive(Debug, Deserialize)]
145struct PyprojectToml {
146	#[serde(default)]
147	project: PyprojectProject,
148	#[serde(default, rename = "build-system")]
149	build_system: PyprojectBuildSystem,
150}
151
152#[derive(Debug, Deserialize, Default)]
153struct PyprojectProject {
154	#[serde(default)]
155	dependencies: Vec<String>,
156}
157
158#[derive(Debug, Deserialize, Default)]
159struct PyprojectBuildSystem {
160	#[serde(default, rename = "build-backend")]
161	build_backend: Option<String>,
162	#[serde(default, rename = "requires")]
163	requires: Vec<String>,
164}
165
166/// Finds the system package which provides a certain Python package.
167pub async fn find_system_package(
168	dep: &Dependency,
169	pkgdep: &StringArray,
170	builddep: &StringArray,
171) -> Result<Option<String>> {
172	let find_dep = |pkg: &str| {
173		if pkgdep.iter().any(|dep| dep == pkg) {
174			debug!(
175				"Matched Python dependency package in PKGDEP: {} -> {}",
176				dep.name, pkg
177			);
178			return true;
179		}
180		if dep.build_dep && builddep.iter().any(|dep| dep == pkg) {
181			debug!(
182				"Matched Python dependency package in BUILDDEP: {} -> {}",
183				dep.name, pkg
184			);
185			return true;
186		}
187		false
188	};
189
190	// Find in current dependencies
191	let aosc_package_name = dep.guess_aosc_package_name();
192	if find_dep(&aosc_package_name) {
193		debug!(
194			"Matched Python dependency through name-normalization: {}",
195			dep.name
196		);
197		return Ok(Some(aosc_package_name));
198	}
199
200	// Find in apt database
201	let mut found = None;
202	match oma_contents::searcher::search(
203		"/var/lib/apt/lists",
204		oma_contents::searcher::Mode::Provides,
205		&if dep.name.contains('-') {
206			format!("/site-packages/{}/", dep.name.replace('-', "_"))
207		} else {
208			format!("/site-packages/{}/", dep.name)
209		},
210		|(pkg, path)| {
211			if path.starts_with("/usr/lib/python") {
212				found = Some(pkg)
213			}
214		},
215	) {
216		Ok(()) => {
217			match &found {
218				Some(pkg) => debug!(
219					"Found system package for Python package: {} -> {}",
220					dep.name, pkg
221				),
222				None => debug!(
223					"No system package was found for Python package: {}",
224					dep.name
225				),
226			}
227			Ok(found)
228		}
229		Err(err) => {
230			error!(
231				"Failed to find system package for Python package {}: {:?}",
232				dep.name, err
233			);
234			Ok(None)
235		}
236	}
237}
238
239#[cfg(test)]
240mod test {
241	use super::*;
242
243	#[test]
244	fn test_extract_name_from_req() {
245		assert!(
246			Dependency::extract_name_from_req("a; platform_system=windows")
247				.is_none()
248		);
249		assert_eq!(Dependency::extract_name_from_req("a").unwrap(), "a");
250		assert_eq!(Dependency::extract_name_from_req("a; b").unwrap(), "a");
251		assert_eq!(Dependency::extract_name_from_req("a ; b").unwrap(), "a");
252		assert_eq!(Dependency::extract_name_from_req("a== 1.0").unwrap(), "a");
253		assert_eq!(Dependency::extract_name_from_req("a~= 1.0").unwrap(), "a");
254		assert_eq!(Dependency::extract_name_from_req("a>= 1.0").unwrap(), "a");
255		assert_eq!(Dependency::extract_name_from_req("a< 1.0").unwrap(), "a");
256	}
257
258	#[test]
259	fn test_collect_from_pyproject() {
260		assert_eq!(
261			collect_from_pyproject(
262				r##"
263[build-system]
264requires = ["flit-core"]
265build-backend = "flit_core.buildapi"
266
267[project]
268dependencies = [
269    "packaging>=23.2",
270    "wheels; platform_system=windows",
271]
272"##
273			)
274			.unwrap(),
275			vec![
276				Dependency {
277					name: "packaging".into(),
278					build_dep: false,
279					origin: DependencyOrigin::Pep517Dependencies,
280					raw_req: "packaging>=23.2".into(),
281				},
282				Dependency {
283					name: "flit-core".into(),
284					build_dep: true,
285					origin: DependencyOrigin::Pep517BuildRequires,
286					raw_req: "flit-core".into(),
287				},
288				Dependency {
289					name: "flit_core".into(),
290					build_dep: true,
291					origin: DependencyOrigin::Pep517BuildBackend,
292					raw_req: "flit_core.buildapi".into(),
293				}
294			]
295		);
296	}
297
298	#[test]
299	fn test_collect_from_requirementstxt() {
300		assert_eq!(
301			collect_from_requirementstxt(
302				r##"beautifulsoup4==4.5.1
303decorator==4.0.10
304requests
305pip~=100.0
306a[b]
307"##
308			)
309			.unwrap(),
310			vec![
311				Dependency {
312					name: "beautifulsoup4".into(),
313					build_dep: false,
314					origin: DependencyOrigin::RequirementsTxt,
315					raw_req: "beautifulsoup4==4.5.1".into(),
316				},
317				Dependency {
318					name: "decorator".into(),
319					build_dep: false,
320					origin: DependencyOrigin::RequirementsTxt,
321					raw_req: "decorator==4.0.10".into(),
322				},
323				Dependency {
324					name: "requests".into(),
325					build_dep: false,
326					origin: DependencyOrigin::RequirementsTxt,
327					raw_req: "requests".into(),
328				},
329				Dependency {
330					name: "pip".into(),
331					build_dep: false,
332					origin: DependencyOrigin::RequirementsTxt,
333					raw_req: "pip~=100.0".into(),
334				},
335				Dependency {
336					name: "a".into(),
337					build_dep: false,
338					origin: DependencyOrigin::RequirementsTxt,
339					raw_req: "a[b]".into(),
340				},
341			]
342		);
343	}
344}