GRM-13: feat: replace print() with stdlib logging module
- Create logging_config.py with get_logger() providing dual handlers: - Console handler (stderr) controlled by GRM_LOG_LEVEL env var (default INFO) - File handler (~/.local/state/grm/logs/grm.log) capturing everything at DEBUG - Replace all print() calls in executor.py and report.py with logger.info()/error() - Add error logging before raising AnsibleError in executor.run() - Add GRM_LOG_LEVEL to README configuration table and logging documentation - Update all unit tests to mock logger instead of using capsys - 114 tests, 100% coverage, pyright clean, ruff clean
This commit is contained in:
@@ -10,6 +10,7 @@ from pathlib import Path
|
||||
|
||||
from .exceptions import AnsibleError
|
||||
from .i18n import _
|
||||
from .logging_config import get_logger
|
||||
|
||||
|
||||
class AnsibleExecutor:
|
||||
@@ -17,31 +18,32 @@ class AnsibleExecutor:
|
||||
|
||||
def __init__(self, log_dir: Path | None = None) -> None:
|
||||
self._log_dir = log_dir or Path.home() / ".local" / "state" / "grm" / "logs"
|
||||
self._logger = get_logger()
|
||||
|
||||
def run(self, cmd: list[str], description: str = "") -> None:
|
||||
"""Run an Ansible command, redirecting output to a log file."""
|
||||
log_file = self._prepare_log(cmd)
|
||||
desc = description or _("Running Ansible playbook")
|
||||
|
||||
print(f"[GRM] {desc}")
|
||||
print(f"[GRM] {_('Full log: {log_file}', log_file=log_file)}")
|
||||
self._logger.info(desc)
|
||||
self._logger.info(_("Full log: {log_file}", log_file=log_file))
|
||||
|
||||
returncode = self._stream(cmd, log_file)
|
||||
|
||||
if returncode != 0:
|
||||
raise AnsibleError(
|
||||
_(
|
||||
"Ansible failed with exit code {code}. See full log: {log_file}",
|
||||
code=returncode,
|
||||
log_file=log_file,
|
||||
)
|
||||
msg = _(
|
||||
"Ansible failed with exit code {code}. See full log: {log_file}",
|
||||
code=returncode,
|
||||
log_file=log_file,
|
||||
)
|
||||
self._logger.error(msg)
|
||||
raise AnsibleError(msg)
|
||||
|
||||
status = self._extract_status(log_file)
|
||||
if status:
|
||||
print(f"[GRM] {status}")
|
||||
self._logger.info(status)
|
||||
|
||||
print(f"[GRM] {_('Done. See full log: {log_file}', log_file=log_file)}")
|
||||
self._logger.info(_("Done. See full log: {log_file}", log_file=log_file))
|
||||
|
||||
def _prepare_log(self, cmd: list[str]) -> Path:
|
||||
"""Create a log file with header."""
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""GRM logging configuration using the standard library."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def get_logger(name: str = "grm") -> logging.Logger:
|
||||
"""Return a configured logger for GRM.
|
||||
|
||||
Console output respects ``GRM_LOG_LEVEL`` (default: INFO).
|
||||
All messages are also written to ``~/.local/state/grm/logs/grm.log``.
|
||||
"""
|
||||
logger = logging.getLogger(name)
|
||||
if logger.handlers:
|
||||
return logger
|
||||
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
# Console handler — user-controlled level
|
||||
console = logging.StreamHandler()
|
||||
console.setLevel(_level_from_env(os.getenv("GRM_LOG_LEVEL", "INFO")))
|
||||
console.setFormatter(logging.Formatter("[GRM] %(message)s"))
|
||||
logger.addHandler(console)
|
||||
|
||||
# File handler — captures everything including DEBUG
|
||||
log_dir = Path.home() / ".local" / "state" / "grm" / "logs"
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
file_handler = logging.FileHandler(log_dir / "grm.log")
|
||||
file_handler.setLevel(logging.DEBUG)
|
||||
file_handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s"))
|
||||
logger.addHandler(file_handler)
|
||||
|
||||
return logger
|
||||
|
||||
|
||||
def _level_from_env(value: str | None) -> int:
|
||||
"""Map a level name to a logging level constant."""
|
||||
if not value:
|
||||
return logging.INFO
|
||||
try:
|
||||
return getattr(logging, value.upper())
|
||||
except AttributeError:
|
||||
return logging.INFO
|
||||
@@ -6,6 +6,7 @@ from collections.abc import Generator
|
||||
from contextlib import contextmanager
|
||||
|
||||
from .i18n import _
|
||||
from .logging_config import get_logger
|
||||
|
||||
|
||||
class Step:
|
||||
@@ -56,14 +57,15 @@ def track_steps() -> Generator[StepTracker, None, None]:
|
||||
|
||||
def _print_report(steps: list[Step]) -> None:
|
||||
"""Print a translated operation report to stdout."""
|
||||
logger = get_logger()
|
||||
icons = {
|
||||
"completed": "✓",
|
||||
"failed": "✗",
|
||||
"pending": "○",
|
||||
"in_progress": "◌",
|
||||
}
|
||||
print(f"[GRM] {_('=== Operation Report ===')}")
|
||||
logger.info(_("=== Operation Report ==="))
|
||||
for step in steps:
|
||||
icon = icons.get(step.status, "?")
|
||||
status_label = _(step.status)
|
||||
print(f"[GRM] {icon} {step.name} ({status_label})")
|
||||
logger.info(f" {icon} {step.name} ({status_label})")
|
||||
|
||||
Reference in New Issue
Block a user