maruel | ea586f3 | 2016-04-05 11:11:33 -0700 | [diff] [blame] | 1 | # Copyright 2015 The LUCI Authors. All rights reserved. |
maruel | f1f5e2a | 2016-05-25 17:10:39 -0700 | [diff] [blame] | 2 | # Use of this source code is governed under the Apache License, Version 2.0 |
| 3 | # that can be found in the LICENSE file. |
Marc-Antoine Ruel | f74cffe | 2015-07-15 15:21:34 -0400 | [diff] [blame] | 4 | |
| 5 | """Utility relating to logging.""" |
| 6 | |
Marc-Antoine Ruel | f899c48 | 2019-10-10 23:32:06 +0000 | [diff] [blame] | 7 | from __future__ import print_function |
| 8 | |
Marc-Antoine Ruel | f74cffe | 2015-07-15 15:21:34 -0400 | [diff] [blame] | 9 | import argparse |
| 10 | import codecs |
maruel | 625c1ea | 2015-09-09 11:41:13 -0700 | [diff] [blame] | 11 | import ctypes |
Marc-Antoine Ruel | f74cffe | 2015-07-15 15:21:34 -0400 | [diff] [blame] | 12 | import logging |
| 13 | import logging.handlers |
| 14 | import optparse |
| 15 | import os |
| 16 | import sys |
| 17 | import tempfile |
| 18 | import time |
| 19 | |
Takuto Ikuta | 39b612a | 2019-10-18 08:51:22 +0000 | [diff] [blame] | 20 | import six |
| 21 | |
nodir | 9130f07 | 2016-05-27 13:59:08 -0700 | [diff] [blame] | 22 | from utils import file_path |
Marc-Antoine Ruel | f74cffe | 2015-07-15 15:21:34 -0400 | [diff] [blame] | 23 | |
| 24 | # This works around file locking issue on Windows specifically in the case of |
| 25 | # long lived child processes. |
| 26 | # |
| 27 | # Python opens files with inheritable handle and without file sharing by |
| 28 | # default. This causes the RotatingFileHandler file handle to be duplicated in |
| 29 | # the subprocesses even if the log file is not used in it. Because of this |
| 30 | # handle in the child process, when the RotatingFileHandler tries to os.rename() |
| 31 | # the file in the parent process, it fails with: |
| 32 | # WindowsError: [Error 32] The process cannot access the file because |
| 33 | # it is being used by another process |
| 34 | if sys.platform == 'win32': |
maruel | 625c1ea | 2015-09-09 11:41:13 -0700 | [diff] [blame] | 35 | import ctypes |
Marc-Antoine Ruel | f74cffe | 2015-07-15 15:21:34 -0400 | [diff] [blame] | 36 | import msvcrt # pylint: disable=F0401 |
| 37 | import _subprocess # pylint: disable=F0401 |
| 38 | |
Marc-Antoine Ruel | 0eb2eb2 | 2019-01-29 21:00:16 +0000 | [diff] [blame] | 39 | FILE_ATTRIBUTE_NORMAL = 0x80 |
maruel | 625c1ea | 2015-09-09 11:41:13 -0700 | [diff] [blame] | 40 | FILE_SHARE_READ = 1 |
| 41 | FILE_SHARE_WRITE = 2 |
| 42 | FILE_SHARE_DELETE = 4 |
| 43 | GENERIC_READ = 0x80000000 |
| 44 | GENERIC_WRITE = 0x40000000 |
| 45 | OPEN_ALWAYS = 4 |
| 46 | |
Marc-Antoine Ruel | f74cffe | 2015-07-15 15:21:34 -0400 | [diff] [blame] | 47 | # TODO(maruel): Make it work in cygwin too if necessary. This would have to |
| 48 | # use ctypes.cdll.kernel32 instead of _subprocess and msvcrt. |
| 49 | |
maruel | 625c1ea | 2015-09-09 11:41:13 -0700 | [diff] [blame] | 50 | |
| 51 | def shared_open(path): |
| 52 | """Opens a file with full sharing mode and without inheritance. |
| 53 | |
| 54 | The file is open for both read and write. |
| 55 | |
| 56 | See https://bugs.python.org/issue15244 for inspiration. |
| 57 | """ |
Takuto Ikuta | 39b612a | 2019-10-18 08:51:22 +0000 | [diff] [blame] | 58 | path = six.text_type(path) |
maruel | 625c1ea | 2015-09-09 11:41:13 -0700 | [diff] [blame] | 59 | handle = ctypes.windll.kernel32.CreateFileW( |
| 60 | path, |
| 61 | GENERIC_READ|GENERIC_WRITE, |
| 62 | FILE_SHARE_READ|FILE_SHARE_WRITE|FILE_SHARE_DELETE, |
| 63 | None, |
| 64 | OPEN_ALWAYS, |
| 65 | FILE_ATTRIBUTE_NORMAL, |
| 66 | None) |
| 67 | ctr_handle = msvcrt.open_osfhandle(handle, os.O_BINARY | os.O_NOINHERIT) |
| 68 | return os.fdopen(ctr_handle, 'r+b') |
Marc-Antoine Ruel | f74cffe | 2015-07-15 15:21:34 -0400 | [diff] [blame] | 69 | |
| 70 | |
| 71 | class NoInheritRotatingFileHandler(logging.handlers.RotatingFileHandler): |
| 72 | def _open(self): |
maruel | 625c1ea | 2015-09-09 11:41:13 -0700 | [diff] [blame] | 73 | """Opens the log file without handle inheritance but with file sharing. |
| 74 | |
| 75 | Ignores self.mode. |
| 76 | """ |
| 77 | f = shared_open(self.baseFilename) |
| 78 | if self.encoding: |
| 79 | # Do the equivalent of |
| 80 | # codecs.open(self.baseFilename, self.mode, self.encoding) |
| 81 | info = codecs.lookup(self.encoding) |
| 82 | f = codecs.StreamReaderWriter( |
| 83 | f, info.streamreader, info.streamwriter, 'replace') |
| 84 | f.encoding = self.encoding |
| 85 | return f |
Marc-Antoine Ruel | f74cffe | 2015-07-15 15:21:34 -0400 | [diff] [blame] | 86 | |
| 87 | |
| 88 | else: # Not Windows. |
| 89 | |
| 90 | |
| 91 | NoInheritRotatingFileHandler = logging.handlers.RotatingFileHandler |
| 92 | |
| 93 | |
| 94 | # Levels used for logging. |
| 95 | LEVELS = [logging.ERROR, logging.INFO, logging.DEBUG] |
| 96 | |
| 97 | |
| 98 | class CaptureLogs(object): |
| 99 | """Captures all the logs in a context.""" |
| 100 | def __init__(self, prefix, root=None): |
| 101 | handle, self._path = tempfile.mkstemp(prefix=prefix, suffix='.log') |
| 102 | os.close(handle) |
| 103 | self._handler = logging.FileHandler(self._path, 'w') |
| 104 | self._handler.setLevel(logging.DEBUG) |
| 105 | formatter = UTCFormatter( |
| 106 | '%(process)d %(asctime)s: %(levelname)-5s %(message)s') |
| 107 | self._handler.setFormatter(formatter) |
| 108 | self._root = root or logging.getLogger() |
| 109 | self._root.addHandler(self._handler) |
| 110 | assert self._root.isEnabledFor(logging.DEBUG) |
| 111 | |
| 112 | def read(self): |
| 113 | """Returns the current content of the logs. |
| 114 | |
| 115 | This also closes the log capture so future logs will not be captured. |
| 116 | """ |
| 117 | self._disconnect() |
| 118 | assert self._path |
| 119 | try: |
| 120 | with open(self._path, 'rb') as f: |
| 121 | return f.read() |
| 122 | except IOError as e: |
| 123 | return 'Failed to read %s: %s' % (self._path, e) |
| 124 | |
| 125 | def close(self): |
| 126 | """Closes and delete the log.""" |
| 127 | self._disconnect() |
| 128 | if self._path: |
| 129 | try: |
| 130 | os.remove(self._path) |
| 131 | except OSError as e: |
| 132 | logging.error('Failed to delete log file %s: %s', self._path, e) |
| 133 | self._path = None |
| 134 | |
| 135 | def __enter__(self): |
| 136 | return self |
| 137 | |
| 138 | def __exit__(self, _exc_type, _exc_value, _traceback): |
| 139 | self.close() |
| 140 | |
| 141 | def _disconnect(self): |
| 142 | if self._handler: |
| 143 | self._root.removeHandler(self._handler) |
| 144 | self._handler.close() |
| 145 | self._handler = None |
| 146 | |
| 147 | |
| 148 | class UTCFormatter(logging.Formatter): |
| 149 | converter = time.gmtime |
| 150 | |
| 151 | def formatTime(self, record, datefmt=None): |
| 152 | """Change is ',' to '.'.""" |
| 153 | ct = self.converter(record.created) |
| 154 | if datefmt: |
| 155 | return time.strftime(datefmt, ct) |
| 156 | else: |
| 157 | t = time.strftime("%Y-%m-%d %H:%M:%S", ct) |
| 158 | return "%s.%03d" % (t, record.msecs) |
| 159 | |
| 160 | |
maruel | 9fd8336 | 2015-10-01 10:51:27 -0700 | [diff] [blame] | 161 | class Filter(object): |
Marc-Antoine Ruel | f74cffe | 2015-07-15 15:21:34 -0400 | [diff] [blame] | 162 | """Adds fields used by the infra-specific formatter. |
| 163 | |
| 164 | Fields added: |
| 165 | - 'severity': one-letter indicator of log level (first letter of levelname). |
| 166 | """ |
| 167 | |
| 168 | def filter(self, record): |
| 169 | record.severity = record.levelname[0] |
| 170 | return True |
| 171 | |
| 172 | |
| 173 | def find_stderr(root=None): |
| 174 | """Returns the logging.handler streaming to stderr, if any.""" |
| 175 | for log in (root or logging.getLogger()).handlers: |
| 176 | if getattr(log, 'stream', None) is sys.stderr: |
| 177 | return log |
| 178 | |
| 179 | |
| 180 | def prepare_logging(filename, root=None): |
| 181 | """Prepare logging for scripts. |
| 182 | |
| 183 | Makes it log in UTC all the time. Prepare a rotating file based log. |
| 184 | """ |
| 185 | assert not find_stderr(root) |
Takuto Ikuta | 5decd1d | 2020-04-14 06:02:29 +0000 | [diff] [blame] | 186 | formatter = UTCFormatter('%(process)d %(asctime)s %(severity)s: %(message)s') |
Marc-Antoine Ruel | f74cffe | 2015-07-15 15:21:34 -0400 | [diff] [blame] | 187 | |
| 188 | # It is a requirement that the root logger is set to DEBUG, so the messages |
| 189 | # are not lost. It defaults to WARNING otherwise. |
| 190 | logger = root or logging.getLogger() |
maruel | 26cfc60 | 2015-09-04 19:12:55 -0700 | [diff] [blame] | 191 | if not logger: |
| 192 | # Better print insanity than crash. |
Marc-Antoine Ruel | f899c48 | 2019-10-10 23:32:06 +0000 | [diff] [blame] | 193 | print('OMG NO ROOT', file=sys.stderr) |
maruel | 26cfc60 | 2015-09-04 19:12:55 -0700 | [diff] [blame] | 194 | return |
Marc-Antoine Ruel | f74cffe | 2015-07-15 15:21:34 -0400 | [diff] [blame] | 195 | logger.setLevel(logging.DEBUG) |
| 196 | |
| 197 | stderr = logging.StreamHandler() |
| 198 | stderr.setFormatter(formatter) |
| 199 | stderr.addFilter(Filter()) |
| 200 | # Default to ERROR. |
| 201 | stderr.setLevel(logging.ERROR) |
| 202 | logger.addHandler(stderr) |
| 203 | |
| 204 | # Setup up logging to a constant file so we can debug issues where |
| 205 | # the results aren't properly sent to the result URL. |
| 206 | if filename: |
Takuto Ikuta | 39b612a | 2019-10-18 08:51:22 +0000 | [diff] [blame] | 207 | file_path.ensure_tree( |
| 208 | os.path.dirname(os.path.abspath(six.text_type(filename)))) |
Marc-Antoine Ruel | f74cffe | 2015-07-15 15:21:34 -0400 | [diff] [blame] | 209 | try: |
| 210 | rotating_file = NoInheritRotatingFileHandler( |
| 211 | filename, maxBytes=10 * 1024 * 1024, backupCount=5, |
| 212 | encoding='utf-8') |
| 213 | rotating_file.setLevel(logging.DEBUG) |
| 214 | rotating_file.setFormatter(formatter) |
| 215 | rotating_file.addFilter(Filter()) |
| 216 | logger.addHandler(rotating_file) |
| 217 | except Exception: |
| 218 | # May happen on cygwin. Do not crash. |
| 219 | logging.exception('Failed to open %s', filename) |
| 220 | |
| 221 | |
| 222 | def set_console_level(level, root=None): |
| 223 | """Reset the console (stderr) logging level.""" |
| 224 | handler = find_stderr(root) |
maruel | 26cfc60 | 2015-09-04 19:12:55 -0700 | [diff] [blame] | 225 | if not handler: |
| 226 | # Better print insanity than crash. |
Marc-Antoine Ruel | f899c48 | 2019-10-10 23:32:06 +0000 | [diff] [blame] | 227 | print('OMG NO STDERR', file=sys.stderr) |
maruel | 26cfc60 | 2015-09-04 19:12:55 -0700 | [diff] [blame] | 228 | return |
Marc-Antoine Ruel | f74cffe | 2015-07-15 15:21:34 -0400 | [diff] [blame] | 229 | handler.setLevel(level) |
| 230 | |
| 231 | |
| 232 | class OptionParserWithLogging(optparse.OptionParser): |
| 233 | """Adds --verbose option.""" |
| 234 | |
| 235 | # Set to True to enable --log-file options. |
| 236 | enable_log_file = True |
| 237 | |
| 238 | # Set in unit tests. |
| 239 | logger_root = None |
| 240 | |
| 241 | def __init__(self, verbose=0, log_file=None, **kwargs): |
| 242 | kwargs.setdefault('description', sys.modules['__main__'].__doc__) |
| 243 | optparse.OptionParser.__init__(self, **kwargs) |
| 244 | self.group_logging = optparse.OptionGroup(self, 'Logging') |
| 245 | self.group_logging.add_option( |
| 246 | '-v', '--verbose', |
| 247 | action='count', |
| 248 | default=verbose, |
| 249 | help='Use multiple times to increase verbosity') |
| 250 | if self.enable_log_file: |
| 251 | self.group_logging.add_option( |
| 252 | '-l', '--log-file', |
| 253 | default=log_file, |
| 254 | help='The name of the file to store rotating log details') |
| 255 | self.group_logging.add_option( |
| 256 | '--no-log', action='store_const', const='', dest='log_file', |
| 257 | help='Disable log file') |
| 258 | |
| 259 | def parse_args(self, *args, **kwargs): |
| 260 | # Make sure this group is always the last one. |
| 261 | self.add_option_group(self.group_logging) |
| 262 | |
| 263 | options, args = optparse.OptionParser.parse_args(self, *args, **kwargs) |
| 264 | prepare_logging(self.enable_log_file and options.log_file, self.logger_root) |
| 265 | set_console_level( |
| 266 | LEVELS[min(len(LEVELS) - 1, options.verbose)], self.logger_root) |
| 267 | return options, args |
| 268 | |
| 269 | |
| 270 | class ArgumentParserWithLogging(argparse.ArgumentParser): |
| 271 | """Adds --verbose option.""" |
| 272 | |
| 273 | # Set to True to enable --log-file options. |
| 274 | enable_log_file = True |
| 275 | |
| 276 | def __init__(self, verbose=0, log_file=None, **kwargs): |
| 277 | kwargs.setdefault('description', sys.modules['__main__'].__doc__) |
| 278 | kwargs.setdefault('conflict_handler', 'resolve') |
| 279 | self.__verbose = verbose |
| 280 | self.__log_file = log_file |
| 281 | super(ArgumentParserWithLogging, self).__init__(**kwargs) |
| 282 | |
| 283 | def _add_logging_group(self): |
| 284 | group = self.add_argument_group('Logging') |
| 285 | group.add_argument( |
| 286 | '-v', '--verbose', |
| 287 | action='count', |
| 288 | default=self.__verbose, |
| 289 | help='Use multiple times to increase verbosity') |
| 290 | if self.enable_log_file: |
| 291 | group.add_argument( |
| 292 | '-l', '--log-file', |
| 293 | default=self.__log_file, |
| 294 | help='The name of the file to store rotating log details') |
| 295 | group.add_argument( |
| 296 | '--no-log', action='store_const', const='', dest='log_file', |
| 297 | help='Disable log file') |
| 298 | |
| 299 | def parse_args(self, *args, **kwargs): |
| 300 | # Make sure this group is always the last one. |
| 301 | self._add_logging_group() |
| 302 | |
| 303 | args = super(ArgumentParserWithLogging, self).parse_args(*args, **kwargs) |
| 304 | prepare_logging(self.enable_log_file and args.log_file, self.logger_root) |
| 305 | set_console_level( |
| 306 | LEVELS[min(len(LEVELS) - 1, args.verbose)], self.logger_root) |
| 307 | return args |