• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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