xref: /aosp_15_r20/external/pigweed/pw_cli/py/pw_cli/log.py (revision 61c4878ac05f98d0ceed94b57d316916de578985)
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