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
9 changes: 5 additions & 4 deletions docs/CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 14 additions & 3 deletions moon_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -368,24 +370,33 @@ 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}")

is_default_proxies = args.proxies is None
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")
Expand Down
75 changes: 75 additions & 0 deletions tests/test_cli_exit_codes.py
Original file line number Diff line number Diff line change
@@ -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