jmri_core.log

jmri_core.log — logging bootstrap for headless installs.

Every entry point (jmri-server's uvicorn app, WiThrottle server, scripts) calls configure() once at startup. Configuration is driven by the env vars in jmri_core.env:

  • JMRI_LOG_LEVEL — root threshold (default INFO).
  • JMRI_LOG_FILE — path to a rotating log file. If unset, logs go to the console only, matching pre-Tier-2 behaviour so unattended installs don't grow files without an operator opting in.
  • JMRI_LOG_MAX_MB — rotation size in MiB (default 5).
  • JMRI_LOG_BACKUPS — number of rotated files to keep (default 3).

The unified format is:

2026-08-04 15:03:07,412 INFO  [jmri_nce.connection] NCE ready

Timestamps use the local timezone (matches JMRI Java). Level column is 5-wide so INFO / WARN / ERROR align. Logger name goes in brackets so grep on [jmri_nce picks up every NCE line regardless of the sub-module.

configure() is idempotent — a repeat call replaces the handlers so tests can reconfigure without leaking handlers across the process.

  1"""``jmri_core.log`` — logging bootstrap for headless installs.
  2
  3Every entry point (jmri-server's uvicorn app, WiThrottle server,
  4scripts) calls :func:`configure` once at startup. Configuration is
  5driven by the env vars in :mod:`jmri_core.env`:
  6
  7* ``JMRI_LOG_LEVEL`` — root threshold (default ``INFO``).
  8* ``JMRI_LOG_FILE`` — path to a rotating log file. If unset, logs go
  9  to the console only, matching pre-Tier-2 behaviour so unattended
 10  installs don't grow files without an operator opting in.
 11* ``JMRI_LOG_MAX_MB`` — rotation size in MiB (default 5).
 12* ``JMRI_LOG_BACKUPS`` — number of rotated files to keep (default 3).
 13
 14The unified format is:
 15
 16    2026-08-04 15:03:07,412 INFO  [jmri_nce.connection] NCE ready
 17
 18Timestamps use the local timezone (matches JMRI Java). Level column is
 195-wide so ``INFO`` / ``WARN`` / ``ERROR`` align. Logger name goes in
 20brackets so grep on ``[jmri_nce`` picks up every NCE line regardless
 21of the sub-module.
 22
 23:func:`configure` is idempotent — a repeat call replaces the handlers
 24so tests can reconfigure without leaking handlers across the process.
 25"""
 26from __future__ import annotations
 27
 28import logging
 29import logging.handlers
 30import pathlib
 31import sys
 32
 33from jmri_core import env
 34
 35# Column widths chosen so INFO / WARN / DEBUG all align and the logger
 36# name doesn't wander. Longer logger names wrap inside the brackets.
 37_FORMAT = "%(asctime)s %(levelname)-5s [%(name)s] %(message)s"
 38_DATEFMT = "%Y-%m-%d %H:%M:%S"
 39
 40# Tracked handlers we've installed. Kept so a re-configure removes
 41# them cleanly instead of adding on top (which would double every log
 42# line and eventually thrash file descriptors in tests).
 43_installed: list[logging.Handler] = []
 44
 45
 46def configure(
 47    *,
 48    level: str | None = None,
 49    log_file: pathlib.Path | str | None = None,
 50    max_mb: int | None = None,
 51    backups: int | None = None,
 52    console: bool = True,
 53) -> pathlib.Path | None:
 54    """Install root-logger handlers.
 55
 56    Parameters mirror the :mod:`jmri_core.env` getters. Passing an
 57    explicit value overrides the env; passing ``None`` reads the env.
 58
 59    Returns the resolved log-file path (or ``None`` if no file handler
 60    was installed). Callers who want to advertise the log location on a
 61    status page can use the return value directly.
 62    """
 63    _clear()
 64
 65    resolved_level = (level or env.log_level()).upper()
 66    numeric = getattr(logging, resolved_level, None)
 67    if not isinstance(numeric, int):
 68        logging.getLogger(__name__).warning(
 69            "unknown log level %r; using INFO", resolved_level,
 70        )
 71        numeric = logging.INFO
 72    root = logging.getLogger()
 73    root.setLevel(numeric)
 74
 75    formatter = logging.Formatter(fmt=_FORMAT, datefmt=_DATEFMT)
 76
 77    if console:
 78        console_handler = logging.StreamHandler(stream=sys.stderr)
 79        console_handler.setFormatter(formatter)
 80        root.addHandler(console_handler)
 81        _installed.append(console_handler)
 82
 83    resolved_file: pathlib.Path | None = None
 84    if log_file is None:
 85        resolved_file = env.log_file()
 86    else:
 87        resolved_file = pathlib.Path(log_file).expanduser()
 88
 89    if resolved_file is not None:
 90        resolved_file.parent.mkdir(parents=True, exist_ok=True)
 91        rotate_bytes = (max_mb or env.log_max_mb()) * 1024 * 1024
 92        backup_count = backups if backups is not None else env.log_backups()
 93        file_handler = logging.handlers.RotatingFileHandler(
 94            resolved_file,
 95            maxBytes=rotate_bytes,
 96            backupCount=backup_count,
 97            encoding="utf-8",
 98        )
 99        file_handler.setFormatter(formatter)
100        root.addHandler(file_handler)
101        _installed.append(file_handler)
102
103    return resolved_file
104
105
106def _clear() -> None:
107    """Remove any handlers we've previously added. Used by
108    :func:`configure` for idempotency, and by tests via
109    :func:`reset`."""
110    root = logging.getLogger()
111    for handler in _installed:
112        root.removeHandler(handler)
113        try:
114            handler.close()
115        except OSError:
116            pass
117    _installed.clear()
118
119
120def reset() -> None:
121    """Public alias for the private cleanup — useful in test teardown
122    so each test gets a fresh handler set."""
123    _clear()
124
125
126def tail(path: pathlib.Path, n: int) -> list[str]:
127    """Return the last *n* non-blank log lines from *path*.
128
129    Reads the whole file — cheap for logs bounded by
130    ``JMRI_LOG_MAX_MB`` (default 5 MB). If the file doesn't exist,
131    returns an empty list rather than raising, so the /logs endpoint
132    can render a graceful empty state.
133    """
134    if not path.is_file():
135        return []
136    try:
137        text = path.read_text(encoding="utf-8", errors="replace")
138    except OSError:
139        return []
140    lines = [line for line in text.splitlines() if line.strip()]
141    return lines[-n:] if n > 0 else []
def configure( *, level: str | None = None, log_file: pathlib._local.Path | str | None = None, max_mb: int | None = None, backups: int | None = None, console: bool = True) -> pathlib._local.Path | None:
 47def configure(
 48    *,
 49    level: str | None = None,
 50    log_file: pathlib.Path | str | None = None,
 51    max_mb: int | None = None,
 52    backups: int | None = None,
 53    console: bool = True,
 54) -> pathlib.Path | None:
 55    """Install root-logger handlers.
 56
 57    Parameters mirror the :mod:`jmri_core.env` getters. Passing an
 58    explicit value overrides the env; passing ``None`` reads the env.
 59
 60    Returns the resolved log-file path (or ``None`` if no file handler
 61    was installed). Callers who want to advertise the log location on a
 62    status page can use the return value directly.
 63    """
 64    _clear()
 65
 66    resolved_level = (level or env.log_level()).upper()
 67    numeric = getattr(logging, resolved_level, None)
 68    if not isinstance(numeric, int):
 69        logging.getLogger(__name__).warning(
 70            "unknown log level %r; using INFO", resolved_level,
 71        )
 72        numeric = logging.INFO
 73    root = logging.getLogger()
 74    root.setLevel(numeric)
 75
 76    formatter = logging.Formatter(fmt=_FORMAT, datefmt=_DATEFMT)
 77
 78    if console:
 79        console_handler = logging.StreamHandler(stream=sys.stderr)
 80        console_handler.setFormatter(formatter)
 81        root.addHandler(console_handler)
 82        _installed.append(console_handler)
 83
 84    resolved_file: pathlib.Path | None = None
 85    if log_file is None:
 86        resolved_file = env.log_file()
 87    else:
 88        resolved_file = pathlib.Path(log_file).expanduser()
 89
 90    if resolved_file is not None:
 91        resolved_file.parent.mkdir(parents=True, exist_ok=True)
 92        rotate_bytes = (max_mb or env.log_max_mb()) * 1024 * 1024
 93        backup_count = backups if backups is not None else env.log_backups()
 94        file_handler = logging.handlers.RotatingFileHandler(
 95            resolved_file,
 96            maxBytes=rotate_bytes,
 97            backupCount=backup_count,
 98            encoding="utf-8",
 99        )
100        file_handler.setFormatter(formatter)
101        root.addHandler(file_handler)
102        _installed.append(file_handler)
103
104    return resolved_file

Install root-logger handlers.

Parameters mirror the jmri_core.env getters. Passing an explicit value overrides the env; passing None reads the env.

Returns the resolved log-file path (or None if no file handler was installed). Callers who want to advertise the log location on a status page can use the return value directly.

def reset() -> None:
121def reset() -> None:
122    """Public alias for the private cleanup — useful in test teardown
123    so each test gets a fresh handler set."""
124    _clear()

Public alias for the private cleanup — useful in test teardown so each test gets a fresh handler set.

def tail(path: pathlib._local.Path, n: int) -> list[str]:
127def tail(path: pathlib.Path, n: int) -> list[str]:
128    """Return the last *n* non-blank log lines from *path*.
129
130    Reads the whole file — cheap for logs bounded by
131    ``JMRI_LOG_MAX_MB`` (default 5 MB). If the file doesn't exist,
132    returns an empty list rather than raising, so the /logs endpoint
133    can render a graceful empty state.
134    """
135    if not path.is_file():
136        return []
137    try:
138        text = path.read_text(encoding="utf-8", errors="replace")
139    except OSError:
140        return []
141    lines = [line for line in text.splitlines() if line.strip()]
142    return lines[-n:] if n > 0 else []

Return the last n non-blank log lines from path.

Reads the whole file — cheap for logs bounded by JMRI_LOG_MAX_MB (default 5 MB). If the file doesn't exist, returns an empty list rather than raising, so the /logs endpoint can render a graceful empty state.