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