python_check_updates/parsers/
requirements.rs

1use super::{Dependency, DependencyParser};
2use crate::version::VersionSpec;
3use anyhow::{Context, Result};
4use std::fs;
5use std::path::PathBuf;
6
7/// Parser for requirements.txt files
8pub struct RequirementsParser;
9
10impl RequirementsParser {
11    pub fn new() -> Self {
12        Self
13    }
14
15    /// Parse a single line from requirements.txt
16    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        // Skip empty lines
21        if line.is_empty() {
22            return None;
23        }
24
25        // Skip comments
26        if line.starts_with('#') {
27            return None;
28        }
29
30        // Skip -r includes (don't follow them)
31        if line.starts_with("-r ") || line.starts_with("-r\t") {
32            return None;
33        }
34
35        // Skip --index-url and other pip options
36        if line.starts_with("--") || line.starts_with('-') {
37            return None;
38        }
39
40        // Handle environment markers - split on semicolon
41        // e.g., "package>=1.0; python_version >= '3.8'"
42        let line_without_marker = if let Some(idx) = line.find(';') {
43            line[..idx].trim()
44        } else {
45            line
46        };
47
48        // Handle inline comments
49        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        // Parse package name and version specifier
60        // Package name can include extras: package[extra1,extra2]>=1.0
61        let (package_with_extras, version_str) = Self::split_package_version(line_clean)?;
62
63        // Extract package name (remove extras)
64        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        // Normalize package name to lowercase with underscores/hyphens handled
71        let normalized_name = package_name.to_lowercase().replace('_', "-");
72
73        // Parse version specification
74        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                    // If parsing fails, store as complex constraint
81                    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    /// Split a package specification into name (with extras) and version
96    /// Returns (package_with_extras, version_spec)
97    fn split_package_version(spec: &str) -> Option<(&str, &str)> {
98        // Try to find version operators
99        // Order matters: check two-char operators first
100        let operators = ["==", ">=", "<=", "~=", "!=", ">", "<"];
101
102        // Find the first operator
103        let mut first_op_idx = None;
104        for op in &operators {
105            if let Some(idx) = spec.find(op) {
106                // Make sure we're not inside brackets (extras)
107                let before = &spec[..idx];
108                let open_brackets = before.matches('[').count();
109                let close_brackets = before.matches(']').count();
110
111                // Only consider this operator if we're not inside brackets
112                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            // No version specifier - just package name
125            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                // Line numbers are 1-indexed
140                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}