diff --git a/docs/CLI.md b/docs/CLI.md index 59ff4ac..0f93f12 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -114,11 +114,12 @@ causes the shared Chrome instance to start; it is not one browser per worker. ## Exit codes -| Code | Current behavior | +| Code | Meaning | |:--:|:--| -| `0` | The CLI completes normally, including a run in which individual URLs fail, or is interrupted with `Ctrl+C`. Inspect the final `ok` and `fail` counts and `failed_links.txt` when it is written. | -| `1` | The URL file is missing or has no usable URLs, or an unhandled exception reaches `main()`. In the latter case the CLI writes `crash_log.txt` beside `moon_cli.py`. | -| `2` | `argparse` rejects command-line usage, such as missing required arguments or a non-integer value for an integer argument. | +| `0` | **Success** — Every file in the batch completed successfully. | +| `1` | **Partial failure / Interrupted** — The run completed but at least one file failed (dead link, exhausted retries, etc.), OR the run aborted completely (e.g. `ENOSPC` disk full) before the batch finished, OR the run was interrupted via `Ctrl+C`, OR an unhandled runtime exception occurred (`crash_log.txt` written). | +| `2` | **Pre-flight error** — The run could not start due to invalid usage (e.g. `argparse` missing arguments), the `--urls` file not being found, or the file containing no usable URLs. | +| `3` | **Total failure** — The run completed but every single URL attempted failed. | After a normal run, the CLI writes `moontech_cli_*.log` and `moontech_cli_*.json` beside `moon_cli.py`, not inside `--output`. If any URL exhausts its attempts, it diff --git a/moon_cli.py b/moon_cli.py index 3edfa16..4feb56d 100644 --- a/moon_cli.py +++ b/moon_cli.py @@ -342,6 +342,8 @@ async def _task(pu=proxy_url, co=cookies, fn=filename, ou=url, r=rec): except OSError as e: print(f"[warn] Failed links save error: {e}") + return ok_count, fail_count, disk_full is not None + # ── ENTRY POINT ──────────────────────────────────────────────────────────────── def main(): ap = argparse.ArgumentParser( @@ -368,13 +370,13 @@ def main(): n_extractors = 8 if not os.path.exists(args.urls): - print(f"ERROR: urls file not found: {args.urls}"); sys.exit(1) + print(f"ERROR: urls file not found: {args.urls}"); sys.exit(2) with open(args.urls, encoding="utf-8", errors="replace") as f: urls = [l.strip() for l in f if l.strip() and not l.startswith("#")] if not urls: - print("ERROR: no URLs found in file"); sys.exit(1) + print("ERROR: no URLs found in file"); sys.exit(2) print(f"Loaded {len(urls)} URLs from {args.urls}") @@ -382,10 +384,19 @@ def main(): proxy_path = args.proxies if args.proxies is not None else "proxies.txt" try: - asyncio.run(run(urls, args.output, n_extractors, args.streams, + ok, fail, aborted = asyncio.run(run(urls, args.output, n_extractors, args.streams, args.retries, proxy_path, is_default_proxies)) + if aborted: + sys.exit(1) + elif ok == 0 and fail > 0: + sys.exit(3) + elif fail > 0: + sys.exit(1) + else: + sys.exit(0) except KeyboardInterrupt: print("\nInterrupted.") + sys.exit(1) except Exception: # Catch unexpected top-level CLI exceptions to log crash traceback and exit cleanly crash = os.path.join(os.path.dirname(os.path.abspath(__file__)), "crash_log.txt") diff --git a/tests/test_cli_exit_codes.py b/tests/test_cli_exit_codes.py new file mode 100644 index 0000000..3e0b9de --- /dev/null +++ b/tests/test_cli_exit_codes.py @@ -0,0 +1,75 @@ +"""Tests for moon_cli.py exit codes (Issue #32). + +Ensures the CLI correctly maps outcomes and errors to structured exit codes. +""" +import sys +import pytest +from unittest.mock import patch + +import moon_cli + +def test_exit_code_2_argparse_error(): + # argparse raises SystemExit(2) natively when args are missing/invalid + with patch("sys.argv", ["moon_cli.py"]): + with pytest.raises(SystemExit) as exc: + moon_cli.main() + assert exc.value.code == 2 + +def test_exit_code_2_missing_urls_file(): + with patch("sys.argv", ["moon_cli.py", "--urls", "does_not_exist.txt", "--output", "./out"]): + with pytest.raises(SystemExit) as exc: + moon_cli.main() + assert exc.value.code == 2 + +def test_exit_code_2_empty_urls_file(tmp_path): + f = tmp_path / "empty.txt" + f.write_text(" \n# commented line\n\n", encoding="utf-8") + with patch("sys.argv", ["moon_cli.py", "--urls", str(f), "--output", "./out"]): + with pytest.raises(SystemExit) as exc: + moon_cli.main() + assert exc.value.code == 2 + +def test_exit_code_0_success(tmp_path): + f = tmp_path / "links.txt" + f.write_text("http://example.com/file.zip\n", encoding="utf-8") + with patch("sys.argv", ["moon_cli.py", "--urls", str(f), "--output", "./out"]): + with patch("moon_cli.run", return_value=(1, 0, False)): # (ok, fail, aborted) + with pytest.raises(SystemExit) as exc: + moon_cli.main() + assert exc.value.code == 0 + +def test_exit_code_1_partial_failure(tmp_path): + f = tmp_path / "links.txt" + f.write_text("http://example.com/1.zip\nhttp://example.com/2.zip\n", encoding="utf-8") + with patch("sys.argv", ["moon_cli.py", "--urls", str(f), "--output", "./out"]): + with patch("moon_cli.run", return_value=(1, 1, False)): # (ok, fail, aborted) + with pytest.raises(SystemExit) as exc: + moon_cli.main() + assert exc.value.code == 1 + +def test_exit_code_3_total_failure(tmp_path): + f = tmp_path / "links.txt" + f.write_text("http://example.com/file.zip\n", encoding="utf-8") + with patch("sys.argv", ["moon_cli.py", "--urls", str(f), "--output", "./out"]): + with patch("moon_cli.run", return_value=(0, 1, False)): # (ok, fail, aborted) + with pytest.raises(SystemExit) as exc: + moon_cli.main() + assert exc.value.code == 3 + +def test_exit_code_1_keyboard_interrupt(tmp_path): + f = tmp_path / "links.txt" + f.write_text("http://example.com/file.zip\n", encoding="utf-8") + with patch("sys.argv", ["moon_cli.py", "--urls", str(f), "--output", "./out"]): + with patch("moon_cli.run", side_effect=KeyboardInterrupt()): + with pytest.raises(SystemExit) as exc: + moon_cli.main() + assert exc.value.code == 1 + +def test_exit_code_1_aborted(tmp_path): + f = tmp_path / "links.txt" + f.write_text("http://example.com/file.zip\n", encoding="utf-8") + with patch("sys.argv", ["moon_cli.py", "--urls", str(f), "--output", "./out"]): + with patch("moon_cli.run", return_value=(0, 1, True)): # aborted (e.g. ENOSPC) + with pytest.raises(SystemExit) as exc: + moon_cli.main() + assert exc.value.code == 1