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