mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 15:37:55 +08:00
tools: model batch steps as nested executions
This commit is contained in:
@@ -456,9 +456,9 @@ async def test_pretty_run_argv_batch_stopped(capsys: pytest.CaptureFixture[str])
|
||||
result = (
|
||||
"steps=3 ran=2 stop_on_error=true stopped_early=true\n"
|
||||
"--- step 1 ---\nexit_code=1\ntimed_out=false\ncwd=.\ncmd=git status\n"
|
||||
"pipe_out=false\nmix_stderr=false\n--- stdout ---\n--- stderr ---\n"
|
||||
"pipe=false\nmix_stderr=false\n--- stdout ---\n--- stderr ---\n"
|
||||
"--- step 2 ---\nexit_code=0\ntimed_out=false\ncwd=.\ncmd=git diff\n"
|
||||
"pipe_out=false\nmix_stderr=false\n--- stdout ---\n--- stderr ---\n"
|
||||
"pipe=false\nmix_stderr=false\n--- stdout ---\n--- stderr ---\n"
|
||||
"--- summary ---\nlast_exit=1\n"
|
||||
)
|
||||
await render_events(_aiter([_pretty_call("run_argv_batch", '{"steps": []}'), _result(result)]))
|
||||
@@ -495,16 +495,27 @@ async def test_pretty_run_argv_batch_lists_commands(capsys: pytest.CaptureFixtur
|
||||
|
||||
|
||||
async def test_pretty_run_argv_batch_joins_pipe_chain(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""``pipe_out`` steps share one line; a step they do not feed keeps its own."""
|
||||
"""A pipe chain shares one line; a node it does not feed keeps its own."""
|
||||
args = (
|
||||
'{"steps": [{"argv": ["grep", "-n", "foo", "a.py"], "pipe_out": true},'
|
||||
' {"argv": ["wc", "-l"]}, {"argv": ["git", "status"]}]}'
|
||||
'{"steps": [{"argv": ["grep", "-n", "foo", "a.py"], "pipe": {"argv": ["wc", "-l"]}},'
|
||||
' {"argv": ["git", "status"]}]}'
|
||||
)
|
||||
await render_events(_aiter([_pretty_call("run_argv_batch", args), _result(_BATCH_DONE_RESULT)]))
|
||||
out = capsys.readouterr().out
|
||||
assert "$ grep -n foo a.py | wc -l\n$ git status\n* Run batch (done)" in out
|
||||
|
||||
|
||||
async def test_pretty_run_argv_batch_indents_branches(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""A ``then`` / ``else`` body sits below the node it follows."""
|
||||
args = (
|
||||
'{"steps": [{"argv": ["pytest", "-q"], "then": {"argv": ["ruff", "format", "."]},'
|
||||
' "else": [{"argv": ["git", "status"]}]}]}'
|
||||
)
|
||||
await render_events(_aiter([_pretty_call("run_argv_batch", args), _result(_BATCH_DONE_RESULT)]))
|
||||
out = capsys.readouterr().out
|
||||
assert "$ pytest -q\n then ruff format .\n else git status\n* Run batch (done)" in out
|
||||
|
||||
|
||||
async def test_pretty_run_argv_batch_elides_a_long_command(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
long_arg = "x" * 200
|
||||
args = f'{{"steps": [{{"argv": ["echo", "{long_arg}"]}}]}}'
|
||||
|
||||
@@ -108,6 +108,34 @@ def test_classify_run_argv_batch_risky() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_classify_run_argv_batch_sees_nested_steps() -> None:
|
||||
"""A command hiding in a continuation is reviewed like a top-level step."""
|
||||
reason = _danger(
|
||||
"run_argv_batch",
|
||||
{
|
||||
"steps": [
|
||||
{
|
||||
"argv": ["echo", "ok"],
|
||||
"then": {"argv": ["bash", "-c", "echo risky"]},
|
||||
"else": [{"argv": ["sh", "-c", "echo risky"]}],
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
assert "run_argv_batch[0].then[0]" in reason.text
|
||||
assert "run_argv_batch[0].else[0]" in reason.text
|
||||
# A plain interpreter run is YOLO mode's to drop, wherever it sits.
|
||||
assert reason.yolo_skip is True
|
||||
|
||||
|
||||
def test_a_nested_step_that_escalates_keeps_the_batch_prompt() -> None:
|
||||
reason = _danger(
|
||||
"run_argv_batch",
|
||||
{"steps": [{"argv": ["echo", "ok"], "then": {"argv": ["sudo", "python", "-c", "print(1)"]}}]},
|
||||
)
|
||||
assert reason.yolo_skip is False
|
||||
|
||||
|
||||
def test_classify_shell_and_dash_c() -> None:
|
||||
r = _reason("run_argv", {"argv": ["bash", "-c", "rm -rf /"]})
|
||||
assert r is not None
|
||||
|
||||
@@ -112,33 +112,31 @@ async def test_run_argv_batch_stop_on_error(workspace: object) -> None:
|
||||
assert "should-not-run" not in out
|
||||
|
||||
|
||||
async def test_run_argv_batch_pipe_out(workspace: object) -> None:
|
||||
async def test_run_argv_batch_pipe(workspace: object) -> None:
|
||||
del workspace
|
||||
out = await call_async(
|
||||
run_argv_batch,
|
||||
[
|
||||
{
|
||||
"argv": _py("print('piped-payload', end='')"),
|
||||
"pipe_out": True,
|
||||
},
|
||||
{
|
||||
"argv": _py("import sys; print(sys.stdin.read())"),
|
||||
"pipe": {"argv": _py("import sys; print(sys.stdin.read())")},
|
||||
},
|
||||
],
|
||||
)
|
||||
assert "ran=2" in out
|
||||
assert "piped-payload" in out
|
||||
assert "pipe_out=true" in out
|
||||
assert "pipe=true" in out
|
||||
|
||||
|
||||
async def test_run_argv_batch_last_pipe_out_error(workspace: object) -> None:
|
||||
async def test_run_argv_batch_pipe_out_is_gone(workspace: object) -> None:
|
||||
"""The old provider-side flag is refused, pointing at the pipe node."""
|
||||
del workspace
|
||||
out = await call_async(
|
||||
run_argv_batch,
|
||||
[{"argv": _py("print(1)"), "pipe_out": True}],
|
||||
)
|
||||
assert "error:" in out
|
||||
assert "pipe_out" in out
|
||||
assert '{"pipe": [...]}' in out
|
||||
|
||||
|
||||
async def test_run_argv_batch_mix_stderr(workspace: object) -> None:
|
||||
@@ -149,9 +147,8 @@ async def test_run_argv_batch_mix_stderr(workspace: object) -> None:
|
||||
{
|
||||
"argv": _py("import sys; sys.stdout.write('out'); sys.stderr.write('err')"),
|
||||
"mix_stderr": True,
|
||||
"pipe_out": True,
|
||||
"pipe": {"argv": _py("import sys; print(repr(sys.stdin.read()))")},
|
||||
},
|
||||
{"argv": _py("import sys; print(repr(sys.stdin.read()))")},
|
||||
],
|
||||
)
|
||||
assert "ran=2" in out
|
||||
@@ -159,6 +156,149 @@ async def test_run_argv_batch_mix_stderr(workspace: object) -> None:
|
||||
assert "out" in out and "err" in out
|
||||
|
||||
|
||||
async def test_run_argv_batch_then_runs_on_success(workspace: object) -> None:
|
||||
del workspace
|
||||
out = await call_async(
|
||||
run_argv_batch,
|
||||
[{"argv": _py("print('first')"), "then": {"argv": _py("print('then-body')")}}],
|
||||
)
|
||||
assert "then-body" in out
|
||||
assert "ran=2" in out
|
||||
assert "depth=1" in out
|
||||
assert "via=then of step 0" in out
|
||||
|
||||
|
||||
async def test_run_argv_batch_else_handles_the_failure(workspace: object) -> None:
|
||||
"""A failure with an ``else`` is answered, so the batch keeps going."""
|
||||
del workspace
|
||||
out = await call_async(
|
||||
run_argv_batch,
|
||||
[
|
||||
{
|
||||
"argv": _py("import sys; sys.exit(2)"),
|
||||
"else": {"argv": _py("print('else-body')")},
|
||||
},
|
||||
{"argv": _py("print('sibling-ran')")},
|
||||
],
|
||||
)
|
||||
assert "else-body" in out
|
||||
assert "sibling-ran" in out
|
||||
assert "via=else of step 0" in out
|
||||
assert "stopped_early=false" in out
|
||||
|
||||
|
||||
async def test_run_argv_batch_runs_a_then_list_in_order(workspace: object) -> None:
|
||||
del workspace
|
||||
out = await call_async(
|
||||
run_argv_batch,
|
||||
[
|
||||
{
|
||||
"argv": _py("print('first')"),
|
||||
"then": [{"argv": _py("print('second')")}, {"argv": _py("print('third')")}],
|
||||
}
|
||||
],
|
||||
)
|
||||
assert "ran=3" in out
|
||||
assert out.index("first") < out.index("second") < out.index("third")
|
||||
|
||||
|
||||
async def test_run_argv_batch_a_failing_branch_stops_the_batch(workspace: object) -> None:
|
||||
"""Nobody answered a failure inside a continuation, so the batch stops."""
|
||||
del workspace
|
||||
out = await call_async(
|
||||
run_argv_batch,
|
||||
[
|
||||
{"argv": _py("print('first')"), "then": {"argv": _py("import sys; sys.exit(4)")}},
|
||||
{"argv": _py("print('sibling-ran')")},
|
||||
],
|
||||
)
|
||||
assert "stopped_early=true" in out
|
||||
assert "sibling-ran" not in out
|
||||
|
||||
|
||||
async def test_run_argv_batch_stop_on_error_false_skips_only_its_continuations(workspace: object) -> None:
|
||||
del workspace
|
||||
out = await call_async(
|
||||
run_argv_batch,
|
||||
[
|
||||
{
|
||||
"argv": _py("import sys; sys.exit(2)"),
|
||||
"stop_on_error": False,
|
||||
"then": {"argv": _py("print('then-ran')")},
|
||||
},
|
||||
{"argv": _py("print('sibling-ran')")},
|
||||
],
|
||||
)
|
||||
assert "then-ran" not in out
|
||||
assert "sibling-ran" in out
|
||||
assert "stopped_early=false" in out
|
||||
|
||||
|
||||
async def test_run_argv_batch_branches_on_the_pipe_result(workspace: object) -> None:
|
||||
"""A piped node branches on the last command of its chain."""
|
||||
del workspace
|
||||
out = await call_async(
|
||||
run_argv_batch,
|
||||
[
|
||||
{
|
||||
"argv": _py("print('payload', end='')"),
|
||||
"pipe": {"argv": _py("import sys; sys.stdin.read(); sys.exit(3)")},
|
||||
"then": {"argv": _py("print('pipe-ok')")},
|
||||
"else": {"argv": _py("print('pipe-failed')")},
|
||||
}
|
||||
],
|
||||
)
|
||||
assert "pipe-failed" in out
|
||||
assert "pipe-ok" not in out
|
||||
assert "ran=3" in out
|
||||
|
||||
|
||||
async def test_run_argv_batch_pipe_step_takes_no_branch(workspace: object) -> None:
|
||||
del workspace
|
||||
out = await call_async(
|
||||
run_argv_batch,
|
||||
[{"argv": _py("print(1)"), "pipe": {"argv": _py("print(2)"), "then": {"argv": _py("print(3)")}}}],
|
||||
)
|
||||
assert "error:" in out
|
||||
assert "pipe step takes no then/else" in out
|
||||
|
||||
|
||||
async def test_run_argv_batch_pipe_step_takes_no_stdin(workspace: object) -> None:
|
||||
del workspace
|
||||
out = await call_async(
|
||||
run_argv_batch,
|
||||
[{"argv": _py("print(1)"), "pipe": {"argv": _py("print(2)"), "stdin": "own"}}],
|
||||
)
|
||||
assert "error:" in out
|
||||
assert "stdin comes from the pipe" in out
|
||||
|
||||
|
||||
async def test_run_argv_batch_rejects_a_deep_tree(workspace: object) -> None:
|
||||
del workspace
|
||||
node: dict[str, object] = {"argv": _py("print(1)")}
|
||||
for _ in range(6):
|
||||
node = {"argv": _py("print(1)"), "then": node}
|
||||
out = await call_async(run_argv_batch, [node])
|
||||
assert "error:" in out
|
||||
assert "nest at most 4 levels deep" in out
|
||||
|
||||
|
||||
async def test_run_argv_batch_counts_nested_nodes(workspace: object) -> None:
|
||||
"""Every node of the tree costs one, so a nested tree hits the same cap."""
|
||||
del workspace
|
||||
out = await call_async(run_argv_batch, [{"argv": _py("print(1)")} for _ in range(21)])
|
||||
assert "error:" in out
|
||||
assert "more than 20 nodes" in out
|
||||
|
||||
|
||||
async def test_run_argv_batch_names_the_node_grammar(workspace: object) -> None:
|
||||
del workspace
|
||||
out = await call_async(run_argv_batch, [{"argv": _py("print(1)"), "when": "always"}])
|
||||
assert "error:" in out
|
||||
assert "unknown key(s) when" in out
|
||||
assert "then, else, pipe" in out
|
||||
|
||||
|
||||
async def test_run_argv_denied(workspace: object) -> None:
|
||||
del workspace
|
||||
out = await call_async(run_argv, ["rm", "-rf", "."])
|
||||
|
||||
Reference in New Issue
Block a user