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