GRM-15: fix: eliminate duplicate console output, restore GRM_LOG_LEVEL filtering

- Remove console StreamHandler from get_logger() — say() already handles
  console output via click.echo(); having both caused every message to
  appear twice
- Move GRM_LOG_LEVEL filtering into ui.say() via _console_level() so
  console verbosity is still user-controllable while the log file always
  captures everything at DEBUG
- Remove [GRM] prefix from say() calls — no longer needed without
  duplicate logger output, giving cleaner user-facing messages
- Update test_logging_config.py: remove console handler tests and
  _level_from_env tests (now in test_ui.py), expect 1 handler only
- Add test_ui.py coverage for _console_level and say() level filtering
- Update README to document single-path console output via click.echo
- 116 tests, 100% coverage, pyright clean, ruff clean
This commit is contained in:
Emil Simeonov
2026-06-19 01:54:05 +02:00
parent 47d5df0aed
commit 6ede871054
7 changed files with 64 additions and 71 deletions
+5 -5
View File
@@ -27,8 +27,8 @@ class AnsibleExecutor:
log_file = self._prepare_log(cmd)
desc = description or _("Running Ansible playbook")
say(f"[GRM] {desc}", color="cyan")
say(f"[GRM] {_('Full log: {log_file}', log_file=log_file)}")
say(desc, color="cyan")
say(_("Full log: {log_file}", log_file=log_file))
returncode = self._stream(cmd, log_file)
@@ -38,14 +38,14 @@ class AnsibleExecutor:
code=returncode,
log_file=log_file,
)
say(f"[GRM] {msg}", level=logging.ERROR, err=True, color="red")
say(msg, level=logging.ERROR, err=True, color="red")
raise AnsibleError(msg)
status = self._extract_status(log_file)
if status:
say(f"[GRM] {status}", color="yellow")
say(status, color="yellow")
say(f"[GRM] {_('Done. See full log: {log_file}', log_file=log_file)}", color="green")
say(_("Done. See full log: {log_file}", log_file=log_file), color="green")
def _prepare_log(self, cmd: list[str]) -> Path:
"""Create a log file with header."""
+2 -19
View File
@@ -3,15 +3,14 @@
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``.
All messages are written to ``~/.local/state/grm/logs/grm.log``.
Console output is handled by ``ui.say()`` via ``click.echo``.
"""
logger = logging.getLogger(name)
if logger.handlers:
@@ -19,12 +18,6 @@ def get_logger(name: str = "grm") -> logging.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)
@@ -34,13 +27,3 @@ def get_logger(name: str = "grm") -> logging.Logger:
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
+2 -2
View File
@@ -63,7 +63,7 @@ def _print_report(steps: list[Step]) -> None:
"pending": "",
"in_progress": "",
}
say(f"[GRM] {_('=== Operation Report ===')}", color="bright_cyan")
say(_("=== Operation Report ==="), color="bright_cyan")
for step in steps:
icon = icons.get(step.status, "?")
status_label = _(step.status)
@@ -73,4 +73,4 @@ def _print_report(steps: list[Step]) -> None:
"in_progress": "yellow",
"pending": "white",
}.get(step.status)
say(f"[GRM] {icon} {step.name} ({status_label})", color=color)
say(f" {icon} {step.name} ({status_label})", color=color)
+16 -4
View File
@@ -7,21 +7,33 @@ The persistent log file always receives plain text (no ANSI codes).
from __future__ import annotations
import logging
import os
import click
def _console_level() -> int:
"""Return the minimum level for console output from ``GRM_LOG_LEVEL``."""
value = os.getenv("GRM_LOG_LEVEL", "INFO")
try:
return getattr(logging, value.upper())
except AttributeError:
return logging.INFO
def say(msg: str, level: int = logging.INFO, err: bool = False, color: str | None = None) -> None:
"""Output a message to the user and also log it for auditing.
Console output goes via ``click.echo`` (handles encoding, CliRunner,
Windows colorama). The same message is also sent to the ``grm`` logger
so it appears in the persistent log file.
Windows colorama) only when *level* is at least ``GRM_LOG_LEVEL``.
The same message is always sent to the ``grm`` logger so it appears
in the persistent log file regardless of console verbosity.
The optional *color* parameter is passed to ``click.style`` so the
console line is tinted (e.g. ``color="green"``). The log file always
stores the raw plain text.
"""
styled = click.style(msg, fg=color) if color else msg
click.echo(styled, err=err)
if level >= _console_level():
styled = click.style(msg, fg=color) if color else msg
click.echo(styled, err=err)
logging.getLogger("grm").log(level, msg)