Skip to content
Open
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
3 changes: 2 additions & 1 deletion src/dotbot/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 14 additions & 4 deletions src/dotbot/dispatcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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
Expand All @@ -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
16 changes: 16 additions & 0 deletions tests/dotbot_plugin_raises.py
Original file line number Diff line number Diff line change
@@ -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)
47 changes: 44 additions & 3 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand All @@ -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]
Expand Down
Loading