1# Copyright 2020 The Pigweed Authors 2# 3# Licensed under the Apache License, Version 2.0 (the "License"); you may not 4# use this file except in compliance with the License. You may obtain a copy of 5# the License at 6# 7# https://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, WITHOUT 11# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12# License for the specific language governing permissions and limitations under 13# the License. 14"""Tools for configuring Python logging.""" 15 16import logging 17from pathlib import Path 18import sys 19from typing import NamedTuple, Iterator 20 21from pw_cli.color import colors as pw_cli_colors 22from pw_cli.env import pigweed_environment 23 24# Log level used for captured output of a subprocess run through pw. 25LOGLEVEL_STDOUT = 21 26 27# Log level indicating a irrecoverable failure. 28LOGLEVEL_FATAL = 70 29 30 31class _LogLevel(NamedTuple): 32 level: int 33 color: str 34 ascii: str 35 emoji: str 36 37 38# Shorten all the log levels to 3 characters for column-aligned logs. 39# Color the logs using ANSI codes. 40# fmt: off 41_LOG_LEVELS = ( 42 _LogLevel(LOGLEVEL_FATAL, 'bold_red', 'FTL', '☠️ '), 43 _LogLevel(logging.CRITICAL, 'bold_magenta', 'CRT', '‼️ '), 44 _LogLevel(logging.ERROR, 'red', 'ERR', '❌'), 45 _LogLevel(logging.WARNING, 'yellow', 'WRN', '⚠️ '), 46 _LogLevel(logging.INFO, 'magenta', 'INF', 'ℹ️ '), 47 _LogLevel(LOGLEVEL_STDOUT, 'cyan', 'OUT', ''), 48 _LogLevel(logging.DEBUG, 'blue', 'DBG', ''), 49) 50# fmt: on 51 52_LOG = logging.getLogger(__name__) 53_STDERR_HANDLER = logging.StreamHandler() 54 55 56def c_to_py_log_level(c_level: int) -> int: 57 """Converts pw_log C log-level macros to Python logging levels.""" 58 return c_level * 10 59 60 61def main() -> int: 62 """Shows how logs look at various levels.""" 63 64 # Force the log level to make sure all logs are shown. 65 _LOG.setLevel(logging.DEBUG) 66 67 # Log one message for every log level. 68 _LOG.log(LOGLEVEL_FATAL, 'An irrecoverable error has occurred!') 69 _LOG.critical('Something important has happened!') 70 _LOG.error('There was an error on our last operation') 71 _LOG.warning('Looks like something is amiss; consider investigating') 72 _LOG.info('The operation went as expected') 73 _LOG.log(LOGLEVEL_STDOUT, 'Standard output of subprocess') 74 _LOG.debug('Adding 1 to i') 75 76 return 0 77 78 79def _setup_handler( 80 handler: logging.Handler, 81 formatter: logging.Formatter, 82 level: str | int, 83 logger: logging.Logger, 84) -> None: 85 handler.setLevel(level) 86 handler.setFormatter(formatter) 87 logger.addHandler(handler) 88 89 90def install( 91 level: str | int = logging.INFO, 92 use_color: bool | None = None, 93 hide_timestamp: bool = False, 94 log_file: str | Path | None = None, 95 logger: logging.Logger | None = None, 96 debug_log: str | Path | None = None, 97 time_format: str = '%Y%m%d %H:%M:%S', 98 msec_format: str = '%s,%03d', 99 include_msec: bool = False, 100 message_format: str = '%(levelname)s %(message)s', 101) -> None: 102 """Configures the system logger for the default pw command log format. 103 104 If you have Python loggers separate from the root logger you can use 105 `pw_cli.log.install` to get the Pigweed log formatting there too. For 106 example: :: 107 108 import logging 109 110 import pw_cli.log 111 112 pw_cli.log.install( 113 level=logging.INFO, 114 use_color=True, 115 hide_timestamp=False, 116 log_file=(Path.home() / 'logs.txt'), 117 logger=logging.getLogger(__package__), 118 ) 119 120 Args: 121 level: The logging level to apply. Default: `logging.INFO`. 122 use_color: When `True` include ANSI escape sequences to colorize log 123 messages. 124 hide_timestamp: When `True` omit timestamps from the log formatting. 125 log_file: File to send logs into instead of the terminal. 126 logger: Python Logger instance to install Pigweed formatting into. 127 Defaults to the Python root logger: `logging.getLogger()`. 128 debug_log: File to log to from all levels, regardless of chosen log level. 129 Logs will go here in addition to the terminal. 130 time_format: Default time format string. 131 msec_format: Default millisecond format string. This should be a format 132 string that accepts a both a string ``%s`` and an integer ``%d``. The 133 default Python format for this string is ``%s,%03d``. 134 include_msec: Whether or not to include the millisecond part of log 135 timestamps. 136 message_format: The message format string. By default this includes 137 levelname and message. The asctime field is prepended to this unless 138 hide_timestamp=True. 139 """ 140 if not logger: 141 logger = logging.getLogger() 142 143 colors = pw_cli_colors(use_color) 144 145 env = pigweed_environment() 146 if env.PW_SUBPROCESS or hide_timestamp: 147 # If the logger is being run in the context of a pw subprocess, the 148 # time and date are omitted (since pw_cli.process will provide them). 149 timestamp_fmt = '' 150 else: 151 # This applies a gray background to the time to make the log lines 152 # distinct from other input, in a way that's easier to see than plain 153 # colored text. 154 timestamp_fmt = colors.black_on_white('%(asctime)s') + ' ' 155 156 formatter = logging.Formatter(fmt=timestamp_fmt + message_format) 157 158 formatter.default_time_format = time_format 159 if include_msec: 160 formatter.default_msec_format = msec_format 161 else: 162 # Python 3.8 and lower does not check if default_msec_format is set. 163 # https://github.com/python/cpython/blob/3.8/Lib/logging/__init__.py#L611 164 # https://github.com/python/cpython/blob/3.9/Lib/logging/__init__.py#L605 165 if sys.version_info >= ( 166 3, 167 9, 168 ): 169 formatter.default_msec_format = '' 170 # For 3.8 set datefmt to time_format 171 elif sys.version_info >= ( 172 3, 173 8, 174 ): 175 formatter.datefmt = time_format 176 177 # Set the log level on the root logger to NOTSET, so that all logs 178 # propagated from child loggers are handled. 179 logging.getLogger().setLevel(logging.NOTSET) 180 181 # Always set up the stderr handler, even if it isn't used. 182 _setup_handler(_STDERR_HANDLER, formatter, level, logger) 183 184 if log_file: 185 # Set utf-8 encoding for the log file. Encoding errors may come up on 186 # Windows if the default system encoding is set to cp1250. 187 _setup_handler( 188 logging.FileHandler(log_file, encoding='utf-8'), 189 formatter, 190 level, 191 logger, 192 ) 193 # Since we're using a file, filter logs out of the stderr handler. 194 _STDERR_HANDLER.setLevel(logging.CRITICAL + 1) 195 196 if debug_log: 197 # Set utf-8 encoding for the log file. Encoding errors may come up on 198 # Windows if the default system encoding is set to cp1250. 199 _setup_handler( 200 logging.FileHandler(debug_log, encoding='utf-8'), 201 formatter, 202 logging.DEBUG, 203 logger, 204 ) 205 206 if env.PW_EMOJI: 207 name_attr = 'emoji' 208 209 def colorize(ll): 210 del ll 211 return str 212 213 else: 214 name_attr = 'ascii' 215 216 def colorize(ll): 217 return getattr(colors, ll.color) 218 219 for log_level in _LOG_LEVELS: 220 name = getattr(log_level, name_attr) 221 logging.addLevelName(log_level.level, colorize(log_level)(name)) 222 223 224def all_loggers() -> Iterator[logging.Logger]: 225 """Iterates over all loggers known to Python logging.""" 226 manager = logging.getLogger().manager # type: ignore[attr-defined] 227 228 for logger_name in manager.loggerDict: # pylint: disable=no-member 229 yield logging.getLogger(logger_name) 230 231 232def set_all_loggers_minimum_level(level: int) -> None: 233 """Increases the log level to the specified value for all known loggers.""" 234 for logger in all_loggers(): 235 if logger.isEnabledFor(level - 1): 236 logger.setLevel(level) 237 238 239if __name__ == '__main__': 240 install() 241 main() 242