python_check_updates/parsers/
requirements.rs1use super::{Dependency, DependencyParser};
2use crate::version::VersionSpec;
3use anyhow::{Context, Result};
4use std::fs;
5use std::path::PathBuf;
6
7pub struct RequirementsParser;
9
10impl RequirementsParser {
11 pub fn new() -> Self {
12 Self
13 }
14
15 fn parse_line(line: &str, line_number: usize, source_file: &PathBuf) -> Option<Dependency> {
17 let original_line = line.to_string();
18 let line = line.trim();
19
20 if line.is_empty() {
22 return None;
23 }
24
25 if line.starts_with('#') {
27 return None;
28 }
29
30 if line.starts_with("-r ") || line.starts_with("-r\t") {
32 return None;
33 }
34
35 if line.starts_with("--") || line.starts_with('-') {
37 return None;
38 }
39
40 let line_without_marker = if let Some(idx) = line.find(';') {
43 line[..idx].trim()
44 } else {
45 line
46 };
47
48 let line_clean = if let Some(idx) = line_without_marker.find('#') {
50 line_without_marker[..idx].trim()
51 } else {
52 line_without_marker
53 };
54
55 if line_clean.is_empty() {
56 return None;
57 }
58
59 let (package_with_extras, version_str) = Self::split_package_version(line_clean)?;
62
63 let package_name = if let Some(bracket_idx) = package_with_extras.find('[') {
65 package_with_extras[..bracket_idx].trim()
66 } else {
67 package_with_extras
68 };
69
70 let normalized_name = package_name.to_lowercase().replace('_', "-");
72
73 let version_spec = if version_str.is_empty() {
75 VersionSpec::Any
76 } else {
77 match VersionSpec::parse(version_str) {
78 Ok(spec) => spec,
79 Err(_) => {
80 VersionSpec::Complex(version_str.to_string())
82 }
83 }
84 };
85
86 Some(Dependency {
87 name: normalized_name,
88 version_spec,
89 source_file: source_file.clone(),
90 line_number,
91 original_line,
92 })
93 }
94
95 fn split_package_version(spec: &str) -> Option<(&str, &str)> {
98 let operators = ["==", ">=", "<=", "~=", "!=", ">", "<"];
101
102 let mut first_op_idx = None;
104 for op in &operators {
105 if let Some(idx) = spec.find(op) {
106 let before = &spec[..idx];
108 let open_brackets = before.matches('[').count();
109 let close_brackets = before.matches(']').count();
110
111 if open_brackets == close_brackets {
113 first_op_idx = Some(idx);
114 break;
115 }
116 }
117 }
118
119 if let Some(idx) = first_op_idx {
120 let package = spec[..idx].trim();
121 let version = spec[idx..].trim();
122 Some((package, version))
123 } else {
124 Some((spec.trim(), ""))
126 }
127 }
128}
129
130impl DependencyParser for RequirementsParser {
131 fn parse(&self, path: &PathBuf) -> Result<Vec<Dependency>> {
132 let content = fs::read_to_string(path)
133 .with_context(|| format!("Failed to read requirements file: {:?}", path))?;
134
135 let dependencies: Vec<Dependency> = content
136 .lines()
137 .enumerate()
138 .filter_map(|(idx, line)| {
139 Self::parse_line(line, idx + 1, path)
141 })
142 .collect();
143
144 Ok(dependencies)
145 }
146
147 fn can_parse(&self, path: &PathBuf) -> bool {
148 path.file_name()
149 .and_then(|n| n.to_str())
150 .map(|n| n.starts_with("requirements") && n.ends_with(".txt"))
151 .unwrap_or(false)
152 }
153}
154
155#[cfg(test)]
156mod tests {
157 use super::*;
158 use std::io::Write;
159 use tempfile::NamedTempFile;
160
161 #[test]
162 fn test_parse_simple_package() {
163 let mut file = NamedTempFile::new().unwrap();
164 writeln!(file, "requests==2.28.0").unwrap();
165 writeln!(file, "numpy>=1.24.0").unwrap();
166 writeln!(file, "flask").unwrap();
167
168 let parser = RequirementsParser::new();
169 let deps = parser.parse(&file.path().to_path_buf()).unwrap();
170
171 assert_eq!(deps.len(), 3);
172 assert_eq!(deps[0].name, "requests");
173 assert!(matches!(deps[0].version_spec, VersionSpec::Pinned(_)));
174 assert_eq!(deps[1].name, "numpy");
175 assert!(matches!(deps[1].version_spec, VersionSpec::Minimum(_)));
176 assert_eq!(deps[2].name, "flask");
177 assert!(matches!(deps[2].version_spec, VersionSpec::Any));
178 }
179
180 #[test]
181 fn test_parse_with_extras() {
182 let mut file = NamedTempFile::new().unwrap();
183 writeln!(file, "requests[security]>=2.0.0").unwrap();
184 writeln!(file, "celery[redis,msgpack]==5.2.0").unwrap();
185
186 let parser = RequirementsParser::new();
187 let deps = parser.parse(&file.path().to_path_buf()).unwrap();
188
189 assert_eq!(deps.len(), 2);
190 assert_eq!(deps[0].name, "requests");
191 assert_eq!(deps[1].name, "celery");
192 }
193
194 #[test]
195 fn test_parse_with_comments() {
196 let mut file = NamedTempFile::new().unwrap();
197 writeln!(file, "# This is a comment").unwrap();
198 writeln!(file, "requests==2.28.0 # inline comment").unwrap();
199 writeln!(file, "").unwrap();
200 writeln!(file, "numpy>=1.24.0").unwrap();
201
202 let parser = RequirementsParser::new();
203 let deps = parser.parse(&file.path().to_path_buf()).unwrap();
204
205 assert_eq!(deps.len(), 2);
206 assert_eq!(deps[0].name, "requests");
207 assert_eq!(deps[1].name, "numpy");
208 }
209
210 #[test]
211 fn test_parse_with_environment_markers() {
212 let mut file = NamedTempFile::new().unwrap();
213 writeln!(file, "dataclasses>=0.6; python_version < '3.7'").unwrap();
214 writeln!(file, "typing-extensions>=3.7; python_version >= '3.8'").unwrap();
215
216 let parser = RequirementsParser::new();
217 let deps = parser.parse(&file.path().to_path_buf()).unwrap();
218
219 assert_eq!(deps.len(), 2);
220 assert_eq!(deps[0].name, "dataclasses");
221 assert_eq!(deps[1].name, "typing-extensions");
222 }
223
224 #[test]
225 fn test_parse_skip_directives() {
226 let mut file = NamedTempFile::new().unwrap();
227 writeln!(file, "--index-url https://pypi.org/simple").unwrap();
228 writeln!(file, "-r requirements-dev.txt").unwrap();
229 writeln!(file, "requests==2.28.0").unwrap();
230
231 let parser = RequirementsParser::new();
232 let deps = parser.parse(&file.path().to_path_buf()).unwrap();
233
234 assert_eq!(deps.len(), 1);
235 assert_eq!(deps[0].name, "requests");
236 }
237
238 #[test]
239 fn test_parse_complex_version_specs() {
240 let mut file = NamedTempFile::new().unwrap();
241 writeln!(file, "django>=2.0,<3.0").unwrap();
242 writeln!(file, "pytest~=7.0").unwrap();
243 writeln!(file, "click!=8.0.0").unwrap();
244
245 let parser = RequirementsParser::new();
246 let deps = parser.parse(&file.path().to_path_buf()).unwrap();
247
248 assert_eq!(deps.len(), 3);
249 assert_eq!(deps[0].name, "django");
250 assert!(matches!(deps[0].version_spec, VersionSpec::Range { .. }));
251 assert_eq!(deps[1].name, "pytest");
252 assert_eq!(deps[2].name, "click");
253 }
254
255 #[test]
256 fn test_line_numbers() {
257 let mut file = NamedTempFile::new().unwrap();
258 writeln!(file, "# Comment line").unwrap();
259 writeln!(file, "requests==2.28.0").unwrap();
260 writeln!(file, "").unwrap();
261 writeln!(file, "numpy>=1.24.0").unwrap();
262
263 let parser = RequirementsParser::new();
264 let deps = parser.parse(&file.path().to_path_buf()).unwrap();
265
266 assert_eq!(deps.len(), 2);
267 assert_eq!(deps[0].line_number, 2);
268 assert_eq!(deps[1].line_number, 4);
269 }
270
271 #[test]
272 fn test_can_parse() {
273 let parser = RequirementsParser::new();
274 assert!(parser.can_parse(&PathBuf::from("requirements.txt")));
275 assert!(parser.can_parse(&PathBuf::from("requirements-dev.txt")));
276 assert!(parser.can_parse(&PathBuf::from("requirements-test.txt")));
277 assert!(!parser.can_parse(&PathBuf::from("pyproject.toml")));
278 assert!(!parser.can_parse(&PathBuf::from("setup.py")));
279 }
280}