blob: de6056368d1a52ce7496eb9aa42df2b9543f5f35 [file] [log] [blame]
maruelea586f32016-04-05 11:11:33 -07001# Copyright 2015 The LUCI Authors. All rights reserved.
maruelf1f5e2a2016-05-25 17:10:39 -07002# 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 Ruelf74cffe2015-07-15 15:21:34 -04004
5"""Utility relating to logging."""
6
Marc-Antoine Ruelf899c482019-10-10 23:32:06 +00007from __future__ import print_function
8
Marc-Antoine Ruelf74cffe2015-07-15 15:21:34 -04009import argparse
10import codecs
maruel625c1ea2015-09-09 11:41:13 -070011import ctypes
Marc-Antoine Ruelf74cffe2015-07-15 15:21:34 -040012import logging
13import logging.handlers
14import optparse
15import os
16import sys
17import tempfile
18import time
19
nodir9130f072016-05-27 13:59:08 -070020from utils import file_path
Marc-Antoine Ruelf74cffe2015-07-15 15:21:34 -040021
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
32if sys.platform == 'win32':
maruel625c1ea2015-09-09 11:41:13 -070033 import ctypes
Marc-Antoine Ruelf74cffe2015-07-15 15:21:34 -040034 import msvcrt # pylint: disable=F0401
35 import _subprocess # pylint: disable=F0401
36
Marc-Antoine Ruel0eb2eb22019-01-29 21:00:16 +000037 FILE_ATTRIBUTE_NORMAL = 0x80
maruel625c1ea2015-09-09 11:41:13 -070038 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 Ruelf74cffe2015-07-15 15:21:34 -040045 # 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
maruel625c1ea2015-09-09 11:41:13 -070048
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 Ruelf74cffe2015-07-15 15:21:34 -040067
68
69 class NoInheritRotatingFileHandler(logging.handlers.RotatingFileHandler):
70 def _open(self):
maruel625c1ea2015-09-09 11:41:13 -070071 """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 Ruelf74cffe2015-07-15 15:21:34 -040084
85
86else: # Not Windows.
87
88
89 NoInheritRotatingFileHandler = logging.handlers.RotatingFileHandler
90
91
92# Levels used for logging.
93LEVELS = [logging.ERROR, logging.INFO, logging.DEBUG]
94
95
96class 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
146class 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
maruel9fd83362015-10-01 10:51:27 -0700159class Filter(object):
Marc-Antoine Ruelf74cffe2015-07-15 15:21:34 -0400160 """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
171def 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
178def 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()
maruel26cfc602015-09-04 19:12:55 -0700189 if not logger:
190 # Better print insanity than crash.
Marc-Antoine Ruelf899c482019-10-10 23:32:06 +0000191 print('OMG NO ROOT', file=sys.stderr)
maruel26cfc602015-09-04 19:12:55 -0700192 return
Marc-Antoine Ruelf74cffe2015-07-15 15:21:34 -0400193 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:
nodir9130f072016-05-27 13:59:08 -0700205 file_path.ensure_tree(os.path.dirname(os.path.abspath(unicode(filename))))
Marc-Antoine Ruelf74cffe2015-07-15 15:21:34 -0400206 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
219def set_console_level(level, root=None):
220 """Reset the console (stderr) logging level."""
221 handler = find_stderr(root)
maruel26cfc602015-09-04 19:12:55 -0700222 if not handler:
223 # Better print insanity than crash.
Marc-Antoine Ruelf899c482019-10-10 23:32:06 +0000224 print('OMG NO STDERR', file=sys.stderr)
maruel26cfc602015-09-04 19:12:55 -0700225 return
Marc-Antoine Ruelf74cffe2015-07-15 15:21:34 -0400226 handler.setLevel(level)
227
228
229class 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
267class 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