GRM-14: feat: use click.echo() for user-facing messages with dual logging

- Create ui.py with say() helper that routes messages to both click.echo()
  (console/stdout, user-facing) and logging.getLogger('grm') (file audit trail)
- Update executor.py: replace logger.info() with say() for start, status, done
  messages; use say(level=ERROR, err=True) before raising AnsibleError
- Update report.py: replace logger.info() with say() for operation report lines
- Update all unit tests to patch say() instead of using capsys or get_logger
- Add test_ui.py with coverage for say() calling both click.echo and logging
- 117 tests, 100% coverage, pyright clean, ruff clean
This commit is contained in:
Emil Simeonov
2026-06-19 01:30:14 +02:00
parent 3b3e002f7f
commit 3f7507500b
6 changed files with 93 additions and 46 deletions
+15 -19
View File
@@ -1,5 +1,6 @@
"""Unit tests for AnsibleExecutor."""
import logging
import os
from pathlib import Path
from unittest.mock import MagicMock, mock_open, patch
@@ -21,38 +22,36 @@ def _mock_popen_process(returncode: int = 0) -> MagicMock:
class TestAnsibleExecutorRun:
def test_run_success(self, tmp_path: Path) -> None:
mock_logger = MagicMock()
with patch("gitea_runner_manager.executor.get_logger", return_value=mock_logger):
with patch("gitea_runner_manager.executor.say") as mock_say:
executor = AnsibleExecutor(log_dir=tmp_path)
with patch("subprocess.Popen") as mock_popen, patch("builtins.open", mock_open()):
mock_popen.return_value = _mock_popen_process(returncode=0)
executor.run(["echo", "hello"], description="Test run")
messages = [call.args[0] for call in mock_logger.info.call_args_list]
messages = [call.args[0] for call in mock_say.call_args_list]
assert any("Test run" in msg for msg in messages)
assert any("Done" in msg for msg in messages)
def test_run_failure(self, tmp_path: Path) -> None:
mock_logger = MagicMock()
with patch("gitea_runner_manager.executor.get_logger", return_value=mock_logger):
with patch("gitea_runner_manager.executor.say") as mock_say:
executor = AnsibleExecutor(log_dir=tmp_path)
with patch("subprocess.Popen") as mock_popen, patch("builtins.open", mock_open()):
mock_popen.return_value = _mock_popen_process(returncode=1)
with pytest.raises(AnsibleError, match="exit code 1"):
executor.run(["false"], description="Test run")
assert mock_logger.error.called
assert "exit code 1" in mock_logger.error.call_args[0][0]
error_calls = [call for call in mock_say.call_args_list if call.kwargs.get("level") == logging.ERROR]
assert len(error_calls) == 1
assert "exit code 1" in error_calls[0].args[0]
def test_run_default_description(self, tmp_path: Path) -> None:
mock_logger = MagicMock()
with patch("gitea_runner_manager.executor.get_logger", return_value=mock_logger):
with patch("gitea_runner_manager.executor.say") as mock_say:
executor = AnsibleExecutor(log_dir=tmp_path)
with patch("subprocess.Popen") as mock_popen, patch("builtins.open", mock_open()):
mock_popen.return_value = _mock_popen_process(returncode=0)
executor.run(["echo", "hello"])
messages = [call.args[0] for call in mock_logger.info.call_args_list]
messages = [call.args[0] for call in mock_say.call_args_list]
assert any("Running Ansible playbook" in msg for msg in messages)
def test_run_with_stdout_lines(self, tmp_path: Path) -> None:
@@ -70,20 +69,19 @@ class TestAnsibleExecutorRun:
handle.write.assert_any_call("line2\n")
def test_run_extracts_status(self, tmp_path: Path) -> None:
mock_logger = MagicMock()
log_content = (
"TASK [gitea-runner : Report runner status]\n"
"ok: [127.0.0.1] => {\n"
' "msg": "Runner \'127.0.0.1\' is installed and running."\n'
"}\n"
)
with patch("gitea_runner_manager.executor.get_logger", return_value=mock_logger):
with patch("gitea_runner_manager.executor.say") as mock_say:
executor = AnsibleExecutor(log_dir=tmp_path)
with patch("subprocess.Popen") as mock_popen, patch("builtins.open", mock_open(read_data=log_content)):
mock_popen.return_value = _mock_popen_process(returncode=0)
executor.run(["echo", "hello"], description="Test run")
messages = [call.args[0] for call in mock_logger.info.call_args_list]
messages = [call.args[0] for call in mock_say.call_args_list]
assert any("Runner '127.0.0.1' is installed and running" in msg for msg in messages)
@@ -141,32 +139,30 @@ class TestAnsibleExecutorPrepareLog:
class TestAnsibleExecutorTranslation:
def test_run_translated(self, tmp_path: Path) -> None:
mock_logger = MagicMock()
with (
patch.dict(os.environ, {"GRM_LANG": "de"}),
patch("gitea_runner_manager.executor.get_logger", return_value=mock_logger),
patch("gitea_runner_manager.executor.say") as mock_say,
):
executor = AnsibleExecutor(log_dir=tmp_path)
with patch("subprocess.Popen") as mock_popen, patch("builtins.open", mock_open()):
mock_popen.return_value = _mock_popen_process(returncode=0)
executor.run(["echo", "hello"]) # use default description to test translation
messages = [call.args[0] for call in mock_logger.info.call_args_list]
messages = [call.args[0] for call in mock_say.call_args_list]
assert any("Ansible-Playbook wird ausgeführt" in msg for msg in messages)
assert any("Fertig" in msg for msg in messages)
def test_unsupported_lang_fallback(self, tmp_path: Path) -> None:
mock_logger = MagicMock()
with (
patch.dict(os.environ, {"GRM_LANG": "xx"}),
patch("gitea_runner_manager.executor.get_logger", return_value=mock_logger),
patch("gitea_runner_manager.executor.say") as mock_say,
):
executor = AnsibleExecutor(log_dir=tmp_path)
with patch("subprocess.Popen") as mock_popen, patch("builtins.open", mock_open()):
mock_popen.return_value = _mock_popen_process(returncode=0)
executor.run(["echo", "hello"])
messages = [call.args[0] for call in mock_logger.info.call_args_list]
messages = [call.args[0] for call in mock_say.call_args_list]
assert any("Running Ansible playbook" in msg for msg in messages) # English fallback