Skip to content

Commit 9b35960

Browse files
committed
Fix: add --output PATH flag to generate command (#35)
The generate command always derived the output path from the workflow ID via harness_path(workflow). Callers had no way to redirect scripts to project-local bin folders, CI artifact directories, or custom locations. Changes: - Added --output PATH option to generate() in cli.py - Validates --output only when exactly one workflow is selected; aborts with a clear error message when multiple workflows would be generated - Updated write_harnesses() to accept optional output_path override - Updated EXPECTED_HELP constant to include the new option - Added 5 new tests covering: explicit path, parent dir creation, multi-workflow rejection, dry-run path display, and single-workflow selector compatibility Fixes #35
1 parent fc8e683 commit 9b35960

2 files changed

Lines changed: 186 additions & 3 deletions

File tree

src/flowsh_cli/cli.py

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,14 @@ def generate(
9191
is_eager=True,
9292
),
9393
] = None,
94+
output: Annotated[
95+
Path | None,
96+
typer.Option(
97+
"--output",
98+
metavar="PATH",
99+
help="Write generated script to PATH. Only valid when generating a single workflow.",
100+
),
101+
] = None,
94102
) -> None:
95103
"""Generate Bash harnesses from workflow YAML."""
96104

@@ -102,7 +110,14 @@ def generate(
102110
try:
103111
workflows = parse_workflows(workflow_yaml)
104112
selected = select_workflows(workflows, workflow)
105-
write_harnesses(selected, dry_run=dry_run, force=force)
113+
if output is not None and len(selected) != 1:
114+
print(
115+
"ERROR: --output requires exactly one workflow "
116+
"(use --workflow or ensure the file contains only one workflow)",
117+
file=sys.stderr,
118+
)
119+
raise typer.Exit(1)
120+
write_harnesses(selected, dry_run=dry_run, force=force, output_path=output)
106121
except WorkflowParseError as error:
107122
print(f"ERROR: {error}", file=sys.stderr)
108123
raise typer.Exit(1) from error
@@ -159,8 +174,13 @@ def print_example(value: str | None) -> None:
159174
raise typer.Exit
160175

161176

162-
def write_harnesses(workflows: list[Workflow], *, dry_run: bool, force: bool) -> None:
163-
output_paths = [(workflow, harness_path(workflow)) for workflow in workflows]
177+
def write_harnesses(
178+
workflows: list[Workflow], *, dry_run: bool, force: bool, output_path: Path | None = None
179+
) -> None:
180+
output_paths = [
181+
(workflow, output_path if output_path is not None else harness_path(workflow))
182+
for workflow in workflows
183+
]
164184

165185
if dry_run:
166186
for workflow, output_path in output_paths:

tests/test_workflow_to_harness.py

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
--schema Show the workflow YAML schema and exit.
3737
--examples List available workflow examples and exit.
3838
--example NAME Print a named example workflow YAML to stdout and exit.
39+
--output PATH Write generated script to PATH. Only valid when generating a single workflow.
3940
--help Show this message and exit.
4041
"""
4142

@@ -2837,3 +2838,165 @@ def test_workflow_file_without_description_defaults_to_none() -> None:
28372838
}
28382839
wf = WorkflowFile.model_validate(data)
28392840
assert wf.description is None
2841+
2842+
2843+
# ---------------------------------------------------------------------------
2844+
# --output tests (issue #35)
2845+
# ---------------------------------------------------------------------------
2846+
2847+
2848+
def test_cli_output_writes_harness_to_explicit_path(tmp_path: Path) -> None:
2849+
"""--output writes the script to the given path, not the default harness_path."""
2850+
workflow_file = tmp_path / "workflows.yml"
2851+
write_workflow(workflow_file)
2852+
custom_output = tmp_path / "scripts" / "run.sh"
2853+
2854+
result = subprocess.run(
2855+
[
2856+
sys.executable,
2857+
"-m",
2858+
"flowsh_cli",
2859+
str(workflow_file),
2860+
"--workflow",
2861+
"wf_example",
2862+
"--output",
2863+
str(custom_output),
2864+
],
2865+
check=False,
2866+
capture_output=True,
2867+
text=True,
2868+
cwd=tmp_path,
2869+
)
2870+
2871+
assert result.returncode == 0, result.stderr
2872+
assert custom_output.exists()
2873+
assert custom_output.read_text(encoding="utf-8").startswith("#!/usr/bin/env bash\n")
2874+
assert custom_output.stat().st_mode & 0o777 == 0o700
2875+
# Default path must NOT have been created
2876+
assert not (tmp_path / "example.sh").exists()
2877+
2878+
2879+
def test_cli_output_creates_parent_directories(tmp_path: Path) -> None:
2880+
"""--output creates intermediate parent directories when they do not exist."""
2881+
workflow_file = tmp_path / "workflows.yml"
2882+
write_workflow(workflow_file)
2883+
nested_output = tmp_path / "a" / "b" / "c" / "run.sh"
2884+
2885+
result = subprocess.run(
2886+
[
2887+
sys.executable,
2888+
"-m",
2889+
"flowsh_cli",
2890+
str(workflow_file),
2891+
"--workflow",
2892+
"wf_example",
2893+
"--output",
2894+
str(nested_output),
2895+
],
2896+
check=False,
2897+
capture_output=True,
2898+
text=True,
2899+
cwd=tmp_path,
2900+
)
2901+
2902+
assert result.returncode == 0, result.stderr
2903+
assert nested_output.exists()
2904+
2905+
2906+
def test_cli_output_rejects_multi_workflow_without_selector(tmp_path: Path) -> None:
2907+
"""--output aborts with an error when multiple workflows would be generated."""
2908+
workflow_file = tmp_path / "workflows.yml"
2909+
workflow_file.write_text(
2910+
"""\
2911+
workflows:
2912+
- id: wf_first
2913+
name: First
2914+
steps:
2915+
- type: bash
2916+
run: printf 'first\\n'
2917+
- id: wf_second
2918+
name: Second
2919+
steps:
2920+
- type: bash
2921+
run: printf 'second\\n'
2922+
""",
2923+
encoding="utf-8",
2924+
)
2925+
2926+
result = subprocess.run(
2927+
[
2928+
sys.executable,
2929+
"-m",
2930+
"flowsh_cli",
2931+
str(workflow_file),
2932+
"--output",
2933+
str(tmp_path / "out.sh"),
2934+
],
2935+
check=False,
2936+
capture_output=True,
2937+
text=True,
2938+
cwd=tmp_path,
2939+
)
2940+
2941+
assert result.returncode == 1
2942+
assert result.stdout == ""
2943+
assert "--output requires exactly one workflow" in result.stderr
2944+
assert not (tmp_path / "out.sh").exists()
2945+
assert not (tmp_path / "first.sh").exists()
2946+
2947+
2948+
def test_cli_output_dry_run_prints_resolved_path(tmp_path: Path) -> None:
2949+
"""--dry-run with --output prints the --output path, not the default path."""
2950+
workflow_file = tmp_path / "workflows.yml"
2951+
write_workflow(workflow_file)
2952+
custom_output = tmp_path / "scripts" / "run.sh"
2953+
2954+
result = subprocess.run(
2955+
[
2956+
sys.executable,
2957+
"-m",
2958+
"flowsh_cli",
2959+
str(workflow_file),
2960+
"--workflow",
2961+
"wf_example",
2962+
"--output",
2963+
str(custom_output),
2964+
"--dry-run",
2965+
],
2966+
check=False,
2967+
capture_output=True,
2968+
text=True,
2969+
cwd=tmp_path,
2970+
)
2971+
2972+
assert result.returncode == 0, result.stderr
2973+
assert str(custom_output) in result.stdout
2974+
assert "DRY-RUN" in result.stdout
2975+
assert not custom_output.exists()
2976+
2977+
2978+
def test_cli_output_with_workflow_selector_for_single_workflow_file(tmp_path: Path) -> None:
2979+
"""--output combined with --workflow works when file has a single workflow."""
2980+
workflow_file = tmp_path / "workflows.yml"
2981+
write_workflow(workflow_file)
2982+
2983+
result = subprocess.run(
2984+
[
2985+
sys.executable,
2986+
"-m",
2987+
"flowsh_cli",
2988+
str(workflow_file),
2989+
"--workflow",
2990+
"wf_example",
2991+
"--output",
2992+
"out.sh",
2993+
],
2994+
check=False,
2995+
capture_output=True,
2996+
text=True,
2997+
cwd=tmp_path,
2998+
)
2999+
3000+
assert result.returncode == 0, result.stderr
3001+
assert (tmp_path / "out.sh").exists()
3002+
assert not (tmp_path / "example.sh").exists()

0 commit comments

Comments
 (0)