1use anyhow::Result;
2use async_trait::async_trait;
3use changepacks_core::{Project, ProjectFinder};
4use std::{
5 collections::HashMap,
6 path::{Path, PathBuf},
7};
8
9use crate::{package::PythonPackage, read_and_parse_pyproject_toml, workspace::PythonWorkspace};
10
11const PROJECT_FILES: &[&str] = &["pyproject.toml"];
16
17#[derive(Debug, Default)]
18pub struct PythonProjectFinder {
19 projects: HashMap<PathBuf, Project>,
20}
21
22impl PythonProjectFinder {
23 #[must_use]
24 pub fn new() -> Self {
25 Self::default()
26 }
27}
28
29fn add_uv_source_dependencies(project: &mut Project, uv_table: Option<&toml_edit::Item>) {
41 let Some(sources) = uv_table
42 .and_then(|u| u.get("sources"))
43 .and_then(toml_edit::Item::as_table_like)
44 else {
45 return;
46 };
47 for (dep_name, source) in sources.iter() {
48 let is_local_source = source.as_table_like().is_some_and(|source| {
49 source.contains_key("path")
50 || source.get("workspace").and_then(toml_edit::Item::as_bool) == Some(true)
51 });
52 if is_local_source {
53 project.add_dependency(dep_name);
54 }
55 }
56}
57
58#[async_trait]
59impl ProjectFinder for PythonProjectFinder {
60 changepacks_core::impl_projects_hashmap_accessors!();
67
68 fn project_files(&self) -> &[&str] {
69 PROJECT_FILES
70 }
71
72 async fn visit(&mut self, path: &Path, relative_path: &Path) -> Result<()> {
73 if !self.should_visit_manifest(path).await? {
78 return Ok(());
79 }
80 let (_raw, pyproject_toml) = read_and_parse_pyproject_toml(path).await?;
82 let project_table = pyproject_toml.get("project");
90
91 let version = changepacks_utils::toml_item_str(project_table, "version");
99 let name = changepacks_utils::toml_item_str(project_table, "name");
100 let publishable_by_default = name.is_some();
101 let path_key = path.to_path_buf();
102 let relative_path_key = relative_path.to_path_buf();
103
104 let uv_table = pyproject_toml.get("tool").and_then(|t| t.get("uv"));
114
115 let has_workspace_declaration = changepacks_utils::ensure_declared_shape(
120 uv_table
121 .and_then(|u| u.get("workspace"))
122 .map(|workspace| workspace.as_table_like().is_some()),
123 path,
124 "[tool.uv].workspace",
125 "a table or inline table",
126 )?;
127
128 let mut project = changepacks_core::discovered_project!(
129 has_workspace_declaration,
130 PythonWorkspace::new_discovered,
131 PythonPackage::new_discovered,
132 name,
133 version,
134 path_key.clone(),
135 relative_path_key,
136 publishable_by_default,
137 );
138
139 add_uv_source_dependencies(&mut project, uv_table);
141
142 self.projects.insert(path_key, project);
143 Ok(())
144 }
145}
146
147#[cfg(test)]
148mod tests {
149 use super::*;
150 use changepacks_core::Project;
151 use rstest::rstest;
152 use std::fs;
153 use tempfile::TempDir;
154
155 #[rstest]
158 #[case(PythonProjectFinder::new())]
159 #[case(PythonProjectFinder::default())]
160 fn test_python_project_finder_construction(#[case] finder: PythonProjectFinder) {
161 assert_eq!(finder.project_files(), &["pyproject.toml"]);
162 assert_eq!(finder.projects().len(), 0);
163 }
164
165 #[tokio::test]
166 async fn test_python_project_finder_visit_package() {
167 let temp_dir = TempDir::new().unwrap();
168 let pyproject_toml = temp_dir.path().join("pyproject.toml");
169 fs::write(
170 &pyproject_toml,
171 r#"[project]
172name = "test-package"
173version = "1.0.0"
174"#,
175 )
176 .unwrap();
177
178 let mut finder = PythonProjectFinder::new();
179 finder
180 .visit(&pyproject_toml, &PathBuf::from("pyproject.toml"))
181 .await
182 .unwrap();
183
184 let projects = finder.projects();
185 assert_eq!(projects.len(), 1);
186 let pkg = projects[0].expect_package();
187 assert_eq!(pkg.name(), Some("test-package"));
188 assert_eq!(pkg.version(), Some("1.0.0"));
189 assert!(pkg.is_publishable_by_default());
190
191 temp_dir.close().unwrap();
192 }
193
194 #[rstest]
195 #[case(
196 r#"[tool.uv.workspace]
197members = ["packages/*"]
198"#
199 )]
200 #[case("[tool.uv.workspace]\n")]
201 #[case(
202 r#"[tool.uv]
203workspace = { members = ["packages/*"] }
204"#
205 )]
206 #[case("[tool.uv]\nworkspace = {}\n")]
207 #[tokio::test]
208 async fn test_python_project_finder_visit_workspace_with_table_like_declaration(
209 #[case] workspace: &str,
210 ) {
211 let temp_dir = TempDir::new().unwrap();
212 let pyproject_toml = temp_dir.path().join("pyproject.toml");
213 fs::write(
214 &pyproject_toml,
215 format!(
216 r#"{workspace}
217[project]
218name = "test-workspace"
219version = "1.0.0"
220"#
221 ),
222 )
223 .unwrap();
224
225 let mut finder = PythonProjectFinder::new();
226 finder
227 .visit(&pyproject_toml, &PathBuf::from("pyproject.toml"))
228 .await
229 .unwrap();
230
231 let projects = finder.projects();
232 assert_eq!(projects.len(), 1);
233 let ws = projects[0].expect_workspace();
234 assert_eq!(ws.name(), Some("test-workspace"));
235 assert_eq!(ws.version(), Some("1.0.0"));
236 assert!(ws.is_publishable_by_default());
237
238 temp_dir.close().unwrap();
239 }
240
241 #[rstest]
242 #[case("true")]
243 #[case(r#""packages/*""#)]
244 #[case("[]")]
245 #[tokio::test]
246 async fn test_python_project_finder_rejects_invalid_uv_workspace_declaration(
247 #[case] workspace: &str,
248 ) {
249 let temp_dir = TempDir::new().unwrap();
250 let pyproject_toml = temp_dir.path().join("pyproject.toml");
251 fs::write(
252 &pyproject_toml,
253 format!(
254 r#"[tool.uv]
255workspace = {workspace}
256
257[project]
258name = "test-package"
259version = "1.0.0"
260"#
261 ),
262 )
263 .unwrap();
264
265 let mut finder = PythonProjectFinder::new();
266 let result = finder
267 .visit(&pyproject_toml, &PathBuf::from("pyproject.toml"))
268 .await;
269
270 let error_msg = result
271 .expect_err("invalid uv workspace declaration should fail")
272 .to_string();
273 assert!(
274 error_msg.contains("Invalid `[tool.uv].workspace` declaration"),
275 "error message should explain the invalid declaration, got: {error_msg}"
276 );
277 assert!(
278 error_msg.contains(pyproject_toml.to_string_lossy().as_ref()),
279 "error message should contain the manifest path, got: {error_msg}"
280 );
281 assert!(finder.projects().is_empty());
282
283 temp_dir.close().unwrap();
284 }
285
286 #[tokio::test]
287 async fn test_python_project_finder_visit_workspace_without_version() {
288 let temp_dir = TempDir::new().unwrap();
289 let pyproject_toml = temp_dir.path().join("pyproject.toml");
290 fs::write(
291 &pyproject_toml,
292 r#"[tool.uv.workspace]
293members = ["packages/*"]
294
295[project]
296name = "test-workspace"
297"#,
298 )
299 .unwrap();
300
301 let mut finder = PythonProjectFinder::new();
302 finder
303 .visit(&pyproject_toml, &PathBuf::from("pyproject.toml"))
304 .await
305 .unwrap();
306
307 let projects = finder.projects();
308 assert_eq!(projects.len(), 1);
309 let ws = projects[0].expect_workspace();
310 assert_eq!(ws.name(), Some("test-workspace"));
311 assert_eq!(ws.version(), None);
312
313 temp_dir.close().unwrap();
314 }
315
316 #[tokio::test]
317 async fn test_python_project_finder_visit_non_pyproject_file() {
318 let temp_dir = TempDir::new().unwrap();
319 let other_file = temp_dir.path().join("other.txt");
320 fs::write(&other_file, "some content").unwrap();
321
322 let mut finder = PythonProjectFinder::new();
323 finder
324 .visit(&other_file, &PathBuf::from("other.txt"))
325 .await
326 .unwrap();
327
328 assert_eq!(finder.projects().len(), 0);
329
330 temp_dir.close().unwrap();
331 }
332
333 #[tokio::test]
334 async fn test_python_project_finder_visit_directory() {
335 let temp_dir = TempDir::new().unwrap();
336 let pyproject_toml = temp_dir.path().join("pyproject.toml");
337 fs::write(
338 &pyproject_toml,
339 r#"[project]
340name = "test-package"
341version = "1.0.0"
342"#,
343 )
344 .unwrap();
345
346 let mut finder = PythonProjectFinder::new();
347 finder
349 .visit(temp_dir.path(), &PathBuf::from("."))
350 .await
351 .unwrap();
352
353 assert_eq!(finder.projects().len(), 0);
354
355 temp_dir.close().unwrap();
356 }
357
358 #[tokio::test]
359 async fn test_python_project_finder_visit_duplicate() {
360 let temp_dir = TempDir::new().unwrap();
361 let pyproject_toml = temp_dir.path().join("pyproject.toml");
362 fs::write(
363 &pyproject_toml,
364 r#"[project]
365name = "test-package"
366version = "1.0.0"
367"#,
368 )
369 .unwrap();
370
371 let mut finder = PythonProjectFinder::new();
372 finder
373 .visit(&pyproject_toml, &PathBuf::from("pyproject.toml"))
374 .await
375 .unwrap();
376
377 assert_eq!(finder.projects().len(), 1);
378
379 finder
381 .visit(&pyproject_toml, &PathBuf::from("pyproject.toml"))
382 .await
383 .unwrap();
384
385 assert_eq!(finder.projects().len(), 1);
386
387 temp_dir.close().unwrap();
388 }
389
390 #[tokio::test]
391 async fn test_python_project_finder_visit_multiple_packages() {
392 let temp_dir = TempDir::new().unwrap();
393 let pyproject_toml1 = temp_dir.path().join("package1").join("pyproject.toml");
394 fs::create_dir_all(pyproject_toml1.parent().unwrap()).unwrap();
395 fs::write(
396 &pyproject_toml1,
397 r#"[project]
398name = "package1"
399version = "1.0.0"
400"#,
401 )
402 .unwrap();
403
404 let pyproject_toml2 = temp_dir.path().join("package2").join("pyproject.toml");
405 fs::create_dir_all(pyproject_toml2.parent().unwrap()).unwrap();
406 fs::write(
407 &pyproject_toml2,
408 r#"[project]
409name = "package2"
410version = "2.0.0"
411"#,
412 )
413 .unwrap();
414
415 let mut finder = PythonProjectFinder::new();
416 finder
417 .visit(&pyproject_toml1, &PathBuf::from("package1/pyproject.toml"))
418 .await
419 .unwrap();
420 finder
421 .visit(&pyproject_toml2, &PathBuf::from("package2/pyproject.toml"))
422 .await
423 .unwrap();
424
425 let projects = finder.projects();
426 assert_eq!(projects.len(), 2);
427
428 temp_dir.close().unwrap();
429 }
430
431 #[tokio::test]
432 async fn test_python_project_finder_projects_mut() {
433 let temp_dir = TempDir::new().unwrap();
434 let pyproject_toml = temp_dir.path().join("pyproject.toml");
435 fs::write(
436 &pyproject_toml,
437 r#"[project]
438name = "test-package"
439version = "1.0.0"
440"#,
441 )
442 .unwrap();
443
444 let mut finder = PythonProjectFinder::new();
445 finder
446 .visit(&pyproject_toml, &PathBuf::from("pyproject.toml"))
447 .await
448 .unwrap();
449
450 let mut_projects = finder.projects_mut();
451 assert_eq!(mut_projects.len(), 1);
452
453 temp_dir.close().unwrap();
454 }
455
456 #[tokio::test]
457 async fn test_python_project_finder_visit_package_without_project_section() {
458 let temp_dir = TempDir::new().unwrap();
466 let pyproject_toml = temp_dir.path().join("pyproject.toml");
467 fs::write(
468 &pyproject_toml,
469 r#"[build-system]
470requires = ["setuptools"]
471"#,
472 )
473 .unwrap();
474
475 let mut finder = PythonProjectFinder::new();
476 finder
477 .visit(&pyproject_toml, &PathBuf::from("pyproject.toml"))
478 .await
479 .unwrap();
480
481 let mut projects = finder.projects_mut();
482 assert_eq!(projects.len(), 1);
483 let project = projects.pop().unwrap();
484 assert!(matches!(&project, Project::Package(_)));
485 assert_eq!(project.name(), None);
486 assert_eq!(project.version(), None);
487 assert!(!project.is_publishable_by_default());
488
489 project.set_name("repository-name".to_string());
490
491 assert_eq!(project.name(), Some("repository-name"));
492 assert!(!project.is_publishable_by_default());
493
494 temp_dir.close().unwrap();
495 }
496
497 #[tokio::test]
498 async fn test_python_project_finder_visit_workspace_without_project_section() {
499 let temp_dir = TempDir::new().unwrap();
508 let pyproject_toml = temp_dir.path().join("pyproject.toml");
509 fs::write(
510 &pyproject_toml,
511 r#"[tool.uv.workspace]
512members = ["packages/*"]
513"#,
514 )
515 .unwrap();
516
517 let mut finder = PythonProjectFinder::new();
518 finder
519 .visit(&pyproject_toml, &PathBuf::from("pyproject.toml"))
520 .await
521 .unwrap();
522
523 let projects = finder.projects();
524 assert_eq!(projects.len(), 1);
525 let ws = projects[0].expect_workspace();
526 assert_eq!(ws.name(), None);
527 assert_eq!(ws.version(), None);
528 assert!(!ws.is_publishable_by_default());
529
530 temp_dir.close().unwrap();
531 }
532
533 #[tokio::test]
534 async fn test_workspace_only_root_fallback_name_does_not_enable_default_publish() {
535 let temp_dir = TempDir::new().unwrap();
536 let pyproject_toml = temp_dir.path().join("pyproject.toml");
537 fs::write(
538 &pyproject_toml,
539 r#"[tool.uv.workspace]
540members = ["packages/*"]
541"#,
542 )
543 .unwrap();
544
545 let mut finder = PythonProjectFinder::new();
546 finder
547 .visit(&pyproject_toml, &PathBuf::from("pyproject.toml"))
548 .await
549 .unwrap();
550
551 let mut projects = finder.projects_mut();
552 assert_eq!(projects.len(), 1);
553 let project = projects.pop().unwrap();
554 assert!(!project.is_publishable_by_default());
555
556 project.set_name("repository-name".to_string());
557
558 assert_eq!(project.name(), Some("repository-name"));
559 assert!(!project.is_publishable_by_default());
560
561 temp_dir.close().unwrap();
562 }
563
564 #[tokio::test]
565 async fn test_python_project_finder_visit_registers_uv_sources_dependencies() {
566 let temp_dir = TempDir::new().unwrap();
572 let pyproject_toml = temp_dir.path().join("pyproject.toml");
573 fs::write(
574 &pyproject_toml,
575 r#"[tool.uv.workspace]
576members = ["packages/*"]
577
578 [tool.uv.sources]
579 pkg-a = { path = "packages/pkg-a", editable = true }
580 pkg-b = { workspace = true }
581 pkg-c = { git = "https://example.com/pkg-c.git", editable = true }
582 pkg-d = { url = "https://example.com/pkg-d.tar.gz" }
583 pkg-e = { workspace = false }
584
585 [project]
586 name = "test-workspace"
587version = "1.0.0"
588"#,
589 )
590 .unwrap();
591
592 let mut finder = PythonProjectFinder::new();
593 finder
594 .visit(&pyproject_toml, &PathBuf::from("pyproject.toml"))
595 .await
596 .unwrap();
597
598 let projects = finder.projects();
599 assert_eq!(projects.len(), 1);
600 let deps = projects[0].expect_workspace().dependencies();
601 assert_eq!(
602 deps.len(),
603 2,
604 "expected only local tool.uv.sources entries, got {deps:?}"
605 );
606 assert!(deps.contains("pkg-a"), "missing pkg-a in {deps:?}");
607 assert!(deps.contains("pkg-b"), "missing pkg-b in {deps:?}");
608 assert!(!deps.contains("pkg-c"), "unexpected pkg-c in {deps:?}");
609 assert!(!deps.contains("pkg-d"), "unexpected pkg-d in {deps:?}");
610 assert!(!deps.contains("pkg-e"), "unexpected pkg-e in {deps:?}");
611
612 temp_dir.close().unwrap();
613 }
614
615 #[tokio::test]
616 async fn test_python_project_finder_skips_non_table_uv_sources() {
617 let temp_dir = TempDir::new().unwrap();
622 let pyproject_toml = temp_dir.path().join("pyproject.toml");
623 fs::write(
624 &pyproject_toml,
625 r#"[tool.uv]
626sources = "packages/pkg-a"
627
628[project]
629name = "test-package"
630version = "1.0.0"
631"#,
632 )
633 .unwrap();
634
635 let mut finder = PythonProjectFinder::new();
636 finder
637 .visit(&pyproject_toml, &PathBuf::from("pyproject.toml"))
638 .await
639 .unwrap();
640
641 let projects = finder.projects();
642 assert_eq!(projects.len(), 1);
643 let deps = projects[0].dependencies();
644 assert!(
645 deps.is_empty(),
646 "scalar tool.uv.sources must register no dependencies, got {deps:?}"
647 );
648
649 temp_dir.close().unwrap();
650 }
651
652 #[tokio::test]
653 async fn test_python_project_finder_skips_non_table_uv_source_entry() {
654 let temp_dir = TempDir::new().unwrap();
659 let pyproject_toml = temp_dir.path().join("pyproject.toml");
660 fs::write(
661 &pyproject_toml,
662 r#"[project]
663name = "test-package"
664version = "1.0.0"
665
666[tool.uv.sources]
667pkg-a = { path = "packages/pkg-a" }
668pkg-f = "packages/pkg-f"
669"#,
670 )
671 .unwrap();
672
673 let mut finder = PythonProjectFinder::new();
674 finder
675 .visit(&pyproject_toml, &PathBuf::from("pyproject.toml"))
676 .await
677 .unwrap();
678
679 let projects = finder.projects();
680 assert_eq!(projects.len(), 1);
681 let deps = projects[0].dependencies();
682 assert_eq!(
683 deps.len(),
684 1,
685 "expected only the table-like source entry, got {deps:?}"
686 );
687 assert!(deps.contains("pkg-a"), "missing pkg-a in {deps:?}");
688 assert!(!deps.contains("pkg-f"), "unexpected pkg-f in {deps:?}");
689
690 temp_dir.close().unwrap();
691 }
692
693 #[tokio::test]
694 async fn test_uv_sources_feed_local_graphs_and_ignore_registry_only_names() {
695 use changepacks_utils::sort_by_dependencies;
707
708 let temp_dir = TempDir::new().unwrap();
709 let manifests = [
710 (
711 "core",
712 r#"[project]
713name = "core"
714version = "1.0.0"
715"#,
716 ),
717 (
718 "app",
719 r#"[project]
720name = "app"
721version = "1.0.0"
722dependencies = ["core", "registry-only"]
723
724[tool.uv.sources]
725core = { path = "../core" }
726registry-only = { index = "internal-index" }
727"#,
728 ),
729 ];
730
731 let mut finder = PythonProjectFinder::new();
732 for (directory, contents) in manifests {
733 let path = temp_dir.path().join(directory).join("pyproject.toml");
734 fs::create_dir_all(path.parent().unwrap()).unwrap();
735 fs::write(&path, contents).unwrap();
736 finder
737 .visit(&path, &PathBuf::from(directory).join("pyproject.toml"))
738 .await
739 .unwrap();
740 }
741
742 let projects = finder.projects();
743 assert_eq!(projects.len(), 2);
744 let by_name = |name: &str| {
745 *projects
746 .iter()
747 .find(|project| project.name() == Some(name))
748 .unwrap()
749 };
750 let app = by_name("app");
751 let app_deps = app.dependencies();
752 assert_eq!(
753 app_deps.len(),
754 1,
755 "only the local path source must register, got {app_deps:?}"
756 );
757 assert!(app_deps.contains("core"), "missing core in {app_deps:?}");
758
759 let sorted =
760 sort_by_dependencies(vec![app, by_name("core")]).expect("fixture graph is a DAG");
761 assert_eq!(sorted[0].name(), Some("core"));
762 assert_eq!(sorted[1].name(), Some("app"));
763
764 temp_dir.close().unwrap();
765 }
766
767 #[tokio::test]
768 async fn test_python_project_finder_visit_malformed_manifest() {
769 let temp_dir = TempDir::new().unwrap();
773 let pyproject_toml = temp_dir.path().join("pyproject.toml");
774 fs::write(&pyproject_toml, "invalid toml [[[").unwrap();
775
776 let mut finder = PythonProjectFinder::new();
777 let result = finder
778 .visit(&pyproject_toml, &PathBuf::from("pyproject.toml"))
779 .await;
780
781 assert!(result.is_err(), "Expected error for malformed manifest");
782 let error_msg = result.unwrap_err().to_string();
783 assert!(
784 error_msg.contains("Failed to parse pyproject.toml"),
785 "Error message missing 'Failed to parse pyproject.toml': {error_msg}"
786 );
787 assert!(
788 error_msg.contains(pyproject_toml.to_string_lossy().as_ref()),
789 "Error message missing path: {error_msg}"
790 );
791
792 temp_dir.close().unwrap();
793 }
794}