Coverage for bzfs_main / loggers.py: 100%
124 statements
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-22 08:03 +0000
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-22 08:03 +0000
1# Copyright 2024 Wolfgang Hoschek AT mac DOT com
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14#
15"""Logging helpers that build default and syslog-enabled loggers; Centralizes logger setup so that all bzfs tools share
16uniform formatting and configuration.
18Each bzfs.Job has its own separate Logger object such that multiple Jobs can run concurrently in the same Python process
19without interfering with each other's logging semantics. This is achieved by passing a unique ``logger_name_suffix`` per job
20to ``get_logger()`` so each job receives an isolated Logger instance. Callers are responsible for closing any loggers they
21own via ``reset_logger()``.
22"""
23from __future__ import (
24 annotations,
25)
26import argparse
27import contextlib
28import logging
29import sys
30from datetime import (
31 datetime,
32)
33from logging import (
34 Logger,
35)
36from typing import (
37 TYPE_CHECKING,
38 Any,
39 Final,
40 final,
41)
43from bzfs_main.util.utils import (
44 LOG_STDERR,
45 LOG_STDOUT,
46 LOG_TRACE,
47)
49if TYPE_CHECKING: # pragma: no cover - for type hints only
50 from bzfs_main.configuration import (
51 LogParams,
52 )
55def _get_logger_name() -> str:
56 """Returns the canonical logger name used throughout bzfs."""
57 return "bzfs_main.bzfs"
60def _resolve_logger_name(logger_name_suffix: str) -> str:
61 """Returns the logger name for the given optional logger suffix.
63 Each bzfs.Job instance gets a distinct Logger name and hence uses a separate private (thread-safe) logging.Logger object.
64 """
65 logger_name: str = _get_logger_name()
66 return logger_name + "." + logger_name_suffix if logger_name_suffix else logger_name
69def reset_logger(log: Logger) -> None:
70 """Removes and closes logging handlers (and closes their files) and resets logger to default state."""
71 for handler in log.handlers.copy():
72 log.removeHandler(handler)
73 with contextlib.suppress(BrokenPipeError):
74 handler.flush()
75 handler.close()
76 for _filter in log.filters.copy():
77 log.removeFilter(_filter)
78 log.setLevel(logging.NOTSET)
79 log.propagate = True
82def get_logger(
83 log_params: LogParams, args: argparse.Namespace, log: Logger | None = None, logger_name_suffix: str = ""
84) -> Logger:
85 """Returns a logger configured from CLI arguments or an optional base logger."""
86 if log is not None:
87 assert isinstance(log, Logger)
88 return log # use third party provided logger object
89 return _get_default_logger(log_params, args, logger_name_suffix=logger_name_suffix)
92def _get_default_logger(log_params: LogParams, args: argparse.Namespace, logger_name_suffix: str = "") -> Logger:
93 """Creates the default logger with stream, file and optional syslog handlers."""
94 logger_name: str = _resolve_logger_name(logger_name_suffix)
95 log = Logger(logger_name) # noqa: LOG001 do not register logger with Logger.manager to avoid potential memory leak
96 log.setLevel(log_params.log_level)
97 log.propagate = False # don't propagate log messages up to the root logger to avoid emitting duplicate messages
99 handler: logging.StreamHandler = logging.StreamHandler(stream=sys.stdout)
100 handler.terminator = ""
101 handler.setFormatter(get_default_log_formatter(default_terminator="\n", log_params=log_params))
102 handler.setLevel(log_params.log_level)
103 log.addHandler(handler)
105 handler = logging.FileHandler(log_params.log_file, encoding="utf-8")
106 handler.terminator = ""
107 handler.setFormatter(get_default_log_formatter(default_terminator="\n"))
108 handler.setLevel(log_params.log_level)
109 log.addHandler(handler)
111 address = args.log_syslog_address
112 if address: # optionally, also log to local or remote syslog
113 from logging import handlers # lazy import for startup perf
115 address, socktype = _get_syslog_address(address, args.log_syslog_socktype)
116 log_syslog_prefix = str(args.log_syslog_prefix).strip().replace("%", "") # sanitize
117 syslog_handler = handlers.SysLogHandler(address=address, facility=args.log_syslog_facility, socktype=socktype)
118 syslog_handler.setFormatter(get_default_log_formatter(prefix=log_syslog_prefix + " "))
119 syslog_handler.setLevel(args.log_syslog_level)
120 log.addHandler(syslog_handler)
121 if syslog_handler.level < log.getEffectiveLevel():
122 log_level_name: str = logging.getLevelName(log.getEffectiveLevel())
123 log.warning(
124 "%s",
125 f"No messages with priority lower than {log_level_name} will be sent to syslog because syslog "
126 f"log level {args.log_syslog_level} is lower than overall log level {log_level_name}.",
127 )
128 return log
131LOG_LEVEL_PREFIXES: Final[dict[int, str]] = {
132 logging.CRITICAL: "[C] CRITICAL:",
133 logging.ERROR: "[E] ERROR:",
134 logging.WARNING: "[W]",
135 logging.INFO: "[I]",
136 logging.DEBUG: "[D]",
137 LOG_TRACE: "[T]",
138}
141def get_default_log_formatter(
142 prefix: str = "", default_terminator: str = "", log_params: LogParams | None = None
143) -> logging.Formatter:
144 """Returns a formatter for bzfs logs with optional prefix and column padding."""
145 level_prefixes_: dict[int, str] = LOG_LEVEL_PREFIXES.copy()
146 log_stderr_: int = LOG_STDERR
147 log_stdout_: int = LOG_STDOUT
148 cols: int = 0 if log_params is None else log_params.terminal_columns
150 @final
151 class DefaultLogFormatter(logging.Formatter):
152 """Formatter adding timestamps and padding for progress output."""
154 def format(self, record: logging.LogRecord) -> str:
155 """Formats the given record, adding timestamp and level prefix and padding."""
156 levelno: int = record.levelno
157 if levelno != log_stderr_ and levelno != log_stdout_: # emit stdout and stderr "as-is" (no formatting)
158 timestamp: str = datetime.now().isoformat(sep=" ", timespec="seconds") # 2024-09-03 12:26:15
159 ts_level: str = f"{timestamp} {level_prefixes_.get(levelno, '')} "
160 msg: str = record.msg
161 i: int = msg.find("%s")
162 msg = ts_level + msg
163 if i >= 1:
164 i += len(ts_level)
165 msg = msg[0:i].ljust(54) + msg[i:] # right-pad msg if record.msg contains "%s" unless at start
166 if record.exc_info or record.exc_text or record.stack_info:
167 original_msg = record.msg
168 try:
169 record.msg = msg
170 msg = super().format(record)
171 finally:
172 record.msg = original_msg # restore original msg so other logging handlers see unmodified state
173 elif record.args:
174 msg = msg % record.args
175 else:
176 msg = super().format(record)
178 msg = prefix + msg
179 msg = msg.ljust(cols) # w/ progress line, "overwrite" trailing chars of previous msg with spaces
180 terminator: str = getattr(record, "terminator", default_terminator)
181 return msg + terminator
183 return DefaultLogFormatter()
186def get_simple_logger(program: str, logger_name_suffix: str = "") -> Logger:
187 """Returns a minimal logger for simple tools."""
189 level_prefixes_: dict[int, str] = LOG_LEVEL_PREFIXES.copy()
190 logger_name = program + "." + logger_name_suffix if logger_name_suffix else program
192 @final
193 class LevelFormatter(logging.Formatter):
194 """Injects level prefix and program name into log records."""
196 def format(self, record: logging.LogRecord) -> str:
197 """Attaches extra fields before delegating to base formatter."""
198 record.level_prefix = level_prefixes_.get(record.levelno, "")
199 record.program = program
200 return super().format(record)
202 log = Logger(logger_name) # noqa: LOG001 do not register logger with Logger.manager to avoid potential memory leak
203 log.setLevel(logging.INFO)
204 log.propagate = False
205 handler = logging.StreamHandler()
206 handler.setFormatter(
207 LevelFormatter(fmt="%(asctime)s %(level_prefix)s [%(program)s] %(message)s", datefmt="%Y-%m-%d %H:%M:%S")
208 )
209 log.addHandler(handler)
210 return log
213def set_logging_runtime_defaults() -> None:
214 """Applies process-wide logging runtime defaults for production use."""
215 logging.addLevelName(LOG_TRACE, "TRACE")
216 logging.addLevelName(LOG_STDERR, "STDERR")
217 logging.addLevelName(LOG_STDOUT, "STDOUT")
218 # perf: tell logging framework not to gather unnecessary expensive info for each log record
219 logging.logProcesses = False
220 logging.logThreads = False
221 logging.logMultiprocessing = False
222 logging.raiseExceptions = False # avoid noisy tracebacks from logging handler errors like BrokenPipeError in production
225def _get_syslog_address(address: str, log_syslog_socktype: str) -> tuple[str | tuple[str, int], Any]:
226 """Normalizes syslog address to tuple form and returns socket type."""
227 import socket # lazy import for startup perf
229 address = address.strip()
230 socktype: socket.SocketKind | None = None
231 if ":" in address:
232 host, port_str = address.rsplit(":", 1)
233 addr = (host.strip(), int(port_str.strip()))
234 scktype: socket.SocketKind = socket.SOCK_DGRAM if log_syslog_socktype == "UDP" else socket.SOCK_STREAM # for TCP
235 return addr, scktype
236 return address, socktype