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#[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 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 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 vec![
114 "python".into(),
115 "-m".into(),
116 "pytest".into(),
117 package_id.0.clone(),
118 ]
119 }
120}
121
122impl PythonResolver {
123 fn resolve_poetry(&self, root: &Path) -> Result<ProjectGraph> {
125 debug!("Resolving Poetry project at {}", root.display());
126
127 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 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 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 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 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 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 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 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 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 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 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 let mut edges = Vec::new();
395 let workspace_names: HashSet<String> = name_to_id.keys().cloned().collect();
396
397 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 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 edges.sort();
439 edges.dedup();
440
441 Ok(ProjectGraph {
442 packages,
443 edges,
444 root: root.to_path_buf(),
445 })
446 }
447
448 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 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 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
479fn normalize_python_name(name: &str) -> String {
481 name.to_lowercase().replace('-', "_")
482}
483
484fn 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
494fn 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 if trimmed.starts_with("import ") && !trimmed.contains('(') {
516 let rest = trimmed.trim_start_matches("import ").trim();
517 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 else if trimmed.starts_with("from ") && trimmed.contains(" import ") {
528 let rest = trimmed.trim_start_matches("from ").trim();
529 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 std::fs::write(
659 dir.path().join("pyproject.toml"),
660 "[project]\nname = \"root\"\nversion = \"0.1.0\"\n",
661 )
662 .unwrap();
663
664 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 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 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 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 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 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 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 std::fs::write(
783 dir.path().join("pyproject.toml"),
784 "[tool.poetry]\nname = \"root\"\nversion = \"0.1.0\"\n",
785 )
786 .unwrap();
787
788 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 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 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 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 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 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 assert!(graph
847 .edges
848 .contains(&(PackageId("pkg-a".into()), PackageId("pkg-b".into()),)));
849 }
850}