Skip to content

Add catch-all CLI exception handler#41

Merged
wpak-ai merged 5 commits into
cppalliance:developfrom
henry0816191:cli-catch-all-handler
May 28, 2026
Merged

Add catch-all CLI exception handler#41
wpak-ai merged 5 commits into
cppalliance:developfrom
henry0816191:cli-catch-all-handler

Conversation

@henry0816191
Copy link
Copy Markdown
Collaborator

@henry0816191 henry0816191 commented May 27, 2026

Summary

Adds a top-level catch-all exception handler to the localci CLI so unhandled
errors no longer surface as raw Python tracebacks. Unhandled exceptions now
produce a user-friendly message, write a full diagnostic report to
~/.localci/crash.log, and exit with code 2 (distinct from 1 used for
user/config errors). A --debug flag re-raises for developer debugging.

Resolves w4_issue_01.md (Eval Test 10).

Changes

  • New cli/localci/utils/crash.pylog_crash(exc) writes timestamp,
    localci version, Python version, OS platform, and full traceback to
    ~/.localci/crash.log.
  • cli/localci/cli/main.py
    • CatchAllGroup(click.Group) wraps invoke() with except Exception,
      re-raising click.exceptions.Exit / click.ClickException so existing
      exit flows are preserved.
    • Friendly stderr message: brief description, exception type and message,
      and a pointer to the crash log for bug reports.
    • New global --debug flag re-raises unhandled exceptions instead of
      catching them.
    • Config errors continue to exit 1; catch-all uses 2.
  • cli/tests/test_cli.py — new TestCatchAllHandler covering:
    friendly output (no traceback), crash log contents, --debug re-raise,
    and config-error exit-code regression.

Behaviour

Scenario Before After
Unhandled RuntimeError (e.g. act/Docker bug) Raw Python traceback to stderr Friendly message + ~/.localci/crash.log, exit 2
localci --debug ... with unhandled exception (same) Re-raises traceback for developers
Config errors (ConfigError family) Friendly message, exit 1 Unchanged
Subcommand domain errors (WorkflowError, etc.) Friendly message, exit 1 Unchanged

Test plan

  • pytest tests/test_cli.py::TestCatchAllHandler -v — 4/4 passing
  • pytest (full suite) — 492 passed
  • CI pytest job on Python 3.10 / 3.11 / 3.12 green

Acceptance criteria

  • Top-level catch-all except Exception at CLI entry point
  • Unhandled exceptions produce a user-friendly error (no raw traceback)
  • Message includes (1) brief description, (2) exception type + message,
    (3) bug-report suggestion with log location
  • Full traceback written to ~/.localci/crash.log
  • Exit code 2 for internal errors; 1 retained for user/config errors
  • Tests cover friendly output + traceback logged to file
  • Tests pass locally; CI to confirm
  • PR approved by at least 1 reviewer

Notes for reviewers

  • The handler intentionally excludes click.exceptions.Exit and
    click.ClickException so normal CLI exits and Click usage errors keep
    their current behaviour.
  • KeyboardInterrupt and SystemExit are not subclasses of Exception, so
    Ctrl-C and explicit sys.exit() calls still propagate.
  • crash.log is overwritten per crash (single “latest crash” file) — matches
    the issue’s example wording.
  • No changes to cli/localci/errors.py; the existing exception hierarchy is
    unaffected.

Related issue

close #38

Summary by CodeRabbit

  • New Features

    • Added a --debug flag and automatic crash-log creation for unexpected CLI errors.
  • Improvements

    • Friendlier error output (no raw tracebacks) while debug mode preserves original exceptions; crash logs include environment and traceback details and fall back gracefully.
  • Chores

    • Updated ignore rules to exclude the data directory and macOS metadata files.
  • Tests

    • Added tests covering exception handling, crash-log behavior, and debug-mode paths.

Review Change Stack

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 27, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0f1c8f93-22ab-40fa-af0f-b2f88676debd

📥 Commits

Reviewing files that changed from the base of the PR and between 09b78e7 and 9c42e53.

📒 Files selected for processing (2)
  • cli/localci/utils/crash.py
  • cli/tests/test_cli.py
🚧 Files skipped from review as they are similar to previous changes (2)
  • cli/localci/utils/crash.py
  • cli/tests/test_cli.py

📝 Walkthrough

Walkthrough

Adds a CatchAllGroup Click group that intercepts unhandled CLI exceptions, logs full tracebacks and environment metadata to a crash log under localci_home, prints a friendly error, supports --debug to re-raise, and includes crash-log utilities, tests, and .gitignore updates.

Changes

CLI Catch-All Exception Handler

Layer / File(s) Summary
Repository ignore updates
.gitignore
Adds ignore patterns for data/ and macOS system metadata files/directories (.DS_Store, resource forks, Spotlight/Trash, Time Machine icons, cursor entries).
Crash logging utilities
cli/localci/utils/crash.py
New CRASH_LOG_NAME, crash_log_path(), crash_log_display_path(), _build_crash_report() and log_crash() to assemble and write crash reports with UTC timestamp, localci_version, Python/platform info, and traceback; log_crash() falls back to stderr and returns None on write failure.
CLI exception handler implementation
cli/localci/cli/main.py
Adds CatchAllGroup(click.Group) overriding invoke() to catch unexpected exceptions, consult ctx.obj['debug'] to re-raise in debug mode, call log_crash() for internal errors, print a friendly error, and exit with status codes (1 for LocalCIError user errors, 2 for internal errors). Wires a root --debug flag and updates decorator/imports.
Exception handler tests
cli/tests/test_cli.py
Adds isolated_localci_home fixture, imports for mocking, and TestCatchAllHandler tests that validate friendly output (no traceback), crash-log creation and contents including environment metadata, --debug re-raise behavior, leaked LocalCI errors exiting 1 without crash logs, run command crash handling, missing-config exit code for list, and behavior when crash-log writing fails.
sequenceDiagram
  participant User
  participant CLI as "CLI Entry (CatchAllGroup)"
  participant Invoke as "CatchAllGroup.invoke"
  participant Command as "WorkflowAnalyzer / JobExecutor"
  participant Crash as "log_crash"
  participant File as "~/.localci/crash.log"
  participant Stderr as "stderr"

  User->>CLI: run localci [--debug]
  CLI->>Invoke: invoke(ctx)
  Invoke->>Command: execute command
  alt Exception occurs
    Command-->>Invoke: raises Exception
    Invoke->>Invoke: catch Exception
    alt debug flag set
      Invoke-->>User: re-raise exception
    else debug not set
      Invoke->>Crash: log_crash(exc)
      Crash-->>File: write timestamp, versions, traceback
      Invoke->>Stderr: print friendly error and guidance
      Invoke->>CLI: ctx.exit(2)
    end
  else Success
    Invoke-->>User: normal output
  end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 I dug a log where tracebacks sleep,
Tucked errors in for safe-keeping deep,
A friendly note instead of a scream,
--debug peeks behind the seam,
Hop happy — crash reports in a neat little heap.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 29.17% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Add catch-all CLI exception handler' accurately summarizes the main objective of the PR, which introduces a top-level exception handler at the CLI entry point.
Linked Issues check ✅ Passed The PR implementation fulfills all acceptance criteria from issue #38: catch-all handler added via CatchAllGroup, user-friendly error messages with exception type/message and crash log reference, full traceback logged to ~/.localci/crash.log with metadata, exit code 2 for internal errors, --debug flag for re-raising, and comprehensive tests covering friendly output and crash log contents.
Out of Scope Changes check ✅ Passed All changes are directly related to implementing the catch-all exception handler: .gitignore updates for new directories/files, crash logging utilities, CLI group handler implementation, and comprehensive tests—no unrelated changes detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (2)
cli/localci/cli/main.py (1)

48-53: 💤 Low value

Simplify _is_debug logic.

The check for ctx.params.get("debug") on line 53 is redundant. The --debug flag is stored in ctx.obj["debug"] on line 110, so checking ctx.params is unnecessary and could cause confusion if the two sources ever diverge.

♻️ Simplified version
 def _is_debug(ctx: click.Context) -> bool:
     """Return True when ``--debug`` was passed on the CLI."""
-    obj = ctx.obj
-    if obj is not None and obj.get("debug"):
-        return True
-    return bool(ctx.params.get("debug"))
+    return bool(ctx.obj and ctx.obj.get("debug"))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cli/localci/cli/main.py` around lines 48 - 53, The _is_debug function mixes
two sources for the debug flag causing redundancy; update the function (named
_is_debug) to only consult ctx.obj for the flag: ensure you read ctx.obj into a
local variable (obj), return True when obj is not None and obj.get("debug")
truthy, otherwise return False — remove the ctx.params.get("debug") check so the
function consistently relies on ctx.obj["debug"] as the single source of truth.
cli/localci/utils/crash.py (1)

22-35: ⚡ Quick win

Consider adding error handling for file write failures.

If writing the crash log fails (e.g., disk full, permission denied), the exception will bubble up and could mask the original exception that was being logged. Consider wrapping the file write in a try/except block and falling back to stderr if the write fails.

🛡️ Proposed error handling
 def log_crash(exc: BaseException) -> Path:
     """Write a full crash report for *exc* and return the log file path."""
     path = crash_log_path()
     lines = [
         f"timestamp: {datetime.now(timezone.utc).isoformat()}\n",
         f"localci_version: {__version__}\n",
         f"python_version: {sys.version}\n",
         f"platform: {platform.platform()}\n",
         "\n",
         "traceback:\n",
         *traceback.format_exception(type(exc), exc, exc.__traceback__),
     ]
-    path.write_text("".join(lines), encoding="utf-8")
+    try:
+        path.write_text("".join(lines), encoding="utf-8")
+    except Exception as write_error:
+        # Fallback to stderr if crash log write fails
+        import sys
+        print(f"Warning: Could not write crash log to {path}: {write_error}", file=sys.stderr)
     return path
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cli/localci/utils/crash.py` around lines 22 - 35, The log_crash function
currently writes the crash report via path.write_text without guarding against
IO failures; wrap the write in a try/except Exception as e around the
path.write_text call in log_crash, and on failure write the same report to
stderr (using sys.stderr.write or print) including both the original exception
information and the write error (e) so the original crash isn't masked; still
return the Path (or document behavior) after attempting the fallback. Use
existing symbols: log_crash, crash_log_path, traceback.format_exception, and
sys.stderr to implement the fallback.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@cli/localci/cli/main.py`:
- Around line 56-70: The multi-line message built in _print_unhandled_error()
lets Rich wrap the long log path mid-word; change the implementation to emit the
crash log line as a single string (rather than two concatenated f-strings across
lines) or call print_error()/click.echo() with no_wrap behaviour so the path
isn't wrapped; specifically, update the messages construction around f"Please
file a bug report and attach {display_path} (contains the full traceback)." into
one contiguous string and/or pass a no-wrap option to print_error (or fall back
to click.echo(display_string, err=True)) when printing from
_print_unhandled_error so the crash log filename remains intact.

In `@cli/localci/utils/crash.py`:
- Around line 25-34: The metadata lines in the `lines` list are missing newline
terminators so joining with `"".join(lines)` concatenates fields; update the
construction of `lines` (the list used before `path.write_text`) to ensure each
metadata string ends with a newline (or join with `"\n"` and append a final
newline) so the timestamp, localci_version, python_version and platform entries
are on separate lines while leaving `traceback.format_exception(type(exc), exc,
exc.__traceback__)` unchanged.

In `@cli/tests/test_cli.py`:
- Around line 320-335: The failing test test_unhandled_exception_friendly_output
should not rely on an unwrapped literal "crash.log"; update the assertion to be
wrapping-resilient by either asserting the CRASH_LOG_NAME constant appears (or
the full crash path pattern) or by normalizing result.output (e.g., remove
whitespace/newlines) before checking; change the final assertion in
test_unhandled_exception_friendly_output to use CRASH_LOG_NAME or a
whitespace-stripped search so Rich's line-wrapping (which can split "crash.log"
into "crash.lo\ng") doesn't break the test.

---

Nitpick comments:
In `@cli/localci/cli/main.py`:
- Around line 48-53: The _is_debug function mixes two sources for the debug flag
causing redundancy; update the function (named _is_debug) to only consult
ctx.obj for the flag: ensure you read ctx.obj into a local variable (obj),
return True when obj is not None and obj.get("debug") truthy, otherwise return
False — remove the ctx.params.get("debug") check so the function consistently
relies on ctx.obj["debug"] as the single source of truth.

In `@cli/localci/utils/crash.py`:
- Around line 22-35: The log_crash function currently writes the crash report
via path.write_text without guarding against IO failures; wrap the write in a
try/except Exception as e around the path.write_text call in log_crash, and on
failure write the same report to stderr (using sys.stderr.write or print)
including both the original exception information and the write error (e) so the
original crash isn't masked; still return the Path (or document behavior) after
attempting the fallback. Use existing symbols: log_crash, crash_log_path,
traceback.format_exception, and sys.stderr to implement the fallback.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3dfbe96e-b64f-4b65-869c-6bf08e49456b

📥 Commits

Reviewing files that changed from the base of the PR and between a9d22d6 and fb5165a.

📒 Files selected for processing (4)
  • .gitignore
  • cli/localci/cli/main.py
  • cli/localci/utils/crash.py
  • cli/tests/test_cli.py

Comment thread cli/localci/cli/main.py Outdated
Comment thread cli/localci/utils/crash.py Outdated
Comment thread cli/tests/test_cli.py Outdated
@henry0816191 henry0816191 requested a review from bradjin8 May 27, 2026 20:15
Comment thread cli/tests/test_cli.py
Comment thread cli/tests/test_cli.py Outdated
Comment thread cli/localci/utils/crash.py Outdated
Comment thread cli/localci/cli/main.py
Comment thread cli/localci/cli/main.py
Comment thread cli/localci/cli/main.py
Comment thread cli/tests/test_cli.py
Comment thread cli/localci/cli/main.py Outdated
Comment thread cli/localci/cli/main.py
Comment thread cli/tests/test_cli.py Outdated
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@cli/tests/test_cli.py`:
- Around line 367-371: The test currently replaces newlines with spaces
(output_no_newlines = result.output.replace("\n", " ")) which still leaves
spaces when Rich wraps lines and breaks filenames; change the normalization to
strip all whitespace from result.output (e.g., use a whitespace-collapsing
approach such as joining split tokens or a regex to remove \s) so that
comparisons against CRASH_LOG_NAME succeed; update the variable currently named
output_no_newlines (or create a new normalized_output) and use that when
asserting presence of CRASH_LOG_NAME or f".localci/{CRASH_LOG_NAME}" in the
test.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 809ea87e-3ab9-4d34-a827-167212f37478

📥 Commits

Reviewing files that changed from the base of the PR and between d6154e0 and 09b78e7.

📒 Files selected for processing (3)
  • cli/localci/cli/main.py
  • cli/localci/utils/crash.py
  • cli/tests/test_cli.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • cli/localci/cli/main.py

Comment thread cli/tests/test_cli.py Outdated
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 28, 2026

Actionable comments posted: 0

@henry0816191 henry0816191 requested a review from bradjin8 May 28, 2026 16:02
@henry0816191 henry0816191 requested a review from wpak-ai May 28, 2026 18:02
@wpak-ai wpak-ai merged commit 4d7311a into cppalliance:develop May 28, 2026
4 checks passed
@henry0816191 henry0816191 deleted the cli-catch-all-handler branch May 28, 2026 18:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add Catch-All CLI Exception Handler

3 participants