diff --git a/src/dotbot/cli.py b/src/dotbot/cli.py index f395260..6beb5ad 100644 --- a/src/dotbot/cli.py +++ b/src/dotbot/cli.py @@ -131,7 +131,8 @@ def main() -> None: if success: log.info("All tasks executed successfully") else: - msg = "Some tasks were not executed successfully" + failed_str = ", ".join(dispatcher.get_failed_actions()) + msg = f"Some tasks were not executed successfully: {failed_str}" raise DispatchError(msg) # noqa: TRY301 except (ReadingError, DispatchError) as e: log.error(str(e)) # noqa: TRY400 diff --git a/src/dotbot/dispatcher.py b/src/dotbot/dispatcher.py index e7a33c8..c07bf59 100644 --- a/src/dotbot/dispatcher.py +++ b/src/dotbot/dispatcher.py @@ -49,6 +49,7 @@ def _setup_context( def dispatch(self, tasks: List[Dict[str, Any]]) -> bool: success = True + self._failed_actions: List[str] = [] for task in tasks: for action in task: if ( @@ -77,6 +78,7 @@ def dispatch(self, tasks: List[Dict[str, Any]]) -> bool: success = False if not success: self._log.error("Some plugins could not be loaded") + self._failed_actions.append(action) if self._exit: self._log.error("Action plugins failed") return False @@ -90,26 +92,34 @@ def dispatch(self, tasks: List[Dict[str, Any]]) -> bool: continue try: local_success = plugin.handle(action, task[action]) - if not local_success and self._exit: - # The action has failed, exit - self._log.error(f"Action {action} failed") - return False + if not local_success: + self._failed_actions.append(action) + if self._exit: + # The action has failed, exit + self._log.error(f"Action {action} failed") + return False success &= local_success handled = True except Exception as err: # noqa: BLE001 self._log.error(f"An error was encountered while executing action {action}") self._log.debug(str(err)) + self._failed_actions.append(action) if self._exit: # There was an exception, exit return False if not handled: success = False + self._failed_actions.append(action) self._log.error(f"Action {action} not handled") if self._exit: # Invalid action exit return False return success + def get_failed_actions(self) -> List[str]: + """Return the list of action names that failed during the last dispatch.""" + return self._failed_actions + class DispatchError(Exception): pass diff --git a/tests/dotbot_plugin_raises.py b/tests/dotbot_plugin_raises.py new file mode 100644 index 0000000..32a535e --- /dev/null +++ b/tests/dotbot_plugin_raises.py @@ -0,0 +1,16 @@ +from typing import Any + +import dotbot + + +class Raises(dotbot.Plugin): + def can_handle(self, directive: str) -> bool: + return directive == "plugin_raises" + + def handle(self, directive: str, data: Any) -> bool: + _ = data + if directive != "plugin_raises": + msg = f"Raises cannot handle directive {directive}" + raise ValueError(msg) + msg = "test exception" + raise RuntimeError(msg) diff --git a/tests/test_cli.py b/tests/test_cli.py index f4c92d8..867e4d0 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -25,8 +25,9 @@ def test_except_create( run_dotbot("--except", "create") assert not os.path.exists(os.path.join(home, "a")) - stdout = capfd.readouterr().out.splitlines() - assert any(line.startswith("success") for line in stdout) + stdout = capfd.readouterr().out + assert any(line.startswith("success") for line in stdout.splitlines()) + assert "Some tasks were not executed successfully" not in stdout def test_except_shell( @@ -73,7 +74,9 @@ def test_except_multiples( assert not any(line.startswith("failure") for line in stdout) -def test_exit_on_failure(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None: +def test_exit_on_failure( + capfd: pytest.CaptureFixture[str], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None] +) -> None: """Verify that processing can halt immediately on failures.""" dotfiles.write_config( @@ -89,6 +92,44 @@ def test_exit_on_failure(home: str, dotfiles: Dotfiles, run_dotbot: Callable[... assert os.path.isdir(os.path.join(home, "a")) assert not os.path.isdir(os.path.join(home, "b")) + stdout = capfd.readouterr().out + assert "Some tasks were not executed successfully: shell" in stdout + + +def test_exit_on_failure_reports_multiple( + capfd: pytest.CaptureFixture[str], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None] +) -> None: + """Verify that multiple failed action names are reported.""" + + _ = home + dotfiles.write_config( + [ + {"shell": ["this_is_not_a_command"]}, + {"link": {"~/.f": "nonexistent_source"}}, + ] + ) + with pytest.raises(SystemExit): + run_dotbot() + + stdout = capfd.readouterr().out + assert "Some tasks were not executed successfully: shell, link" in stdout + + +def test_exit_on_failure_reports_exception( + capfd: pytest.CaptureFixture[str], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None] +) -> None: + """Verify that an action whose plugin raises an exception is reported as failed.""" + + _ = home + plugin_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), "dotbot_plugin_raises.py") + shutil.copy(plugin_file, os.path.join(dotfiles.directory, "raises.py")) + dotfiles.write_config([{"plugin_raises": "~"}]) + with pytest.raises(SystemExit): + run_dotbot("--plugin", os.path.join(dotfiles.directory, "raises.py")) + + stdout = capfd.readouterr().out + assert "Some tasks were not executed successfully: plugin_raises" in stdout + def test_only( capfd: pytest.CaptureFixture[str], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]