Files
MeshTerm/meshterm/persistence/logging.py
T
Jean-Pierre MartineauandClaude Opus 5 71b16ec7b7 The annotations, imports, and dead locals answer to ruff
`ruff check --fix` over the whole tree, ahead of putting the linter in CI:
1977 mechanical fixes, overwhelmingly `Optional[X]` → `X | None` (1186 in the
package alone) and the quoted annotations that `from __future__ import
annotations` already made unnecessary, plus import sorting and unused imports.
Nothing here changes behaviour — the suite is 1885 green before and after.

Four findings were real rather than stylistic and are fixed by hand: a dead
`cells` range in `MapCanvas`, a dead slot dict in the heat-ladder test, a
`ProgressScreen` task handle nobody read, and a tab loop binding a `label` it
never used. The two `F821`s were quoted forward references to names imported
inside their function bodies — now declared under `TYPE_CHECKING`, which keeps
`test_performance`'s local import (it guards the startup import graph) while
letting the linter resolve the name.

412 findings remain and are deliberately untouched: they need a decision about
the rule, not an edit to the code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 17:26:12 -04:00

133 lines
4.5 KiB
Python

"""Structured logging setup.
Two sinks are configured: a Rich-formatted console handler for human-readable output,
and a JSON-lines file handler that captures every record for later replay/analysis.
"""
from __future__ import annotations
import json
import logging
from pathlib import Path
from rich.console import Console
from rich.logging import RichHandler
_LOGGER_NAME = "meshterm"
class JsonlFormatter(logging.Formatter):
"""Format log records as one JSON object per line."""
def format(self, record: logging.LogRecord) -> str:
"""Serialize a log record to a compact JSON string.
Args:
record: The record to format.
Returns:
A single-line JSON document.
"""
payload = {
"ts": self.formatTime(record, "%Y-%m-%dT%H:%M:%S"),
"level": record.levelname,
"logger": record.name,
"msg": record.getMessage(),
}
if record.exc_info:
payload["exc"] = self.formatException(record.exc_info)
return json.dumps(payload)
def configure_logging(
console: Console,
log_dir: Path,
*,
level: int = logging.INFO,
quiet: bool = False,
) -> logging.Logger:
"""Configure and return the application logger.
Args:
console: The Rich console the handler should render to.
log_dir: Directory for the ``meshterm.log.jsonl`` event file.
level: Console log level.
quiet: When ``True``, suppress console output (file logging continues).
Returns:
The configured ``meshterm`` logger.
"""
logger = logging.getLogger(_LOGGER_NAME)
logger.setLevel(logging.DEBUG)
logger.handlers.clear()
logger.propagate = False
if not quiet:
rich_handler = RichHandler(
console=console, rich_tracebacks=True, show_path=False, markup=True
)
rich_handler.setLevel(level)
logger.addHandler(rich_handler)
log_dir.mkdir(parents=True, exist_ok=True)
file_handler = logging.FileHandler(log_dir / "meshterm.log.jsonl", encoding="utf-8")
file_handler.setLevel(logging.DEBUG)
file_handler.setFormatter(JsonlFormatter())
logger.addHandler(file_handler)
_quiet_library_console(file_handler)
return logger
#: Third-party loggers that must never write to the terminal — their records would print
#: straight over the full-screen TUI. Routed to the file handler instead so they stay
#: captured for debugging. ``meshcore`` is the notable offender: it calls
#: ``logging.basicConfig(level=INFO)`` on import (e.g. "INFO:meshcore:Serial Connection
#: started"). ``asyncio`` is included so its default exception handler — the one that
#: reports stray background-task errors once prompt_toolkit's screen-dumping handler is
#: disabled — logs to the file instead of the console.
_LIBRARY_LOGGERS: tuple[str, ...] = ("meshcore", "asyncio")
def _quiet_library_console(file_handler: logging.Handler) -> None:
"""Keep noisy third-party libraries off the terminal (they corrupt the TUI).
The ``meshcore`` client calls ``logging.basicConfig(level=INFO)`` when it is imported,
which installs a stream handler on the *root* logger that prints its INFO lines straight
over the screen. Two guards prevent that:
1. Give the root logger a :class:`~logging.NullHandler` so ``basicConfig`` — which only
acts when the root has no handlers — becomes a no-op and never adds its stream handler.
2. Take each known library logger off propagation and attach only ``file_handler``, so
its records are still captured to the JSON-lines log but never reach the console, even
if some other code path installs a root stream handler anyway.
Args:
file_handler: The application's file handler to also capture library records to.
"""
root = logging.getLogger()
if not any(isinstance(h, logging.NullHandler) for h in root.handlers):
root.addHandler(logging.NullHandler())
for name in _LIBRARY_LOGGERS:
lib = logging.getLogger(name)
lib.handlers.clear()
lib.setLevel(logging.INFO)
lib.propagate = False
lib.addHandler(file_handler)
def get_logger(name: str | None = None) -> logging.Logger:
"""Return a child of the application logger.
Args:
name: Optional child name; when ``None`` the root app logger is returned.
Returns:
The requested logger.
"""
if name is None:
return logging.getLogger(_LOGGER_NAME)
return logging.getLogger(f"{_LOGGER_NAME}.{name}")