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