Skip to main content

null_e/plugins/
python.rs

1//! Python plugin (pip, poetry, pipenv, conda, uv)
2
3use crate::core::{Artifact, ArtifactKind, ArtifactMetadata, MarkerKind, ProjectKind, ProjectMarker};
4use crate::error::Result;
5use crate::plugins::Plugin;
6use std::path::{Path, PathBuf};
7
8/// Plugin for Python ecosystem
9pub struct PythonPlugin;
10
11impl Plugin for PythonPlugin {
12    fn id(&self) -> &'static str {
13        "python"
14    }
15
16    fn name(&self) -> &'static str {
17        "Python (pip/poetry/pipenv/uv)"
18    }
19
20    fn supported_kinds(&self) -> &[ProjectKind] {
21        &[
22            ProjectKind::PythonPip,
23            ProjectKind::PythonPoetry,
24            ProjectKind::PythonPipenv,
25            ProjectKind::PythonConda,
26            ProjectKind::PythonUv,
27        ]
28    }
29
30    fn markers(&self) -> Vec<ProjectMarker> {
31        vec![
32            ProjectMarker {
33                indicator: MarkerKind::File("pyproject.toml"),
34                kind: ProjectKind::PythonPoetry,
35                priority: 55,
36            },
37            ProjectMarker {
38                indicator: MarkerKind::File("Pipfile"),
39                kind: ProjectKind::PythonPipenv,
40                priority: 55,
41            },
42            ProjectMarker {
43                indicator: MarkerKind::File("requirements.txt"),
44                kind: ProjectKind::PythonPip,
45                priority: 50,
46            },
47            ProjectMarker {
48                indicator: MarkerKind::File("setup.py"),
49                kind: ProjectKind::PythonPip,
50                priority: 45,
51            },
52            ProjectMarker {
53                indicator: MarkerKind::File("environment.yml"),
54                kind: ProjectKind::PythonConda,
55                priority: 55,
56            },
57            ProjectMarker {
58                indicator: MarkerKind::File("uv.lock"),
59                kind: ProjectKind::PythonUv,
60                priority: 60,
61            },
62        ]
63    }
64
65    fn detect(&self, path: &Path) -> Option<ProjectKind> {
66        // Check for uv (newest)
67        if path.join("uv.lock").exists() {
68            return Some(ProjectKind::PythonUv);
69        }
70
71        // Check for Poetry
72        if path.join("poetry.lock").exists() {
73            return Some(ProjectKind::PythonPoetry);
74        }
75
76        // Check for pyproject.toml (could be poetry or modern pip)
77        if path.join("pyproject.toml").exists() {
78            // Try to determine if it's poetry
79            if let Ok(content) = std::fs::read_to_string(path.join("pyproject.toml")) {
80                if content.contains("[tool.poetry]") {
81                    return Some(ProjectKind::PythonPoetry);
82                }
83            }
84            return Some(ProjectKind::PythonPip);
85        }
86
87        // Check for Pipenv
88        if path.join("Pipfile").exists() {
89            return Some(ProjectKind::PythonPipenv);
90        }
91
92        // Check for Conda
93        if path.join("environment.yml").exists() || path.join("environment.yaml").exists() {
94            return Some(ProjectKind::PythonConda);
95        }
96
97        // Check for basic requirements.txt or setup.py
98        if path.join("requirements.txt").exists() || path.join("setup.py").exists() {
99            return Some(ProjectKind::PythonPip);
100        }
101
102        None
103    }
104
105    fn find_artifacts(&self, project_root: &Path) -> Result<Vec<Artifact>> {
106        let mut artifacts = Vec::new();
107
108        // Virtual environments
109        for venv_name in &[".venv", "venv", "env", ".env"] {
110            let venv_path = project_root.join(venv_name);
111            if venv_path.exists() && is_venv(&venv_path) {
112                artifacts.push(Artifact {
113                    path: venv_path,
114                    kind: ArtifactKind::VirtualEnv,
115                    size: 0,
116                    file_count: 0,
117                    age: None,
118                    metadata: ArtifactMetadata {
119                        restorable: true,
120                        restore_command: Some(self.restore_command(project_root)),
121                        lockfile: self.find_lockfile(project_root),
122                        restore_time_estimate: Some(30),
123                        ..Default::default()
124                    },
125                });
126                break; // Usually only one venv
127            }
128        }
129
130        // __pycache__ directories (can be multiple)
131        self.find_pycache_dirs(project_root, &mut artifacts)?;
132
133        // .pytest_cache
134        let pytest_cache = project_root.join(".pytest_cache");
135        if pytest_cache.exists() {
136            artifacts.push(Artifact {
137                path: pytest_cache,
138                kind: ArtifactKind::Cache,
139                size: 0,
140                file_count: 0,
141                age: None,
142                metadata: ArtifactMetadata::default(),
143            });
144        }
145
146        // .mypy_cache
147        let mypy_cache = project_root.join(".mypy_cache");
148        if mypy_cache.exists() {
149            artifacts.push(Artifact {
150                path: mypy_cache,
151                kind: ArtifactKind::Cache,
152                size: 0,
153                file_count: 0,
154                age: None,
155                metadata: ArtifactMetadata::default(),
156            });
157        }
158
159        // .ruff_cache
160        let ruff_cache = project_root.join(".ruff_cache");
161        if ruff_cache.exists() {
162            artifacts.push(Artifact {
163                path: ruff_cache,
164                kind: ArtifactKind::Cache,
165                size: 0,
166                file_count: 0,
167                age: None,
168                metadata: ArtifactMetadata::default(),
169            });
170        }
171
172        // .tox
173        let tox = project_root.join(".tox");
174        if tox.exists() {
175            artifacts.push(Artifact {
176                path: tox,
177                kind: ArtifactKind::TestOutput,
178                size: 0,
179                file_count: 0,
180                age: None,
181                metadata: ArtifactMetadata::restorable("tox"),
182            });
183        }
184
185        // .nox
186        let nox = project_root.join(".nox");
187        if nox.exists() {
188            artifacts.push(Artifact {
189                path: nox,
190                kind: ArtifactKind::TestOutput,
191                size: 0,
192                file_count: 0,
193                age: None,
194                metadata: ArtifactMetadata::restorable("nox"),
195            });
196        }
197
198        // *.egg-info
199        self.find_egg_info(project_root, &mut artifacts)?;
200
201        // dist and build directories
202        let dist = project_root.join("dist");
203        if dist.exists() {
204            artifacts.push(Artifact {
205                path: dist,
206                kind: ArtifactKind::BuildOutput,
207                size: 0,
208                file_count: 0,
209                age: None,
210                metadata: ArtifactMetadata::restorable("python -m build"),
211            });
212        }
213
214        let build = project_root.join("build");
215        if build.exists() {
216            artifacts.push(Artifact {
217                path: build,
218                kind: ArtifactKind::BuildOutput,
219                size: 0,
220                file_count: 0,
221                age: None,
222                metadata: ArtifactMetadata::restorable("python setup.py build"),
223            });
224        }
225
226        // htmlcov (coverage reports)
227        let htmlcov = project_root.join("htmlcov");
228        if htmlcov.exists() {
229            artifacts.push(Artifact {
230                path: htmlcov,
231                kind: ArtifactKind::TestOutput,
232                size: 0,
233                file_count: 0,
234                age: None,
235                metadata: ArtifactMetadata::default(),
236            });
237        }
238
239        // .coverage file
240        let coverage = project_root.join(".coverage");
241        if coverage.exists() {
242            artifacts.push(Artifact {
243                path: coverage,
244                kind: ArtifactKind::TestOutput,
245                size: 0,
246                file_count: 0,
247                age: None,
248                metadata: ArtifactMetadata::default(),
249            });
250        }
251
252        Ok(artifacts)
253    }
254
255    fn cleanable_dirs(&self) -> &[&'static str] {
256        &[
257            ".venv",
258            "venv",
259            "__pycache__",
260            ".pytest_cache",
261            ".mypy_cache",
262            ".ruff_cache",
263            ".tox",
264            ".nox",
265            "htmlcov",
266        ]
267    }
268
269    fn priority(&self) -> u8 {
270        50
271    }
272}
273
274impl PythonPlugin {
275    fn restore_command(&self, path: &Path) -> String {
276        if path.join("uv.lock").exists() {
277            "uv sync".into()
278        } else if path.join("poetry.lock").exists() {
279            "poetry install".into()
280        } else if path.join("Pipfile.lock").exists() {
281            "pipenv install".into()
282        } else if path.join("requirements.txt").exists() {
283            "pip install -r requirements.txt".into()
284        } else {
285            "pip install -e .".into()
286        }
287    }
288
289    fn find_lockfile(&self, path: &Path) -> Option<PathBuf> {
290        let candidates = [
291            "uv.lock",
292            "poetry.lock",
293            "Pipfile.lock",
294            "requirements.txt",
295        ];
296
297        candidates.iter().map(|f| path.join(f)).find(|p| p.exists())
298    }
299
300    fn find_pycache_dirs(&self, root: &Path, artifacts: &mut Vec<Artifact>) -> Result<()> {
301        // Only search one level deep for __pycache__ in the project root
302        // to avoid scanning into venv
303        if let Ok(entries) = std::fs::read_dir(root) {
304            for entry in entries.filter_map(|e| e.ok()) {
305                let path = entry.path();
306                if path.is_dir() {
307                    let name = path.file_name().and_then(|n| n.to_str());
308                    if name == Some("__pycache__") {
309                        artifacts.push(Artifact {
310                            path,
311                            kind: ArtifactKind::Bytecode,
312                            size: 0,
313                            file_count: 0,
314                            age: None,
315                            metadata: ArtifactMetadata::default(),
316                        });
317                    }
318                }
319            }
320        }
321        Ok(())
322    }
323
324    fn find_egg_info(&self, root: &Path, artifacts: &mut Vec<Artifact>) -> Result<()> {
325        if let Ok(entries) = std::fs::read_dir(root) {
326            for entry in entries.filter_map(|e| e.ok()) {
327                let path = entry.path();
328                if path.is_dir() {
329                    if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
330                        if name.ends_with(".egg-info") {
331                            artifacts.push(Artifact {
332                                path,
333                                kind: ArtifactKind::BuildOutput,
334                                size: 0,
335                                file_count: 0,
336                                age: None,
337                                metadata: ArtifactMetadata::default(),
338                            });
339                        }
340                    }
341                }
342            }
343        }
344        Ok(())
345    }
346}
347
348/// Check if a directory is a Python virtual environment
349fn is_venv(path: &Path) -> bool {
350    // Check for pyvenv.cfg (standard venv marker)
351    if path.join("pyvenv.cfg").exists() {
352        return true;
353    }
354
355    // Check for bin/python or Scripts/python.exe
356    let has_python = path.join("bin/python").exists()
357        || path.join("bin/python3").exists()
358        || path.join("Scripts/python.exe").exists();
359
360    has_python
361}
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366    use tempfile::TempDir;
367
368    #[test]
369    fn test_detect_poetry() {
370        let temp = TempDir::new().unwrap();
371        std::fs::write(temp.path().join("poetry.lock"), "").unwrap();
372
373        let plugin = PythonPlugin;
374        assert_eq!(plugin.detect(temp.path()), Some(ProjectKind::PythonPoetry));
375    }
376
377    #[test]
378    fn test_detect_pip() {
379        let temp = TempDir::new().unwrap();
380        std::fs::write(temp.path().join("requirements.txt"), "flask\n").unwrap();
381
382        let plugin = PythonPlugin;
383        assert_eq!(plugin.detect(temp.path()), Some(ProjectKind::PythonPip));
384    }
385
386    #[test]
387    fn test_is_venv() {
388        let temp = TempDir::new().unwrap();
389        std::fs::write(temp.path().join("pyvenv.cfg"), "").unwrap();
390
391        assert!(is_venv(temp.path()));
392    }
393
394    #[test]
395    fn test_find_artifacts() {
396        let temp = TempDir::new().unwrap();
397        std::fs::write(temp.path().join("requirements.txt"), "").unwrap();
398
399        // Create a fake venv
400        let venv = temp.path().join(".venv");
401        std::fs::create_dir(&venv).unwrap();
402        std::fs::write(venv.join("pyvenv.cfg"), "").unwrap();
403
404        // Create __pycache__
405        std::fs::create_dir(temp.path().join("__pycache__")).unwrap();
406
407        // Create .pytest_cache
408        std::fs::create_dir(temp.path().join(".pytest_cache")).unwrap();
409
410        let plugin = PythonPlugin;
411        let artifacts = plugin.find_artifacts(temp.path()).unwrap();
412
413        assert!(artifacts.len() >= 3);
414        assert!(artifacts.iter().any(|a| a.name() == ".venv"));
415        assert!(artifacts.iter().any(|a| a.name() == "__pycache__"));
416        assert!(artifacts.iter().any(|a| a.name() == ".pytest_cache"));
417    }
418}