normalize_manifest/
pip.rs1use crate::{DeclaredDep, DepKind, ManifestError, ManifestParser, ParsedManifest};
4
5pub struct PipParser;
7
8impl ManifestParser for PipParser {
9 fn filename(&self) -> &'static str {
10 "requirements.txt"
11 }
12
13 fn parse(&self, content: &str) -> Result<ParsedManifest, ManifestError> {
14 let mut deps = Vec::new();
15
16 for line in content.lines() {
17 let line = line.trim();
18 if line.is_empty() || line.starts_with('#') || line.starts_with('-') {
20 continue;
21 }
22 let line = match line.find(" #") {
24 Some(idx) => line[..idx].trim(),
25 None => line,
26 };
27 if let Some(dep) = parse_pip_requirement(line) {
28 deps.push(dep);
29 }
30 }
31
32 Ok(ParsedManifest {
33 ecosystem: "pip",
34 name: None,
35 version: None,
36 dependencies: deps,
37 })
38 }
39}
40
41pub(crate) fn parse_pip_requirement(line: &str) -> Option<DeclaredDep> {
42 let line = line.trim();
43 if line.is_empty() {
44 return None;
45 }
46 const OPERATORS: &[&str] = &["===", "~=", "==", "!=", ">=", "<=", ">", "<"];
48 for op in OPERATORS {
49 if let Some(idx) = line.find(op) {
50 let name = line[..idx].trim().to_string();
51 if name.is_empty() {
52 continue;
53 }
54 let version_req = Some(line[idx..].trim().to_string());
55 return Some(DeclaredDep {
56 name,
57 version_req,
58 kind: DepKind::Normal,
59 });
60 }
61 }
62 if !line.is_empty() {
64 return Some(DeclaredDep {
65 name: line.to_string(),
66 version_req: None,
67 kind: DepKind::Normal,
68 });
69 }
70 None
71}
72
73#[cfg(test)]
74mod tests {
75 use super::*;
76 use crate::ManifestParser;
77
78 #[test]
79 fn test_parse_requirements_txt() {
80 let content = r#"# Production dependencies
81requests==2.28.0
82flask>=2.0
83numpy # scientific computing
84# dev
85pytest
86"#;
87 let m = PipParser.parse(content).unwrap();
88 assert_eq!(m.ecosystem, "pip");
89 assert!(m.name.is_none());
90 assert_eq!(m.dependencies.len(), 4);
91
92 let requests = m
93 .dependencies
94 .iter()
95 .find(|d| d.name == "requests")
96 .unwrap();
97 assert_eq!(requests.version_req.as_deref(), Some("==2.28.0"));
98
99 let flask = m.dependencies.iter().find(|d| d.name == "flask").unwrap();
100 assert_eq!(flask.version_req.as_deref(), Some(">=2.0"));
101
102 let numpy = m.dependencies.iter().find(|d| d.name == "numpy").unwrap();
103 assert!(numpy.version_req.is_none());
104
105 let pytest = m.dependencies.iter().find(|d| d.name == "pytest").unwrap();
106 assert!(pytest.version_req.is_none());
107 }
108
109 #[test]
110 fn test_skip_pip_options() {
111 let content = "-r base.txt\n--index-url https://pypi.org\nrequests\n";
112 let m = PipParser.parse(content).unwrap();
113 assert_eq!(m.dependencies.len(), 1);
114 assert_eq!(m.dependencies[0].name, "requests");
115 }
116}