Skip to content

Commit 071f784

Browse files
doquanghuyclaude
andauthored
feat(workflows): opt-in output_format: json exposes parsed shell stdout as output.data (#2963)
* feat(workflows): opt-in output_format: json exposes parsed shell stdout as output.data No step that runs external code could hand a typed value to a later step, so e.g. a fan-out could never consume a runtime-computed collection. With output_format: json declared, stdout is parsed and exposed under output.data (raw keys unchanged); a parse failure fails the step with a clear error. Without the key, behavior is unchanged. Reference implementation for the proposal in #2962. Addresses #2962 * test(shell): emit JSON via sys.executable for cross-platform output_format tests Address review (#2963): replace non-portable echo '{...}' (Windows cmd.exe keeps the single quotes, breaking JSON) with the established '"{py}" "{script}"' pattern using sys.executable + a temp script, so the output_format tests pass on the Windows CI matrix. Also make the validate test's run inert (exit 0). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 1ee2b62 commit 071f784

2 files changed

Lines changed: 91 additions & 0 deletions

File tree

src/specify_cli/workflows/steps/shell/__init__.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import json
56
import subprocess
67
from typing import Any
78

@@ -49,6 +50,23 @@ def execute(self, config: dict[str, Any], context: StepContext) -> StepResult:
4950
error=f"Shell command exited with code {proc.returncode}.",
5051
output=output,
5152
)
53+
if config.get("output_format") == "json":
54+
# Opt-in structured output: expose the parsed stdout under
55+
# ``output.data`` so later steps can consume typed values
56+
# (e.g. a fan-out's ``items:``). A parse failure fails the
57+
# step — declaring ``output_format: json`` is a contract.
58+
try:
59+
output["data"] = json.loads(proc.stdout)
60+
except json.JSONDecodeError as exc:
61+
return StepResult(
62+
status=StepStatus.FAILED,
63+
error=(
64+
f"Shell step {config.get('id', '?')!r} declared "
65+
f"output_format: json but stdout is not valid "
66+
f"JSON: {exc}"
67+
),
68+
output=output,
69+
)
5270
return StepResult(
5371
status=StepStatus.COMPLETED,
5472
output=output,
@@ -72,4 +90,10 @@ def validate(self, config: dict[str, Any]) -> list[str]:
7290
errors.append(
7391
f"Shell step {config.get('id', '?')!r} is missing 'run' field."
7492
)
93+
output_format = config.get("output_format")
94+
if output_format is not None and output_format != "json":
95+
errors.append(
96+
f"Shell step {config.get('id', '?')!r}: 'output_format' must "
97+
f"be 'json' when present, got {output_format!r}."
98+
)
7599
return errors

tests/test_workflows.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -912,6 +912,17 @@ def test_validate_valid(self):
912912
class TestShellStep:
913913
"""Test the shell step type."""
914914

915+
@staticmethod
916+
def _python_run(tmp_path, body):
917+
"""A portable shell ``run`` that executes ``body`` with the current
918+
interpreter, avoiding non-portable shell quoting (e.g. Windows
919+
``cmd.exe`` keeping single quotes) in the output_format tests."""
920+
import sys
921+
922+
script = tmp_path / "emit.py"
923+
script.write_text(body, encoding="utf-8")
924+
return f'"{sys.executable}" "{script}"'
925+
915926
def test_execute_echo(self):
916927
from specify_cli.workflows.steps.shell import ShellStep
917928
from specify_cli.workflows.base import StepContext, StepStatus
@@ -944,6 +955,62 @@ def test_validate_missing_run(self):
944955
assert any("missing 'run'" in e for e in errors)
945956

946957

958+
def test_output_format_json_exposes_data(self, tmp_path):
959+
from specify_cli.workflows.steps.shell import ShellStep
960+
from specify_cli.workflows.base import StepContext, StepStatus
961+
962+
step = ShellStep()
963+
ctx = StepContext(project_root=str(tmp_path))
964+
config = {
965+
"id": "emit",
966+
"run": self._python_run(
967+
tmp_path, 'import json; print(json.dumps({"items": [1, 2]}))\n'
968+
),
969+
"output_format": "json",
970+
}
971+
result = step.execute(config, ctx)
972+
assert result.status == StepStatus.COMPLETED
973+
assert result.output["data"] == {"items": [1, 2]}
974+
assert result.output["exit_code"] == 0 # raw keys still present
975+
976+
def test_output_format_json_invalid_stdout_fails(self, tmp_path):
977+
from specify_cli.workflows.steps.shell import ShellStep
978+
from specify_cli.workflows.base import StepContext, StepStatus
979+
980+
step = ShellStep()
981+
ctx = StepContext(project_root=str(tmp_path))
982+
config = {
983+
"id": "emit",
984+
"run": self._python_run(tmp_path, "print('not-json')\n"),
985+
"output_format": "json",
986+
}
987+
result = step.execute(config, ctx)
988+
assert result.status == StepStatus.FAILED
989+
assert "output_format: json" in (result.error or "")
990+
991+
def test_no_output_format_keeps_raw_output_only(self, tmp_path):
992+
from specify_cli.workflows.steps.shell import ShellStep
993+
from specify_cli.workflows.base import StepContext, StepStatus
994+
995+
step = ShellStep()
996+
ctx = StepContext(project_root=str(tmp_path))
997+
config = {
998+
"id": "emit",
999+
"run": self._python_run(
1000+
tmp_path, 'import json; print(json.dumps({"items": []}))\n'
1001+
),
1002+
}
1003+
result = step.execute(config, ctx)
1004+
assert result.status == StepStatus.COMPLETED
1005+
assert "data" not in result.output
1006+
1007+
def test_validate_rejects_unknown_output_format(self):
1008+
from specify_cli.workflows.steps.shell import ShellStep
1009+
1010+
step = ShellStep()
1011+
errors = step.validate({"id": "emit", "run": "exit 0", "output_format": "yaml"})
1012+
assert any("'output_format' must be 'json'" in e for e in errors)
1013+
9471014
class _StubStdin:
9481015
"""Stdin stub exposing only a fixed ``isatty`` result.
9491016

0 commit comments

Comments
 (0)