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