Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -929,6 +929,8 @@ python -m automation_file --execute_str '[["FA_create_dir",{"dir_path":"x"}]]'
python -m automation_file --create_project ./my_project
```

The legacy flags exit with status 1 when an action of the list failed, after the rest of the list has run, and print `error: N action(s) failed` on stderr.

## JSON action format

Each entry is either a bare command name, a `[name, kwargs]` pair, or a
Expand Down
2 changes: 2 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -910,6 +910,8 @@ python -m automation_file --execute_str '[["FA_create_dir",{"dir_path":"x"}]]'
python -m automation_file --create_project ./my_project
```

旧式标志在清单中有动作失败时,会在其余动作执行完之后以状态 1 结束,并在 stderr 打印 `error: N action(s) failed`。

## JSON 动作格式

每一项动作可以是单纯的命令名称、`[name, kwargs]` 组合,或 `[name, args]`
Expand Down
2 changes: 2 additions & 0 deletions README.zh-TW.md
Original file line number Diff line number Diff line change
Expand Up @@ -910,6 +910,8 @@ python -m automation_file --execute_str '[["FA_create_dir",{"dir_path":"x"}]]'
python -m automation_file --create_project ./my_project
```

舊式旗標在清單中有動作失敗時,會在其餘動作執行完之後以狀態 1 結束,並在 stderr 印出 `error: N action(s) failed`。

## JSON 動作格式

每一項動作可以是單純的指令名稱、`[name, kwargs]` 組合,或 `[name, args]`
Expand Down
5 changes: 4 additions & 1 deletion architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,10 @@ ActionExecutor() → build_default_registry(): local + http + utils + drive comm
contract, guarded by `tests/test_legacy_cli_contract.py`. PyBreeze also declares `automation-file` as
a dependency.
- **TestPioneer** imports `download_file` and `unzip_all` from the facade in-process
(`test_pioneer/executor/file/file_processing.py`). Its `parallel_run` does not spawn this package.
(`test_pioneer/executor/file/file_processing.py`). Its `file-runner` runs an action file through
`execute_action` in-process, and `parallel_run` spawns `python -m automation_file --execute_file <script>`
and reads the exit status: 1 when an action failed (`ActionExecutor.failure_count`, compared by
`__main__.main`), guarded by `tests/test_legacy_cli_contract.py`.
- **Names inherited from AutoControl**: the TCP starter is still called `start_autocontrol_socket_server`
and the action-dict key is `auto_control`. MailThunder has renamed both (`start_mail_thunder_socket_server`, `mail_thunder` key) and keeps the old names as deprecated aliases. MailThunder's
socket-server default is 9942, so it runs next to this package's servers (TCP 9943, HTTP 9944,
Expand Down
8 changes: 7 additions & 1 deletion automation_file/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
from collections.abc import Callable
from typing import Any

from automation_file.core.action_executor import execute_action, execute_files
from automation_file.core.action_executor import execute_action, execute_files, executor
from automation_file.core.json_store import read_action_json
from automation_file.exceptions import ArgparseException
from automation_file.local.file_ops import create_file
Expand Down Expand Up @@ -235,6 +235,7 @@ def main(argv: list[str] | None = None) -> int:
return args.handler(args)

ran = False
failed_before = executor.failure_count
for key, handler in _LEGACY_DISPATCH.items():
value = getattr(args, key, None)
if value is None:
Expand All @@ -243,6 +244,11 @@ def main(argv: list[str] | None = None) -> int:
ran = True
if not ran:
raise ArgparseException("no argument supplied; try --help")
# A failed action does not stop the batch, so it is reported once the batch is done.
failed = executor.failure_count - failed_before
if failed:
print(f"error: {failed} action(s) failed", file=sys.stderr)
return 1
return 0


Expand Down
18 changes: 18 additions & 0 deletions automation_file/core/action_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

from __future__ import annotations

import threading
import time
from collections.abc import Mapping
from concurrent.futures import ThreadPoolExecutor
Expand Down Expand Up @@ -62,6 +63,8 @@ class ActionExecutor(_CoreActionExecutor):

def __init__(self, registry: ActionRegistry | None = None) -> None:
super().__init__(_SETTINGS, registry or build_default_registry())
self._failures = 0
self._failures_lock = threading.Lock()
self.registry.register_many(
{
"FA_execute_action": self.execute_action,
Expand All @@ -84,6 +87,18 @@ def _parse_action(action: list) -> ParsedAction:
return _PARSER.parse(action)

# Public API --------------------------------------------------------
@property
def failure_count(self) -> int:
"""How many actions have failed in this executor since it was created.

A failed action is recorded as ``repr(error)`` and the batch goes on, so
the return value of ``execute_action`` does not say whether anything
failed. A caller that must know (the CLI, for its exit status) compares
this count before and after. Dry runs are not counted.
"""
with self._failures_lock:
return self._failures

def validate(self, action_list: list | Mapping[str, Any]) -> list[str]:
"""Validate shape and resolve every name; return the list of action names.

Expand Down Expand Up @@ -179,6 +194,9 @@ def _run_one(self, action: list, dry_run: bool, display: list | None = None) ->
return repr(error)
finally:
record_action(name, time.monotonic() - started, ok)
if not ok:
with self._failures_lock:
self._failures += 1

def _run_dry(self, action: list, display: list | None = None) -> Any:
display_action = action if display is None else display
Expand Down
7 changes: 7 additions & 0 deletions docs/source/Eng/usage/cli.rst
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,10 @@ Subcommands for one-shot operations::
The ``mcp`` subcommand starts a Model Context Protocol server over stdio so
hosts such as Claude Desktop can call ``FA_*`` actions as MCP tools — see
:doc:`mcp` for the full integration guide.

Exit status of the legacy flags
-------------------------------

``-e``, ``-d`` and ``--execute_str`` exit with status 1 when an action of the list failed. A failed
action does not stop the list: the rest runs first, and ``error: N action(s) failed`` is printed on
stderr at the end. A script that cannot be read exits with 1 as before.
7 changes: 7 additions & 0 deletions docs/source/Zh-CN/usage/cli.rst
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,10 @@ CLI
``mcp`` 子命令通过 stdio 启动 Model Context Protocol 服务器,
让 Claude Desktop 这类宿主可以把 ``FA_*`` 动作当作 MCP 工具调用——完整集成
说明请见 :doc:`mcp`。

旧式标志的结束状态
------------------

``-e``、``-d`` 与 ``--execute_str`` 在清单中有动作失败时以状态 1 结束。失败的动作不会中止清单:
其余动作会先执行完,最后在 stderr 打印 ``error: N action(s) failed``。
无法读取的脚本和以前一样以 1 结束。
7 changes: 7 additions & 0 deletions docs/source/Zh-TW/usage/cli.rst
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,10 @@ CLI
``mcp`` 子指令以 stdio 啟動 Model Context Protocol 伺服器,
讓 Claude Desktop 之類的宿主可把 ``FA_*`` 動作當成 MCP 工具呼叫——
完整整合說明請見 :doc:`mcp`。

舊式旗標的結束狀態
------------------

``-e``、``-d`` 與 ``--execute_str`` 在清單中有動作失敗時以狀態 1 結束。失敗的動作不會中止清單:
其餘動作會先執行完,最後在 stderr 印出 ``error: N action(s) failed``。
無法讀取的腳本和以前一樣以 1 結束。
10 changes: 10 additions & 0 deletions docs/updates/2026-10.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,3 +192,13 @@ Index and query commands: [README.md](README.md). New entries go at the end.
- **Docs**: `CLAUDE.md` › Branching & CI and `architecture.md` §2 and §3 say how the jobs build and what to regenerate when the build requirement changes. The READMEs and the Sphinx pages do not describe the publish jobs, so they are unchanged.
- **Files**: `.github/requirements/publish.in`, `.github/requirements/publish.txt`, `.github/workflows/ci-dev.yml`, `.github/workflows/publish.yml`, `tests/test_workflow_actions.py`, `tests/test_dev_release.py`, `CLAUDE.md`, `architecture.md`, `progress.md`.
- **Open items**: none. `publish.yml` runs only on `main`, so its build step takes effect, and is first exercised, when `dev` is merged.

## U-20261008-01 · 2026-10-08 · The legacy CLI flags exit 1 when an action failed · #feature #cli #testpioneer

- **What**: `python -m automation_file` with `-e`, `-d` or `--execute_str` ended with status 0 whatever happened, because the executor records a failed action as `repr(error)` and runs the next one. `ActionExecutor` now counts the actions that failed (`failure_count`, thread-safe, dry runs excluded), `__main__.main` compares the count before and after the legacy dispatch, and the process exits 1 with `error: N action(s) failed` on stderr. The rest of the list still runs first.
- **Why**: TestPioneer's `parallel_run` starts `--execute_file` and reads the exit status (its `docs/artifacts.rst`, "Native Support"), and a CI job does the same. LoadDensity made the same change in its PR #118.
- **Effect on callers**: a script with a failing action ends with status 1 where it ended with 0. PyBreeze only displays the status. `execute_action` and its return value are unchanged.
- **Also**: `architecture.md` §6 said TestPioneer's `parallel_run` does not spawn this package; it does, for its `file-runner`.
- **Tests**: `tests/test_legacy_cli_contract.py` +4 (real child processes), `tests/test_cli_main.py` +3.
- **Docs**: `README.md`, `README.zh-TW.md`, `README.zh-CN.md`, `docs/source/{Eng,Zh-TW,Zh-CN}/usage/cli.rst`, `architecture.md` §6.
- **Files**: `automation_file/core/action_executor.py`, `automation_file/__main__.py`, the tests and docs above.
3 changes: 2 additions & 1 deletion docs/updates/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here

| ID | Date | Title | Tags | Batch |
|---|---|---|---|---|
| U-20261008-01 | 2026-10-08 | The legacy CLI flags exit 1 when an action failed | #feature #cli #testpioneer | [2026-10](2026-10.md) |
| U-20261001-11 | 2026-10-01 | The publish jobs build with the locked setuptools | #done #ci #security #X-13 | [2026-10](2026-10.md) |
| U-20261001-10 | 2026-10-01 | The publish jobs install hash-locked build tools | #done #ci #security #deps | [2026-10](2026-10.md) |
| U-20261001-09 | 2026-10-01 | The source distributions stop carrying the tests | #done #packaging #tests | [2026-10](2026-10.md) |
Expand Down Expand Up @@ -95,5 +96,5 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here

| File | Period | Entries |
|---|---|---:|
| [2026-10.md](2026-10.md) | 2026-10 | 11 |
| [2026-10.md](2026-10.md) | 2026-10 | 12 |
| [2026-09.md](2026-09.md) | 2026-09 | 21 |
35 changes: 35 additions & 0 deletions tests/test_cli_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,38 @@ def test_execute_str_accepts_double_encoded_payload(

assert rc == 0
assert received == [actions]


def test_legacy_flag_returns_1_when_an_action_failed(
tmp_path: Any, capsys: pytest.CaptureFixture[str]
) -> None:
actions = tmp_path / "actions.json"
actions.write_text(json.dumps([["FA_no_such_action"], ["FA_no_such_action"]]), encoding="utf-8")

assert cli_main.main(["--execute_file", str(actions)]) == 1
assert "error: 2 action(s) failed" in capsys.readouterr().err


def test_legacy_flag_returns_0_when_every_action_passed(tmp_path: Any) -> None:
target = tmp_path / "created.txt"
actions = tmp_path / "actions.json"
actions.write_text(
json.dumps([["FA_create_file", {"file_path": str(target), "content": "x"}]]),
encoding="utf-8",
)

assert cli_main.main(["--execute_file", str(actions)]) == 0
assert target.exists()


def test_failures_of_an_earlier_run_are_not_counted_again(tmp_path: Any) -> None:
bad = tmp_path / "bad.json"
bad.write_text(json.dumps([["FA_no_such_action"]]), encoding="utf-8")
good = tmp_path / "good.json"
good.write_text(
json.dumps([["FA_create_file", {"file_path": str(tmp_path / "x.txt"), "content": "x"}]]),
encoding="utf-8",
)

assert cli_main.main(["--execute_file", str(bad)]) == 1
assert cli_main.main(["--execute_file", str(good)]) == 0
33 changes: 33 additions & 0 deletions tests/test_legacy_cli_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,39 @@ def test_create_project(tmp_path, flag):
assert any(project.rglob("*.json")), result.stdout + result.stderr


def _failing_actions(target: Path) -> list:
"""An action that fails (no command has that name), then one that writes *target*."""
return [["FA_no_such_action"], *_actions(target)]


@pytest.mark.parametrize("flag", ["-e", "--execute_file"])
def test_a_failed_action_exits_1_after_the_rest_ran(tmp_path, flag):
target = tmp_path / "created.txt"
action_file = tmp_path / "actions.json"
action_file.write_text(json.dumps(_failing_actions(target)), encoding="utf-8")
result = _run_cli(tmp_path, flag, str(action_file))
assert result.returncode == 1
assert "error: 1 action(s) failed" in result.stderr
assert target.exists(), result.stdout + result.stderr


def test_a_failed_action_in_a_directory_exits_1(tmp_path):
action_dir = tmp_path / "actions"
action_dir.mkdir()
(action_dir / "a_ok.json").write_text(
json.dumps(_actions(tmp_path / "a.txt")), encoding="utf-8"
)
(action_dir / "b_bad.json").write_text(
json.dumps(_failing_actions(tmp_path / "b.txt")), encoding="utf-8"
)
assert _run_cli(tmp_path, "--execute_dir", str(action_dir)).returncode == 1


def test_a_failed_action_in_execute_str_exits_1(tmp_path):
payload = _pybreeze_execute_str(_failing_actions(tmp_path / "created.txt"))
assert _run_cli(tmp_path, "--execute_str", payload).returncode == 1


def test_no_flag_exits_non_zero(tmp_path):
assert _run_cli(tmp_path).returncode != 0

Expand Down
Loading