1use crate::core::{Artifact, ArtifactKind, ArtifactMetadata, MarkerKind, ProjectKind, ProjectMarker};
4use crate::error::Result;
5use crate::plugins::Plugin;
6use std::path::{Path, PathBuf};
7
8pub 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 if path.join("uv.lock").exists() {
68 return Some(ProjectKind::PythonUv);
69 }
70
71 if path.join("poetry.lock").exists() {
73 return Some(ProjectKind::PythonPoetry);
74 }
75
76 if path.join("pyproject.toml").exists() {
78 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 if path.join("Pipfile").exists() {
89 return Some(ProjectKind::PythonPipenv);
90 }
91
92 if path.join("environment.yml").exists() || path.join("environment.yaml").exists() {
94 return Some(ProjectKind::PythonConda);
95 }
96
97 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 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; }
128 }
129
130 self.find_pycache_dirs(project_root, &mut artifacts)?;
132
133 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 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 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 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 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 self.find_egg_info(project_root, &mut artifacts)?;
200
201 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 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 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 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
348fn is_venv(path: &Path) -> bool {
350 if path.join("pyvenv.cfg").exists() {
352 return true;
353 }
354
355 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 let venv = temp.path().join(".venv");
401 std::fs::create_dir(&venv).unwrap();
402 std::fs::write(venv.join("pyvenv.cfg"), "").unwrap();
403
404 std::fs::create_dir(temp.path().join("__pycache__")).unwrap();
406
407 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}