Skip to main content

affected_core/resolvers/
python.rs

1use anyhow::{Context, Result};
2use serde::Deserialize;
3use std::collections::{HashMap, HashSet};
4use std::path::Path;
5use tracing::debug;
6
7use crate::resolvers::{file_to_package, Resolver};
8use crate::types::{Ecosystem, Package, PackageId, ProjectGraph};
9
10pub struct PythonResolver;
11
12/// Which Python tooling is in use.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum PythonTooling {
15    Generic,
16    Poetry,
17    Uv,
18}
19
20#[derive(Deserialize)]
21struct PyProjectToml {
22    project: Option<ProjectSection>,
23    tool: Option<ToolSection>,
24}
25
26#[derive(Deserialize)]
27struct ProjectSection {
28    name: Option<String>,
29    version: Option<String>,
30    dependencies: Option<Vec<String>>,
31}
32
33#[derive(Deserialize)]
34struct ToolSection {
35    poetry: Option<PoetrySection>,
36    uv: Option<UvSection>,
37}
38
39#[derive(Deserialize)]
40struct PoetrySection {
41    name: Option<String>,
42    version: Option<String>,
43    dependencies: Option<toml::Value>,
44}
45
46#[derive(Deserialize)]
47struct UvSection {
48    workspace: Option<UvWorkspaceSection>,
49}
50
51#[derive(Deserialize)]
52struct UvWorkspaceSection {
53    members: Option<Vec<String>>,
54}
55
56impl PythonResolver {
57    /// Detect which Python tooling is in use at the given root.
58    fn detect_tooling(root: &Path) -> PythonTooling {
59        let root_pyproject = root.join("pyproject.toml");
60        if root_pyproject.exists() {
61            if let Ok(content) = std::fs::read_to_string(&root_pyproject) {
62                if content.contains("[tool.poetry]") {
63                    debug!("Python tooling: Poetry");
64                    return PythonTooling::Poetry;
65                }
66                if content.contains("[tool.uv.workspace]") {
67                    debug!("Python tooling: uv");
68                    return PythonTooling::Uv;
69                }
70            }
71        }
72        debug!("Python tooling: Generic");
73        PythonTooling::Generic
74    }
75}
76
77impl Resolver for PythonResolver {
78    fn ecosystem(&self) -> Ecosystem {
79        Ecosystem::Python
80    }
81
82    fn detect(&self, root: &Path) -> bool {
83        if root.join("pyproject.toml").exists() {
84            return true;
85        }
86        // Check for multiple pyproject.toml in subdirectories
87        let pattern = root.join("*/pyproject.toml");
88        if let Ok(paths) = glob::glob(pattern.to_str().unwrap_or("")) {
89            return paths.filter_map(|p| p.ok()).count() >= 2;
90        }
91        false
92    }
93
94    fn resolve(&self, root: &Path) -> Result<ProjectGraph> {
95        let tooling = Self::detect_tooling(root);
96
97        match tooling {
98            PythonTooling::Poetry => self.resolve_poetry(root),
99            PythonTooling::Uv => self.resolve_uv(root),
100            PythonTooling::Generic => self.resolve_generic(root),
101        }
102    }
103
104    fn package_for_file(&self, graph: &ProjectGraph, file: &Path) -> Option<PackageId> {
105        file_to_package(graph, file)
106    }
107
108    fn test_command(&self, package_id: &PackageId) -> Vec<String> {
109        // We need the root pyproject.toml to detect tooling, but we don't have
110        // root info here. We use the generic command; users can override via config.
111        // The find_affected_with_options path can use config overrides.
112        vec![
113            "python".into(),
114            "-m".into(),
115            "pytest".into(),
116            package_id.0.clone(),
117        ]
118    }
119}
120
121impl PythonResolver {
122    /// Resolve a Poetry-based project.
123    fn resolve_poetry(&self, root: &Path) -> Result<ProjectGraph> {
124        debug!("Resolving Poetry project at {}", root.display());
125
126        // Find all pyproject.toml files
127        let pkg_tomls = self.find_pyproject_tomls(root);
128
129        let mut packages = HashMap::new();
130        let mut name_to_id = HashMap::new();
131
132        for toml_path in &pkg_tomls {
133            let content = std::fs::read_to_string(toml_path)
134                .with_context(|| format!("Failed to read {}", toml_path.display()))?;
135            let pyproject: PyProjectToml = toml::from_str(&content)
136                .with_context(|| format!("Failed to parse {}", toml_path.display()))?;
137
138            // Poetry uses [tool.poetry] for name/version
139            let name = pyproject
140                .tool
141                .as_ref()
142                .and_then(|t| t.poetry.as_ref())
143                .and_then(|p| p.name.clone())
144                .or_else(|| pyproject.project.as_ref().and_then(|p| p.name.clone()));
145
146            let name = match name {
147                Some(n) => n,
148                None => continue,
149            };
150
151            let version = pyproject
152                .tool
153                .as_ref()
154                .and_then(|t| t.poetry.as_ref())
155                .and_then(|p| p.version.clone())
156                .or_else(|| pyproject.project.as_ref().and_then(|p| p.version.clone()));
157
158            let pkg_dir = toml_path.parent().unwrap_or(root).to_path_buf();
159            let pkg_id = PackageId(name.clone());
160            name_to_id.insert(normalize_python_name(&name), pkg_id.clone());
161
162            debug!("Poetry: discovered package '{}'", name);
163
164            packages.insert(
165                pkg_id.clone(),
166                Package {
167                    id: pkg_id,
168                    name: name.clone(),
169                    version,
170                    path: pkg_dir,
171                    manifest_path: toml_path.clone(),
172                },
173            );
174        }
175
176        // Build edges from Poetry dependencies
177        let mut edges = Vec::new();
178        let workspace_names: HashSet<String> = name_to_id.keys().cloned().collect();
179
180        for toml_path in &pkg_tomls {
181            let content = std::fs::read_to_string(toml_path)?;
182            let pyproject: PyProjectToml = toml::from_str(&content)?;
183
184            let from_name = pyproject
185                .tool
186                .as_ref()
187                .and_then(|t| t.poetry.as_ref())
188                .and_then(|p| p.name.clone())
189                .or_else(|| pyproject.project.as_ref().and_then(|p| p.name.clone()));
190
191            let from_name = match from_name {
192                Some(n) => n,
193                None => continue,
194            };
195
196            // Parse Poetry-style dependencies: {path = "../pkg-b", develop = true}
197            if let Some(deps_value) = pyproject
198                .tool
199                .as_ref()
200                .and_then(|t| t.poetry.as_ref())
201                .and_then(|p| p.dependencies.as_ref())
202            {
203                if let Some(deps_table) = deps_value.as_table() {
204                    for (dep_name, _dep_spec) in deps_table {
205                        let normalized = normalize_python_name(dep_name);
206                        if workspace_names.contains(&normalized) {
207                            if let Some(to_id) = name_to_id.get(&normalized) {
208                                edges.push((PackageId(from_name.clone()), to_id.clone()));
209                            }
210                        }
211                    }
212                }
213            }
214
215            // Also check standard PEP 621 dependencies
216            if let Some(deps) = pyproject
217                .project
218                .as_ref()
219                .and_then(|p| p.dependencies.as_ref())
220            {
221                for dep_str in deps {
222                    let dep_name = parse_pep508_name(dep_str);
223                    let normalized = normalize_python_name(&dep_name);
224                    if workspace_names.contains(&normalized) {
225                        if let Some(to_id) = name_to_id.get(&normalized) {
226                            edges.push((PackageId(from_name.clone()), to_id.clone()));
227                        }
228                    }
229                }
230            }
231        }
232
233        edges.sort();
234        edges.dedup();
235
236        Ok(ProjectGraph {
237            packages,
238            edges,
239            root: root.to_path_buf(),
240        })
241    }
242
243    /// Resolve a uv workspace project.
244    fn resolve_uv(&self, root: &Path) -> Result<ProjectGraph> {
245        debug!("Resolving uv workspace at {}", root.display());
246
247        let root_content = std::fs::read_to_string(root.join("pyproject.toml"))
248            .context("Failed to read root pyproject.toml")?;
249        let root_pyproject: PyProjectToml =
250            toml::from_str(&root_content).context("Failed to parse root pyproject.toml")?;
251
252        // Get workspace member globs from [tool.uv.workspace]
253        let member_globs = root_pyproject
254            .tool
255            .as_ref()
256            .and_then(|t| t.uv.as_ref())
257            .and_then(|u| u.workspace.as_ref())
258            .and_then(|w| w.members.clone())
259            .unwrap_or_default();
260
261        debug!("uv workspace member globs: {:?}", member_globs);
262
263        // Expand member globs to find pyproject.toml files
264        let mut pkg_tomls = Vec::new();
265        for pattern in &member_globs {
266            let full_pattern = root.join(pattern).join("pyproject.toml");
267            if let Ok(paths) = glob::glob(full_pattern.to_str().unwrap_or("")) {
268                for entry in paths.filter_map(|p| p.ok()) {
269                    pkg_tomls.push(entry);
270                }
271            }
272        }
273
274        // Also include the root pyproject.toml if it has a [project] section
275        if root_pyproject
276            .project
277            .as_ref()
278            .and_then(|p| p.name.as_ref())
279            .is_some()
280        {
281            pkg_tomls.push(root.join("pyproject.toml"));
282        }
283
284        let mut packages = HashMap::new();
285        let mut name_to_id = HashMap::new();
286
287        for toml_path in &pkg_tomls {
288            let content = std::fs::read_to_string(toml_path)
289                .with_context(|| format!("Failed to read {}", toml_path.display()))?;
290            let pyproject: PyProjectToml = toml::from_str(&content)
291                .with_context(|| format!("Failed to parse {}", toml_path.display()))?;
292
293            let name = match pyproject.project.as_ref().and_then(|p| p.name.as_ref()) {
294                Some(n) => n.clone(),
295                None => continue,
296            };
297
298            let pkg_dir = toml_path.parent().unwrap_or(root).to_path_buf();
299            let pkg_id = PackageId(name.clone());
300            name_to_id.insert(normalize_python_name(&name), pkg_id.clone());
301
302            debug!("uv: discovered package '{}'", name);
303
304            packages.insert(
305                pkg_id.clone(),
306                Package {
307                    id: pkg_id,
308                    name: name.clone(),
309                    version: pyproject.project.as_ref().and_then(|p| p.version.clone()),
310                    path: pkg_dir,
311                    manifest_path: toml_path.clone(),
312                },
313            );
314        }
315
316        // Build edges from declared dependencies
317        let mut edges = Vec::new();
318        let workspace_names: HashSet<String> = name_to_id.keys().cloned().collect();
319
320        for toml_path in &pkg_tomls {
321            let content = std::fs::read_to_string(toml_path)?;
322            let pyproject: PyProjectToml = toml::from_str(&content)?;
323
324            let from_name = match pyproject.project.as_ref().and_then(|p| p.name.as_ref()) {
325                Some(n) => n.clone(),
326                None => continue,
327            };
328
329            if let Some(deps) = pyproject
330                .project
331                .as_ref()
332                .and_then(|p| p.dependencies.as_ref())
333            {
334                for dep_str in deps {
335                    let dep_name = parse_pep508_name(dep_str);
336                    let normalized = normalize_python_name(&dep_name);
337                    if workspace_names.contains(&normalized) {
338                        if let Some(to_id) = name_to_id.get(&normalized) {
339                            edges.push((PackageId(from_name.clone()), to_id.clone()));
340                        }
341                    }
342                }
343            }
344        }
345
346        edges.sort();
347        edges.dedup();
348
349        Ok(ProjectGraph {
350            packages,
351            edges,
352            root: root.to_path_buf(),
353        })
354    }
355
356    /// Resolve a generic Python monorepo (no Poetry or uv).
357    fn resolve_generic(&self, root: &Path) -> Result<ProjectGraph> {
358        debug!("Resolving generic Python project at {}", root.display());
359
360        let pkg_tomls = self.find_pyproject_tomls(root);
361
362        let mut packages = HashMap::new();
363        let mut name_to_id = HashMap::new();
364
365        for toml_path in &pkg_tomls {
366            let content = std::fs::read_to_string(toml_path)
367                .with_context(|| format!("Failed to read {}", toml_path.display()))?;
368            let pyproject: PyProjectToml = toml::from_str(&content)
369                .with_context(|| format!("Failed to parse {}", toml_path.display()))?;
370
371            let name = match pyproject.project.as_ref().and_then(|p| p.name.as_ref()) {
372                Some(n) => n.clone(),
373                None => continue,
374            };
375
376            let pkg_dir = toml_path.parent().unwrap_or(root).to_path_buf();
377            let pkg_id = PackageId(name.clone());
378            name_to_id.insert(normalize_python_name(&name), pkg_id.clone());
379
380            packages.insert(
381                pkg_id.clone(),
382                Package {
383                    id: pkg_id,
384                    name: name.clone(),
385                    version: pyproject.project.as_ref().and_then(|p| p.version.clone()),
386                    path: pkg_dir,
387                    manifest_path: toml_path.clone(),
388                },
389            );
390        }
391
392        // Build edges from declared dependencies + import scanning
393        let mut edges = Vec::new();
394        let workspace_names: HashSet<String> = name_to_id.keys().cloned().collect();
395
396        // Strategy 1: Declared dependencies in pyproject.toml
397        for toml_path in &pkg_tomls {
398            let content = std::fs::read_to_string(toml_path)?;
399            let pyproject: PyProjectToml = toml::from_str(&content)?;
400
401            let from_name = match pyproject.project.as_ref().and_then(|p| p.name.as_ref()) {
402                Some(n) => n.clone(),
403                None => continue,
404            };
405
406            if let Some(deps) = pyproject
407                .project
408                .as_ref()
409                .and_then(|p| p.dependencies.as_ref())
410            {
411                for dep_str in deps {
412                    let dep_name = parse_pep508_name(dep_str);
413                    let normalized = normalize_python_name(&dep_name);
414                    if workspace_names.contains(&normalized) {
415                        if let Some(to_id) = name_to_id.get(&normalized) {
416                            edges.push((PackageId(from_name.clone()), to_id.clone()));
417                        }
418                    }
419                }
420            }
421        }
422
423        // Strategy 2: Import scanning
424        for (pkg_id, pkg) in &packages {
425            let imports = scan_python_imports(&pkg.path);
426            for import_name in imports {
427                let normalized = normalize_python_name(&import_name);
428                if let Some(to_id) = name_to_id.get(&normalized) {
429                    if to_id != pkg_id {
430                        edges.push((pkg_id.clone(), to_id.clone()));
431                    }
432                }
433            }
434        }
435
436        // Deduplicate edges
437        edges.sort();
438        edges.dedup();
439
440        Ok(ProjectGraph {
441            packages,
442            edges,
443            root: root.to_path_buf(),
444        })
445    }
446
447    /// Find all pyproject.toml files in subdirectories (up to 2 levels deep).
448    fn find_pyproject_tomls(&self, root: &Path) -> Vec<std::path::PathBuf> {
449        let mut pkg_tomls = Vec::new();
450
451        let pattern = root.join("*/pyproject.toml");
452        if let Ok(paths) = glob::glob(pattern.to_str().unwrap_or("")) {
453            for entry in paths.filter_map(|p| p.ok()) {
454                pkg_tomls.push(entry);
455            }
456        }
457
458        // Also check two levels deep
459        let pattern2 = root.join("*/*/pyproject.toml");
460        if let Ok(paths) = glob::glob(pattern2.to_str().unwrap_or("")) {
461            for entry in paths.filter_map(|p| p.ok()) {
462                pkg_tomls.push(entry);
463            }
464        }
465
466        if pkg_tomls.is_empty() {
467            // Single project at root
468            let root_toml = root.join("pyproject.toml");
469            if root_toml.exists() {
470                pkg_tomls.push(root_toml);
471            }
472        }
473
474        pkg_tomls
475    }
476}
477
478/// Normalize a Python package name: lowercase, replace hyphens with underscores.
479fn normalize_python_name(name: &str) -> String {
480    name.to_lowercase().replace('-', "_")
481}
482
483/// Parse the package name from a PEP 508 dependency string.
484/// e.g., "my-package>=1.0" -> "my-package"
485fn parse_pep508_name(dep: &str) -> String {
486    let name: String = dep
487        .chars()
488        .take_while(|c| c.is_alphanumeric() || *c == '-' || *c == '_' || *c == '.')
489        .collect();
490    name
491}
492
493/// Scan Python files in a directory for import statements.
494/// Returns top-level module names that are imported.
495fn scan_python_imports(dir: &Path) -> HashSet<String> {
496    let mut imports = HashSet::new();
497    let pattern = dir.join("**/*.py");
498
499    let paths = match glob::glob(pattern.to_str().unwrap_or("")) {
500        Ok(p) => p,
501        Err(_) => return imports,
502    };
503
504    for entry in paths.filter_map(|p| p.ok()) {
505        let content = match std::fs::read_to_string(&entry) {
506            Ok(c) => c,
507            Err(_) => continue,
508        };
509
510        for line in content.lines() {
511            let trimmed = line.trim();
512
513            // `import foo` or `import foo.bar`
514            if trimmed.starts_with("import ") && !trimmed.contains('(') {
515                let rest = trimmed.trim_start_matches("import ").trim();
516                // Handle `import foo, bar`
517                for part in rest.split(',') {
518                    let module = part.trim().split('.').next().unwrap_or("").trim();
519                    if !module.is_empty() && module.chars().all(|c| c.is_alphanumeric() || c == '_')
520                    {
521                        imports.insert(module.to_string());
522                    }
523                }
524            }
525            // `from foo import bar` or `from foo.baz import bar`
526            else if trimmed.starts_with("from ") && trimmed.contains(" import ") {
527                let rest = trimmed.trim_start_matches("from ").trim();
528                // Skip relative imports (from . or from ..)
529                if rest.starts_with('.') {
530                    continue;
531                }
532                let module = rest.split_whitespace().next().unwrap_or("");
533                let top_level = module.split('.').next().unwrap_or("").trim();
534                if !top_level.is_empty()
535                    && top_level.chars().all(|c| c.is_alphanumeric() || c == '_')
536                {
537                    imports.insert(top_level.to_string());
538                }
539            }
540        }
541    }
542
543    imports
544}
545
546#[cfg(test)]
547mod tests {
548    use super::*;
549
550    #[test]
551    fn test_normalize_python_name() {
552        assert_eq!(normalize_python_name("My-Package"), "my_package");
553        assert_eq!(normalize_python_name("simple"), "simple");
554        assert_eq!(normalize_python_name("UPPER-CASE"), "upper_case");
555        assert_eq!(normalize_python_name("already_snake"), "already_snake");
556    }
557
558    #[test]
559    fn test_parse_pep508_basic() {
560        assert_eq!(parse_pep508_name("requests"), "requests");
561        assert_eq!(parse_pep508_name("requests>=2.0"), "requests");
562        assert_eq!(parse_pep508_name("my-package>=1.0,<2.0"), "my-package");
563        assert_eq!(parse_pep508_name("pkg==1.0.0"), "pkg");
564        assert_eq!(parse_pep508_name("my_pkg~=1.0"), "my_pkg");
565    }
566
567    #[test]
568    fn test_parse_pep508_extras() {
569        assert_eq!(parse_pep508_name("package[extra]>=1.0"), "package");
570    }
571
572    #[test]
573    fn test_scan_python_imports_basic() {
574        let dir = tempfile::tempdir().unwrap();
575        std::fs::write(
576            dir.path().join("main.py"),
577            "import os\nimport json\nfrom pathlib import Path\n",
578        )
579        .unwrap();
580
581        let imports = scan_python_imports(dir.path());
582        assert!(imports.contains("os"));
583        assert!(imports.contains("json"));
584        assert!(imports.contains("pathlib"));
585    }
586
587    #[test]
588    fn test_scan_python_imports_multiline() {
589        let dir = tempfile::tempdir().unwrap();
590        std::fs::write(
591            dir.path().join("app.py"),
592            "import foo, bar\nfrom baz.sub import thing\n",
593        )
594        .unwrap();
595
596        let imports = scan_python_imports(dir.path());
597        assert!(imports.contains("foo"));
598        assert!(imports.contains("bar"));
599        assert!(imports.contains("baz"));
600    }
601
602    #[test]
603    fn test_scan_python_imports_skips_relative() {
604        let dir = tempfile::tempdir().unwrap();
605        std::fs::write(
606            dir.path().join("mod.py"),
607            "from . import sibling\nfrom ..parent import thing\nimport real_dep\n",
608        )
609        .unwrap();
610
611        let imports = scan_python_imports(dir.path());
612        assert!(!imports.contains("sibling"));
613        assert!(!imports.contains("parent"));
614        assert!(imports.contains("real_dep"));
615    }
616
617    #[test]
618    fn test_scan_python_imports_nested_files() {
619        let dir = tempfile::tempdir().unwrap();
620        std::fs::create_dir_all(dir.path().join("src/pkg")).unwrap();
621        std::fs::write(dir.path().join("src/pkg/core.py"), "import numpy\n").unwrap();
622
623        let imports = scan_python_imports(dir.path());
624        assert!(imports.contains("numpy"));
625    }
626
627    #[test]
628    fn test_scan_python_imports_empty_dir() {
629        let dir = tempfile::tempdir().unwrap();
630        let imports = scan_python_imports(dir.path());
631        assert!(imports.is_empty());
632    }
633
634    #[test]
635    fn test_detect_python_root_pyproject() {
636        let dir = tempfile::tempdir().unwrap();
637        std::fs::write(
638            dir.path().join("pyproject.toml"),
639            "[project]\nname = \"myapp\"\n",
640        )
641        .unwrap();
642
643        assert!(PythonResolver.detect(dir.path()));
644    }
645
646    #[test]
647    fn test_detect_no_python() {
648        let dir = tempfile::tempdir().unwrap();
649        assert!(!PythonResolver.detect(dir.path()));
650    }
651
652    #[test]
653    fn test_resolve_python_monorepo() {
654        let dir = tempfile::tempdir().unwrap();
655
656        // Root pyproject.toml (for detection)
657        std::fs::write(
658            dir.path().join("pyproject.toml"),
659            "[project]\nname = \"root\"\nversion = \"0.1.0\"\n",
660        )
661        .unwrap();
662
663        // Package A depends on Package B
664        std::fs::create_dir_all(dir.path().join("pkg-a")).unwrap();
665        std::fs::write(
666            dir.path().join("pkg-a/pyproject.toml"),
667            "[project]\nname = \"pkg-a\"\nversion = \"0.1.0\"\ndependencies = [\"pkg-b>=0.1\"]\n",
668        )
669        .unwrap();
670
671        // Package B
672        std::fs::create_dir_all(dir.path().join("pkg-b")).unwrap();
673        std::fs::write(
674            dir.path().join("pkg-b/pyproject.toml"),
675            "[project]\nname = \"pkg-b\"\nversion = \"0.1.0\"\n",
676        )
677        .unwrap();
678
679        let graph = PythonResolver.resolve(dir.path()).unwrap();
680
681        // Should find root + pkg-a + pkg-b
682        assert!(graph.packages.len() >= 2);
683        assert!(graph.packages.contains_key(&PackageId("pkg-a".into())));
684        assert!(graph.packages.contains_key(&PackageId("pkg-b".into())));
685
686        // pkg-a depends on pkg-b
687        assert!(graph
688            .edges
689            .contains(&(PackageId("pkg-a".into()), PackageId("pkg-b".into()),)));
690    }
691
692    #[test]
693    fn test_resolve_python_with_import_scanning() {
694        let dir = tempfile::tempdir().unwrap();
695
696        // Package A imports package B via code
697        std::fs::create_dir_all(dir.path().join("alpha/src")).unwrap();
698        std::fs::write(
699            dir.path().join("alpha/pyproject.toml"),
700            "[project]\nname = \"alpha\"\nversion = \"0.1.0\"\n",
701        )
702        .unwrap();
703        std::fs::write(
704            dir.path().join("alpha/src/main.py"),
705            "import beta\nfrom beta.utils import helper\n",
706        )
707        .unwrap();
708
709        // Package B
710        std::fs::create_dir_all(dir.path().join("beta")).unwrap();
711        std::fs::write(
712            dir.path().join("beta/pyproject.toml"),
713            "[project]\nname = \"beta\"\nversion = \"0.1.0\"\n",
714        )
715        .unwrap();
716
717        let graph = PythonResolver.resolve(dir.path()).unwrap();
718
719        assert!(graph.packages.contains_key(&PackageId("alpha".into())));
720        assert!(graph.packages.contains_key(&PackageId("beta".into())));
721
722        // alpha imports beta
723        assert!(graph
724            .edges
725            .contains(&(PackageId("alpha".into()), PackageId("beta".into()),)));
726    }
727
728    #[test]
729    fn test_test_command() {
730        let cmd = PythonResolver.test_command(&PackageId("my-pkg".into()));
731        assert_eq!(cmd, vec!["python", "-m", "pytest", "my-pkg"]);
732    }
733
734    #[test]
735    fn test_detect_tooling_generic() {
736        let dir = tempfile::tempdir().unwrap();
737        std::fs::write(
738            dir.path().join("pyproject.toml"),
739            "[project]\nname = \"myapp\"\n",
740        )
741        .unwrap();
742        assert_eq!(
743            PythonResolver::detect_tooling(dir.path()),
744            PythonTooling::Generic
745        );
746    }
747
748    #[test]
749    fn test_detect_tooling_poetry() {
750        let dir = tempfile::tempdir().unwrap();
751        std::fs::write(
752            dir.path().join("pyproject.toml"),
753            "[tool.poetry]\nname = \"myapp\"\nversion = \"0.1.0\"\n",
754        )
755        .unwrap();
756        assert_eq!(
757            PythonResolver::detect_tooling(dir.path()),
758            PythonTooling::Poetry
759        );
760    }
761
762    #[test]
763    fn test_detect_tooling_uv() {
764        let dir = tempfile::tempdir().unwrap();
765        std::fs::write(
766            dir.path().join("pyproject.toml"),
767            "[project]\nname = \"root\"\n\n[tool.uv.workspace]\nmembers = [\"packages/*\"]\n",
768        )
769        .unwrap();
770        assert_eq!(
771            PythonResolver::detect_tooling(dir.path()),
772            PythonTooling::Uv
773        );
774    }
775
776    #[test]
777    fn test_resolve_poetry_project() {
778        let dir = tempfile::tempdir().unwrap();
779
780        // Root pyproject.toml with Poetry
781        std::fs::write(
782            dir.path().join("pyproject.toml"),
783            "[tool.poetry]\nname = \"root\"\nversion = \"0.1.0\"\n",
784        )
785        .unwrap();
786
787        // Package A with Poetry-style deps
788        std::fs::create_dir_all(dir.path().join("pkg-a")).unwrap();
789        std::fs::write(
790            dir.path().join("pkg-a/pyproject.toml"),
791            "[tool.poetry]\nname = \"pkg-a\"\nversion = \"0.1.0\"\n\n[tool.poetry.dependencies]\npython = \"^3.9\"\npkg-b = {path = \"../pkg-b\", develop = true}\n",
792        )
793        .unwrap();
794
795        // Package B
796        std::fs::create_dir_all(dir.path().join("pkg-b")).unwrap();
797        std::fs::write(
798            dir.path().join("pkg-b/pyproject.toml"),
799            "[tool.poetry]\nname = \"pkg-b\"\nversion = \"0.1.0\"\n",
800        )
801        .unwrap();
802
803        let graph = PythonResolver.resolve(dir.path()).unwrap();
804        assert!(graph.packages.contains_key(&PackageId("pkg-a".into())));
805        assert!(graph.packages.contains_key(&PackageId("pkg-b".into())));
806
807        // pkg-a depends on pkg-b
808        assert!(graph
809            .edges
810            .contains(&(PackageId("pkg-a".into()), PackageId("pkg-b".into()),)));
811    }
812
813    #[test]
814    fn test_resolve_uv_workspace() {
815        let dir = tempfile::tempdir().unwrap();
816
817        // Root pyproject.toml with uv workspace
818        std::fs::write(
819            dir.path().join("pyproject.toml"),
820            "[project]\nname = \"root\"\nversion = \"0.1.0\"\n\n[tool.uv.workspace]\nmembers = [\"packages/*\"]\n",
821        )
822        .unwrap();
823
824        // Package A depends on Package B
825        std::fs::create_dir_all(dir.path().join("packages/pkg-a")).unwrap();
826        std::fs::write(
827            dir.path().join("packages/pkg-a/pyproject.toml"),
828            "[project]\nname = \"pkg-a\"\nversion = \"0.1.0\"\ndependencies = [\"pkg-b>=0.1\"]\n",
829        )
830        .unwrap();
831
832        // Package B
833        std::fs::create_dir_all(dir.path().join("packages/pkg-b")).unwrap();
834        std::fs::write(
835            dir.path().join("packages/pkg-b/pyproject.toml"),
836            "[project]\nname = \"pkg-b\"\nversion = \"0.1.0\"\n",
837        )
838        .unwrap();
839
840        let graph = PythonResolver.resolve(dir.path()).unwrap();
841        assert!(graph.packages.contains_key(&PackageId("pkg-a".into())));
842        assert!(graph.packages.contains_key(&PackageId("pkg-b".into())));
843
844        // pkg-a depends on pkg-b
845        assert!(graph
846            .edges
847            .contains(&(PackageId("pkg-a".into()), PackageId("pkg-b".into()),)));
848    }
849}