diff --git a/concore_cli/commands/_source_dir.py b/concore_cli/commands/_source_dir.py new file mode 100644 index 0000000..53f3fe8 --- /dev/null +++ b/concore_cli/commands/_source_dir.py @@ -0,0 +1,15 @@ +from pathlib import Path + + +def resolve_source_dir(workflow_path, source_dir): + """Resolve --source the same way for build, validate and inspect. + + A path that exists from the current directory wins, so + `--source demo/src` works from outside the project. Otherwise fall + back to the folder next to the workflow, so the default `src` still + works when the workflow is in a subdirectory. + """ + source = Path(source_dir) + if source.is_absolute() or source.exists(): + return source + return Path(workflow_path).parent / source diff --git a/concore_cli/commands/build.py b/concore_cli/commands/build.py index 86fe0bc..aeb2e50 100644 --- a/concore_cli/commands/build.py +++ b/concore_cli/commands/build.py @@ -7,6 +7,7 @@ from rich.panel import Panel from rich.progress import Progress, SpinnerColumn, TextColumn +from ._source_dir import resolve_source_dir from .metadata import write_study_metadata @@ -162,7 +163,7 @@ def build_workflow( zmq_mode=False, ): workflow_path = Path(workflow_file).resolve() - source_path = Path(source).resolve() + source_path = resolve_source_dir(workflow_path, source).resolve() output_path = Path(output).resolve() if not source_path.exists(): diff --git a/concore_cli/commands/inspect.py b/concore_cli/commands/inspect.py index 4d960f4..4cfcf91 100644 --- a/concore_cli/commands/inspect.py +++ b/concore_cli/commands/inspect.py @@ -5,9 +5,12 @@ from collections import defaultdict import re +from ._source_dir import resolve_source_dir + def inspect_workflow(workflow_file, source_dir, output_json, console): workflow_path = Path(workflow_file) + source_dir = resolve_source_dir(workflow_path, source_dir) if output_json: return _inspect_json(workflow_path, source_dir) @@ -61,8 +64,7 @@ def _inspect_rich(workflow_path, source_dir, console): else: lang_counts["Other"] += 1 - src_dir = workflow_path.parent / source_dir - if not (src_dir / filename).exists(): + if not (source_dir / filename).exists(): missing_files.append(filename) nodes_branch = tree.add(f"Nodes: [bold]{len(nodes)}[/bold]") @@ -133,8 +135,7 @@ def _inspect_rich(workflow_path, source_dir, console): } lang = lang_map.get(ext, "Other") - src_dir = workflow_path.parent / source_dir - status = "✓" if (src_dir / filename).exists() else "✗" + status = "✓" if (source_dir / filename).exists() else "✗" table.add_row(node_id, filename, lang, status) @@ -220,8 +221,7 @@ def _inspect_json(workflow_path, source_dir): lang = lang_map.get(ext, "other") lang_counts[lang] += 1 - src_dir = workflow_path.parent / source_dir - exists = (src_dir / filename).exists() + exists = (source_dir / filename).exists() if not exists: missing_files.append(filename) diff --git a/concore_cli/commands/validate.py b/concore_cli/commands/validate.py index 5adefd0..c8972d7 100644 --- a/concore_cli/commands/validate.py +++ b/concore_cli/commands/validate.py @@ -5,6 +5,8 @@ import re import xml.etree.ElementTree as ET +from ._source_dir import resolve_source_dir + def _classify_message(message, bucket_name): if bucket_name == "info": @@ -132,7 +134,7 @@ def _build_payload(workflow_path, source_root, errors, warnings, info, source_no def validate_workflow(workflow_file, source_dir, console, output_format="text"): workflow_path = Path(workflow_file) - source_root = workflow_path.parent / source_dir + source_root = resolve_source_dir(workflow_path, source_dir) if output_format == "text": console.print(f"[cyan]Validating:[/cyan] {workflow_path.name}") diff --git a/tests/test_cli.py b/tests/test_cli.py index 70441f6..c679fc4 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -139,6 +139,30 @@ def test_validate_json_output_for_missing_source_file(self): self.assertEqual(payload["errors"][0]["error_type"], "missing_source_file") self.assertEqual(payload["errors"][0]["node_id"], "n1") + def test_validate_source_relative_to_cwd(self): + with self.runner.isolated_filesystem(temp_dir=self.temp_dir): + result = self.runner.invoke(cli, ["init", "test-project"]) + self.assertEqual(result.exit_code, 0) + + Path("test-project/src/script.py").unlink() + + result = self.runner.invoke( + cli, + [ + "validate", + "test-project/workflow.graphml", + "--source", + "test-project/src", + "--format", + "json", + ], + ) + self.assertNotEqual(result.exit_code, 0) + + payload = json.loads(result.output) + self.assertEqual(payload["errors"][0]["error_type"], "missing_source_file") + self.assertEqual(Path(payload["source_dir"]), Path("test-project/src")) + def test_validate_json_output_for_node_without_label(self): with self.runner.isolated_filesystem(temp_dir=self.temp_dir): Path("src").mkdir() @@ -178,6 +202,25 @@ def test_build_command_missing_source(self): ) self.assertNotEqual(result.exit_code, 0) + def test_build_command_default_source_next_to_workflow(self): + with self.runner.isolated_filesystem(temp_dir=self.temp_dir): + result = self.runner.invoke(cli, ["init", "test-project"]) + self.assertEqual(result.exit_code, 0) + + result = self.runner.invoke( + cli, + [ + "build", + "test-project/workflow.graphml", + "--output", + "out", + "--type", + "posix", + ], + ) + self.assertEqual(result.exit_code, 0, result.output) + self.assertTrue(Path("out/src/script.py").exists()) + def test_build_command_rejects_edge_label_with_multiple_sources(self): with self.runner.isolated_filesystem(temp_dir=self.temp_dir): Path("src").mkdir() @@ -888,6 +931,24 @@ def test_inspect_missing_source_file(self): self.assertEqual(result.exit_code, 0) self.assertIn("Missing files", result.output) + def test_inspect_source_relative_to_cwd(self): + with self.runner.isolated_filesystem(temp_dir=self.temp_dir): + result = self.runner.invoke(cli, ["init", "test-project"]) + self.assertEqual(result.exit_code, 0) + + result = self.runner.invoke( + cli, + [ + "inspect", + "test-project/workflow.graphml", + "--source", + "test-project/src", + "--json", + ], + ) + self.assertEqual(result.exit_code, 0) + self.assertEqual(json.loads(result.output)["missing_files"], []) + def test_watch_rejects_negative_interval(self): with self.runner.isolated_filesystem(temp_dir=self.temp_dir): os.mkdir("study")