diff --git a/.gitignore b/.gitignore index 3ac6eb3..a5abef1 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,26 @@ # image *.tar images/capy/dist/ + +# data +data/ + +# macOS +.DS_Store +.AppleDouble +.LSOverride +._* +**/.___* +**/._* +.Spotlight-V100 +.Trashes +.fseventsd +.TemporaryItems +.AppleDB +.AppleDesktop +.apdisk +Icon? +.com.apple.timemachine.donotpresent +.VolumeIcon.icns +.cursor + diff --git a/cli/localci/cli/main.py b/cli/localci/cli/main.py index 3f59d82..1ff7c27 100644 --- a/cli/localci/cli/main.py +++ b/cli/localci/cli/main.py @@ -8,7 +8,7 @@ from __future__ import annotations import logging -import sys +from typing import Any import click @@ -19,15 +19,77 @@ ConfigFileNotFoundError, ConfigIOError, ConfigValidationError, + LocalCIError, ) -from localci.utils.output import configure_console, console, print_error +from localci.utils.crash import crash_log_display_path, log_crash +from localci.utils.output import configure_console, print_error + +# --------------------------------------------------------------------------- +# Catch-all exception handling +# --------------------------------------------------------------------------- + + +class CatchAllGroup(click.Group): + """Click group that catches unhandled exceptions at the CLI entry point.""" + + def invoke(self, ctx: click.Context) -> Any: + try: + return super().invoke(ctx) + except (click.exceptions.Exit, click.ClickException): + raise + except LocalCIError as exc: + if _is_debug(ctx): + raise + print_error(str(exc)) + ctx.exit(1) + except Exception as exc: + if _is_debug(ctx): + raise + log_path = log_crash(exc) + _print_unhandled_error(exc, log_path is not None) + ctx.exit(2) + + +def _is_debug(ctx: click.Context) -> bool: + """Return True when ``--debug`` was passed on the CLI.""" + obj = ctx.obj + return bool(obj is not None and obj.get("debug")) + + +def _print_unhandled_error(exc: BaseException, crash_log_written: bool) -> None: + """Print a user-friendly message for an unhandled internal error.""" + if crash_log_written: + attach_hint = ( + f"Please file a bug report and attach {crash_log_display_path()} " + "(contains the full traceback)." + ) + else: + attach_hint = ( + "Please file a bug report; the full traceback was printed to stderr " + "(crash log could not be written)." + ) + messages = [ + "An unexpected internal error occurred.", + f"{type(exc).__name__}: {exc}", + attach_hint, + ] + try: + for msg in messages: + print_error(msg) + except Exception: + for msg in messages: + click.echo(msg, err=True) + # --------------------------------------------------------------------------- # Root CLI group # --------------------------------------------------------------------------- -@click.group(context_settings={"help_option_names": ["-h", "--help"]}) +@click.group( + cls=CatchAllGroup, + context_settings={"help_option_names": ["-h", "--help"]}, +) @click.option( "--config", "-c", @@ -36,9 +98,14 @@ default=None, help="Path to config file (.localci.yml).", ) -@click.option("--verbose", "-v", is_flag=True, help="Enable verbose/debug output.") +@click.option("--verbose", "-v", is_flag=True, help="Enable verbose logging.") @click.option("--quiet", "-q", is_flag=True, help="Suppress non-essential output.") @click.option("--no-color", is_flag=True, help="Disable coloured output.") +@click.option( + "--debug", + is_flag=True, + help="Show full tracebacks instead of friendly errors.", +) @click.version_option(version=__version__, prog_name="localci") @click.pass_context def cli( @@ -47,9 +114,11 @@ def cli( verbose: bool, quiet: bool, no_color: bool, + debug: bool, ) -> None: """Local CI - Run GitHub Actions workflows locally.""" ctx.ensure_object(dict) + ctx.obj["debug"] = debug # Configure Rich console early so all downstream output respects flags. configure_console(no_color=no_color, quiet=quiet) diff --git a/cli/localci/utils/crash.py b/cli/localci/utils/crash.py new file mode 100644 index 0000000..f6bc6c0 --- /dev/null +++ b/cli/localci/utils/crash.py @@ -0,0 +1,62 @@ +"""Crash log utilities for unhandled CLI exceptions.""" + +from __future__ import annotations + +import platform +import sys +import traceback +from datetime import datetime, timezone +from pathlib import Path + +from localci import __version__ +from localci.utils.paths import localci_home + +CRASH_LOG_NAME = "crash.log" + + +def crash_log_path() -> Path: + """Return the path to the crash log file (``~/.localci/crash.log``).""" + return localci_home() / CRASH_LOG_NAME + + +def crash_log_display_path() -> str: + """Return a short user-facing path for the crash log (e.g. ``~/.localci/crash.log``).""" + path = crash_log_path() + # Canonical layout: always show ~/.localci/crash.log (avoids long paths / terminal wrap). + if path.name == CRASH_LOG_NAME and path.parent.name == ".localci": + return f"~/.localci/{CRASH_LOG_NAME}" + try: + rel = path.relative_to(Path.home()) + return "~/" + rel.as_posix() + except ValueError: + return str(path) + + +def _build_crash_report(exc: BaseException) -> str: + return "".join( + [ + 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__), + ] + ) + + +def log_crash(exc: BaseException) -> Path | None: + """Write a full crash report for *exc* and return the log file path. + + On write failure, prints the report to stderr and returns ``None``. + """ + path = crash_log_path() + report = _build_crash_report(exc) + try: + path.write_text(report, encoding="utf-8") + return path + except OSError: + sys.stderr.write(f"Could not write crash log to {path}:\n") + sys.stderr.write(report) + return None diff --git a/cli/tests/test_cli.py b/cli/tests/test_cli.py index 032e3c5..455af05 100644 --- a/cli/tests/test_cli.py +++ b/cli/tests/test_cli.py @@ -8,10 +8,14 @@ import json from pathlib import Path +from unittest.mock import MagicMock, patch +import pytest from click.testing import CliRunner from localci.cli.main import cli +from localci.errors import YqNotFoundError +from localci.utils.crash import CRASH_LOG_NAME runner = CliRunner() @@ -87,7 +91,9 @@ def test_basic_invocation(self): assert result.exit_code == 0 def test_platform_filter(self): - result = runner.invoke(cli, ["list", "--workflow", SAMPLE_CI, "--platform", "linux"]) + result = runner.invoke( + cli, ["list", "--workflow", SAMPLE_CI, "--platform", "linux"] + ) assert result.exit_code == 0 def test_no_workflow_errors(self): @@ -135,9 +141,7 @@ def test_help(self): assert "--dry-run" in result.output def test_dry_run(self): - result = runner.invoke( - cli, ["run", "--workflow", SAMPLE_WORKFLOW, "--dry-run"] - ) + result = runner.invoke(cli, ["run", "--workflow", SAMPLE_WORKFLOW, "--dry-run"]) assert result.exit_code == 0 assert "Dry run" in result.output or "dry" in result.output.lower() @@ -292,9 +296,170 @@ def test_config_set_and_get(self, tmp_path, monkeypatch): # Init a config first. runner.invoke(cli, ["config", "init"]) # Set a value. - result = runner.invoke(cli, ["-c", str(tmp_path / ".localci.yml"), "config", "set", "parallel.max_jobs", "16"]) + result = runner.invoke( + cli, + [ + "-c", + str(tmp_path / ".localci.yml"), + "config", + "set", + "parallel.max_jobs", + "16", + ], + ) assert result.exit_code == 0 # Verify. - result = runner.invoke(cli, ["-c", str(tmp_path / ".localci.yml"), "config", "get", "parallel.max_jobs"]) + result = runner.invoke( + cli, + [ + "-c", + str(tmp_path / ".localci.yml"), + "config", + "get", + "parallel.max_jobs", + ], + ) assert result.exit_code == 0 assert "16" in result.output + + +# --------------------------------------------------------------------------- +# Catch-all exception handler +# --------------------------------------------------------------------------- + + +@pytest.fixture +def isolated_localci_home(monkeypatch, tmp_path): + """Redirect ``localci_home()`` to a temp dir (Unix and Windows).""" + home = tmp_path / ".localci" + home.mkdir(parents=True, exist_ok=True) + + def _home(): + return home + + monkeypatch.setattr("localci.utils.paths.localci_home", _home) + monkeypatch.setattr("localci.utils.crash.localci_home", _home) + return home + + +class TestCatchAllHandler: + """Unhandled exceptions produce friendly output and crash.log diagnostics.""" + + @staticmethod + def _workflow_file(tmp_path: Path) -> Path: + wf = tmp_path / "ci.yml" + wf.write_text("name: CI\n") + return wf + + @patch("localci.cli.analyze.WorkflowAnalyzer.analyze") + def test_unhandled_exception_friendly_output( + self, mock_analyze, tmp_path, isolated_localci_home + ): + mock_analyze.side_effect = RuntimeError("test boom") + wf = self._workflow_file(tmp_path) + result = runner.invoke(cli, ["analyze", str(wf)]) + assert result.exit_code == 2 + assert "Traceback" not in result.output + assert "unexpected internal error" in result.output.lower() + assert "RuntimeError" in result.output + assert "test boom" in result.output + assert "bug report" in result.output.lower() + assert f"~/.localci/{CRASH_LOG_NAME}" in result.output.replace("\n", " ") + + @patch("localci.cli.analyze.WorkflowAnalyzer.analyze") + def test_unhandled_exception_writes_crash_log( + self, mock_analyze, tmp_path, isolated_localci_home + ): + mock_analyze.side_effect = RuntimeError("test boom") + wf = self._workflow_file(tmp_path) + runner.invoke(cli, ["analyze", str(wf)]) + log_path = isolated_localci_home / CRASH_LOG_NAME + assert log_path.is_file() + content = log_path.read_text(encoding="utf-8") + assert "RuntimeError" in content + assert "test boom" in content + assert "traceback:" in content.lower() + assert "localci_version:" in content + assert "python_version:" in content + assert "platform:" in content + assert content.split("python_version:", 1)[1].split("\n", 1)[0].strip() + assert content.split("platform:", 1)[1].split("\n", 1)[0].strip() + + @patch("localci.cli.analyze.WorkflowAnalyzer.analyze") + def test_debug_flag_reraises_exception( + self, mock_analyze, tmp_path, isolated_localci_home + ): + mock_analyze.side_effect = RuntimeError("test boom") + wf = self._workflow_file(tmp_path) + with pytest.raises(RuntimeError, match="test boom"): + runner.invoke( + cli, + ["--debug", "analyze", str(wf)], + catch_exceptions=False, + ) + + @patch("localci.cli.analyze.WorkflowAnalyzer.analyze") + def test_leaked_localci_error_exit_one_no_crash_log( + self, mock_analyze, tmp_path, isolated_localci_home + ): + mock_analyze.side_effect = YqNotFoundError() + wf = self._workflow_file(tmp_path) + result = runner.invoke(cli, ["analyze", str(wf)]) + assert result.exit_code == 1 + assert "unexpected internal error" not in result.output.lower() + assert "mikefarah/yq" in result.output.lower() + assert not (isolated_localci_home / CRASH_LOG_NAME).exists() + + @patch("localci.cli.run.JobExecutor.check_act") + def test_run_unhandled_exception_uses_catch_all( + self, mock_check_act, isolated_localci_home, tmp_path + ): + mock_check_act.side_effect = RuntimeError("act subprocess failed") + if not Path(SAMPLE_WORKFLOW).exists(): + pytest.skip("sample_workflow.yml not found") + logs_dir = tmp_path / "logs" + cache_dir = tmp_path / "cache" + images_registry = tmp_path / "images" + cfg_file = tmp_path / ".localci.yml" + cfg_file.write_text( + "logging:\n" + f" directory: {logs_dir.as_posix()}\n" + "cache:\n" + f" directory: {cache_dir.as_posix()}\n" + " boost:\n" + " enabled: false\n" + "images:\n" + f" registry: {images_registry.as_posix()}\n" + ) + result = runner.invoke( + cli, + ["-c", str(cfg_file), "run", "--workflow", SAMPLE_WORKFLOW], + ) + assert result.exit_code == 2 + assert "unexpected internal error" in result.output.lower() + assert "act subprocess failed" in result.output + mock_check_act.assert_called_once() + assert (isolated_localci_home / CRASH_LOG_NAME).is_file() + + def test_config_error_exit_code_unchanged(self, tmp_path): + result = runner.invoke( + cli, + ["--config", str(tmp_path / "missing.yml"), "list"], + ) + assert result.exit_code == 1 + + @patch("localci.utils.crash.crash_log_path") + @patch("localci.cli.analyze.WorkflowAnalyzer.analyze") + def test_log_crash_write_failure_still_friendly( + self, mock_analyze, mock_crash_log_path, tmp_path, isolated_localci_home + ): + mock_analyze.side_effect = RuntimeError("test boom") + mock_path = MagicMock() + mock_path.write_text.side_effect = OSError("disk full") + mock_crash_log_path.return_value = mock_path + wf = self._workflow_file(tmp_path) + result = runner.invoke(cli, ["analyze", str(wf)]) + assert result.exit_code == 2 + assert "unexpected internal error" in result.output.lower() + assert "stderr" in result.output.lower() + assert not (isolated_localci_home / CRASH_LOG_NAME).exists()