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 (defaultINFO).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 []
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.
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.
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.