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, Optional, Union, 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() -> None: 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 77def _setup_handler( 78 handler: logging.Handler, 79 formatter: logging.Formatter, 80 level: Union[str, int], 81 logger: logging.Logger, 82) -> None: 83 handler.setLevel(level) 84 handler.setFormatter(formatter) 85 logger.addHandler(handler) 86 87 88def install( 89 level: Union[str, int] = logging.INFO, 90 use_color: Optional[bool] = None, 91 hide_timestamp: bool = False, 92 log_file: Optional[Union[str, Path]] = None, 93 logger: Optional[logging.Logger] = None, 94 debug_log: Optional[Union[str, Path]] = None, 95 time_format: str = '%Y%m%d %H:%M:%S', 96 msec_format: str = '%s,%03d', 97 include_msec: bool = False, 98 message_format: str = '%(levelname)s %(message)s', 99) -> None: 100 """Configures the system logger for the default pw command log format. 101 102 If you have Python loggers separate from the root logger you can use 103 `pw_cli.log.install` to get the Pigweed log formatting there too. For 104 example: :: 105 106 import logging 107 108 import pw_cli.log 109 110 pw_cli.log.install( 111 level=logging.INFO, 112 use_color=True, 113 hide_timestamp=False, 114 log_file=(Path.home() / 'logs.txt'), 115 logger=logging.getLogger(__package__), 116 ) 117 118 Args: 119 level: The logging level to apply. Default: `logging.INFO`. 120 use_color: When `True` include ANSI escape sequences to colorize log 121 messages. 122 hide_timestamp: When `True` omit timestamps from the log formatting. 123 log_file: File to send logs into instead of the terminal. 124 logger: Python Logger instance to install Pigweed formatting into. 125 Defaults to the Python root logger: `logging.getLogger()`. 126 debug_log: File to log to from all levels, regardless of chosen log level. 127 Logs will go here in addition to the terminal. 128 time_format: Default time format string. 129 msec_format: Default millisecond format string. This should be a format 130 string that accepts a both a string ``%s`` and an integer ``%d``. The 131 default Python format for this string is ``%s,%03d``. 132 include_msec: Whether or not to include the millisecond part of log 133 timestamps. 134 message_format: The message format string. By default this includes 135 levelname and message. The asctime field is prepended to this unless 136 hide_timestamp=True. 137 """ 138 if not logger: 139 logger = logging.getLogger() 140 141 colors = pw_cli_colors(use_color) 142 143 env = pigweed_environment() 144 if env.PW_SUBPROCESS or hide_timestamp: 145 # If the logger is being run in the context of a pw subprocess, the 146 # time and date are omitted (since pw_cli.process will provide them). 147 timestamp_fmt = '' 148 else: 149 # This applies a gray background to the time to make the log lines 150 # distinct from other input, in a way that's easier to see than plain 151 # colored text. 152 timestamp_fmt = colors.black_on_white('%(asctime)s') + ' ' 153 154 formatter = logging.Formatter(fmt=timestamp_fmt + message_format) 155 156 formatter.default_time_format = time_format 157 if include_msec: 158 formatter.default_msec_format = msec_format 159 else: 160 # Python 3.8 and lower does not check if default_msec_format is set. 161 # https://github.com/python/cpython/blob/3.8/Lib/logging/__init__.py#L611 162 # https://github.com/python/cpython/blob/3.9/Lib/logging/__init__.py#L605 163 if sys.version_info >= ( 164 3, 165 9, 166 ): 167 formatter.default_msec_format = '' 168 169 # Set the log level on the root logger to NOTSET, so that all logs 170 # propagated from child loggers are handled. 171 logging.getLogger().setLevel(logging.NOTSET) 172 173 # Always set up the stderr handler, even if it isn't used. 174 _setup_handler(_STDERR_HANDLER, formatter, level, logger) 175 176 if log_file: 177 # Set utf-8 encoding for the log file. Encoding errors may come up on 178 # Windows if the default system encoding is set to cp1250. 179 _setup_handler( 180 logging.FileHandler(log_file, encoding='utf-8'), 181 formatter, 182 level, 183 logger, 184 ) 185 # Since we're using a file, filter logs out of the stderr handler. 186 _STDERR_HANDLER.setLevel(logging.CRITICAL + 1) 187 188 if debug_log: 189 # Set utf-8 encoding for the log file. Encoding errors may come up on 190 # Windows if the default system encoding is set to cp1250. 191 _setup_handler( 192 logging.FileHandler(debug_log, encoding='utf-8'), 193 formatter, 194 logging.DEBUG, 195 logger, 196 ) 197 198 if env.PW_EMOJI: 199 name_attr = 'emoji' 200 colorize = lambda ll: str 201 else: 202 name_attr = 'ascii' 203 colorize = lambda ll: getattr(colors, ll.color) 204 205 for log_level in _LOG_LEVELS: 206 name = getattr(log_level, name_attr) 207 logging.addLevelName(log_level.level, colorize(log_level)(name)) 208 209 210def all_loggers() -> Iterator[logging.Logger]: 211 """Iterates over all loggers known to Python logging.""" 212 manager = logging.getLogger().manager # type: ignore[attr-defined] 213 214 for logger_name in manager.loggerDict: # pylint: disable=no-member 215 yield logging.getLogger(logger_name) 216 217 218def set_all_loggers_minimum_level(level: int) -> None: 219 """Increases the log level to the specified value for all known loggers.""" 220 for logger in all_loggers(): 221 if logger.isEnabledFor(level - 1): 222 logger.setLevel(level) 223 224 225if __name__ == '__main__': 226 install() 227 main() 228