1# Copyright 2016 Google Inc. 2# 3# Licensed under the Apache License, Version 2.0 (the "License"); 4# you may not use this file except in compliance with the License. 5# You may obtain a copy of the License at 6# 7# http://www.apache.org/licenses/LICENSE-2.0 8# 9# Unless required by applicable law or agreed to in writing, software 10# distributed under the License is distributed on an "AS IS" BASIS, 11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12# See the License for the specific language governing permissions and 13# limitations under the License. 14 15import datetime 16import logging 17import os 18import re 19import sys 20from typing import Any, MutableMapping, Tuple 21 22from mobly import records 23from mobly import utils 24 25LINUX_MAX_FILENAME_LENGTH = 255 26# Filename sanitization mappings for Windows. 27# See https://docs.microsoft.com/en-us/windows/win32/fileio/naming-a-file#naming-conventions 28# Although the documentation says that 260 (including terminating nul, so 259) 29# is the max length. From manually testing on a Windows 10 machine, the actual 30# length seems to be lower. 31WINDOWS_MAX_FILENAME_LENGTH = 237 32WINDOWS_RESERVED_CHARACTERS_REPLACEMENTS = { 33 '<': '-', 34 '>': '-', 35 ':': '-', 36 '"': '_', 37 '/': '_', 38 '\\': '_', 39 '|': ',', 40 '?': ',', 41 '*': ',', 42 # Integer zero (i.e. NUL) is not a valid character. 43 # While integers 1-31 are also usually valid, they aren't sanitized because 44 # they are situationally valid. 45 chr(0): '0', 46} 47# Note, although the documentation does not specify as such, COM0 and LPT0 are 48# also invalid/reserved filenames. 49WINDOWS_RESERVED_FILENAME_REGEX = re.compile( 50 r'^(CON|PRN|AUX|NUL|(COM|LPT)[0-9])(\.[^.]*)?$', re.IGNORECASE 51) 52WINDOWS_RESERVED_FILENAME_PREFIX = 'mobly_' 53 54log_line_format = '%(asctime)s.%(msecs).03d %(levelname)s %(message)s' 55# The micro seconds are added by the format string above, 56# so the time format does not include ms. 57log_line_time_format = '%m-%d %H:%M:%S' 58log_line_timestamp_len = 18 59 60logline_timestamp_re = re.compile(r'\d\d-\d\d \d\d:\d\d:\d\d.\d\d\d') 61 62 63def _parse_logline_timestamp(t): 64 """Parses a logline timestamp into a tuple. 65 66 Args: 67 t: Timestamp in logline format. 68 69 Returns: 70 An iterable of date and time elements in the order of month, day, hour, 71 minute, second, microsecond. 72 """ 73 date, time = t.split(' ') 74 month, day = date.split('-') 75 h, m, s = time.split(':') 76 s, ms = s.split('.') 77 return (month, day, h, m, s, ms) 78 79 80def is_valid_logline_timestamp(timestamp): 81 if len(timestamp) == log_line_timestamp_len: 82 if logline_timestamp_re.match(timestamp): 83 return True 84 return False 85 86 87def logline_timestamp_comparator(t1, t2): 88 """Comparator for timestamps in logline format. 89 90 Args: 91 t1: Timestamp in logline format. 92 t2: Timestamp in logline format. 93 94 Returns: 95 -1 if t1 < t2; 1 if t1 > t2; 0 if t1 == t2. 96 """ 97 dt1 = _parse_logline_timestamp(t1) 98 dt2 = _parse_logline_timestamp(t2) 99 for u1, u2 in zip(dt1, dt2): 100 if u1 < u2: 101 return -1 102 elif u1 > u2: 103 return 1 104 return 0 105 106 107def _get_timestamp(time_format, delta=None): 108 t = datetime.datetime.now() 109 if delta: 110 t = t + datetime.timedelta(seconds=delta) 111 return t.strftime(time_format)[:-3] 112 113 114def epoch_to_log_line_timestamp(epoch_time, time_zone=None): 115 """Converts an epoch timestamp in ms to log line timestamp format, which 116 is readible for humans. 117 118 Args: 119 epoch_time: integer, an epoch timestamp in ms. 120 time_zone: instance of tzinfo, time zone information. 121 Using pytz rather than python 3.2 time_zone implementation for 122 python 2 compatibility reasons. 123 124 Returns: 125 A string that is the corresponding timestamp in log line timestamp 126 format. 127 """ 128 s, ms = divmod(epoch_time, 1000) 129 d = datetime.datetime.fromtimestamp(s, tz=time_zone) 130 return d.strftime('%m-%d %H:%M:%S.') + str(ms) 131 132 133def get_log_line_timestamp(delta=None): 134 """Returns a timestamp in the format used by log lines. 135 136 Default is current time. If a delta is set, the return value will be 137 the current time offset by delta seconds. 138 139 Args: 140 delta: Number of seconds to offset from current time; can be negative. 141 142 Returns: 143 A timestamp in log line format with an offset. 144 """ 145 return _get_timestamp('%m-%d %H:%M:%S.%f', delta) 146 147 148def get_log_file_timestamp(delta=None): 149 """Returns a timestamp in the format used for log file names. 150 151 Default is current time. If a delta is set, the return value will be 152 the current time offset by delta seconds. 153 154 Args: 155 delta: Number of seconds to offset from current time; can be negative. 156 157 Returns: 158 A timestamp in log filen name format with an offset. 159 """ 160 return _get_timestamp('%m-%d-%Y_%H-%M-%S-%f', delta) 161 162 163def _setup_test_logger(log_path, console_level, prefix=None): 164 """Customizes the root logger for a test run. 165 166 The logger object has a stream handler and a file handler. The stream 167 handler logs INFO level to the terminal, the file handler logs DEBUG 168 level to files. 169 170 Args: 171 log_path: Location of the log file. 172 console_level: Log level threshold used for log messages printed 173 to the console. Logs with a level less severe than 174 console_level will not be printed to the console. 175 prefix: A prefix for each log line in terminal. 176 filename: Name of the log file. The default is the time the logger 177 is requested. 178 """ 179 log = logging.getLogger() 180 kill_test_logger(log) 181 log.propagate = False 182 log.setLevel(logging.DEBUG) 183 # Log info to stream 184 terminal_format = log_line_format 185 if prefix: 186 terminal_format = '[%s] %s' % (prefix, log_line_format) 187 c_formatter = logging.Formatter(terminal_format, log_line_time_format) 188 ch = logging.StreamHandler(sys.stdout) 189 ch.setFormatter(c_formatter) 190 ch.setLevel(console_level) 191 # Log everything to file 192 f_formatter = logging.Formatter(log_line_format, log_line_time_format) 193 # Write logger output to files 194 fh_info = logging.FileHandler( 195 os.path.join(log_path, records.OUTPUT_FILE_INFO_LOG) 196 ) 197 fh_info.setFormatter(f_formatter) 198 fh_info.setLevel(logging.INFO) 199 fh_debug = logging.FileHandler( 200 os.path.join(log_path, records.OUTPUT_FILE_DEBUG_LOG) 201 ) 202 fh_debug.setFormatter(f_formatter) 203 fh_debug.setLevel(logging.DEBUG) 204 log.addHandler(ch) 205 log.addHandler(fh_info) 206 log.addHandler(fh_debug) 207 log.log_path = log_path 208 logging.log_path = log_path 209 logging.root_output_path = log_path 210 211 212def kill_test_logger(logger): 213 """Cleans up a test logger object by removing all of its handlers. 214 215 Args: 216 logger: The logging object to clean up. 217 """ 218 for h in list(logger.handlers): 219 logger.removeHandler(h) 220 if isinstance(h, logging.FileHandler): 221 h.close() 222 223 224def create_latest_log_alias(actual_path, alias): 225 """Creates a symlink to the latest test run logs. 226 227 Args: 228 actual_path: string, the source directory where the latest test run's 229 logs are. 230 alias: string, the name of the directory to contain the latest log 231 files. 232 """ 233 alias_path = os.path.join(os.path.dirname(actual_path), alias) 234 utils.create_alias(actual_path, alias_path) 235 236 237def setup_test_logger( 238 log_path, prefix=None, alias='latest', console_level=logging.INFO 239): 240 """Customizes the root logger for a test run. 241 242 In addition to configuring the Mobly logging handlers, this also sets two 243 attributes on the `logging` module for the output directories: 244 245 root_output_path: path to the directory for the entire test run. 246 log_path: same as `root_output_path` outside of a test class run. In the 247 context of a test class run, this is the output directory for files 248 specific to a test class. 249 250 Args: 251 log_path: string, the location of the report file. 252 prefix: optional string, a prefix for each log line in terminal. 253 alias: optional string, The name of the alias to use for the latest log 254 directory. If a falsy value is provided, then the alias directory 255 will not be created, which is useful to save storage space when the 256 storage system (e.g. ZIP files) does not properly support 257 shortcut/symlinks. 258 console_level: optional logging level, log level threshold used for log 259 messages printed to the console. Logs with a level less severe than 260 console_level will not be printed to the console. 261 """ 262 utils.create_dir(log_path) 263 _setup_test_logger(log_path, console_level, prefix) 264 logging.debug('Test output folder: "%s"', log_path) 265 if alias: 266 create_latest_log_alias(log_path, alias=alias) 267 268 269def _truncate_filename(filename, max_length): 270 """Truncates a filename while trying to preserve the extension. 271 272 Args: 273 filename: string, the filename to potentially truncate. 274 275 Returns: 276 The truncated filename that is less than or equal to the given maximum 277 length. 278 """ 279 if len(filename) <= max_length: 280 return filename 281 282 if '.' in filename: 283 filename, extension = filename.rsplit('.', 1) 284 # Subtract one for the extension's period. 285 if len(extension) > max_length - 1: 286 # This is kind of a degrenerate case where the extension is 287 # extremely long, in which case, just return the truncated filename. 288 return filename[:max_length] 289 return '.'.join([filename[: max_length - len(extension) - 1], extension]) 290 else: 291 return filename[:max_length] 292 293 294def _sanitize_windows_filename(filename): 295 """Sanitizes a filename for Windows. 296 297 Refer to the following Windows documentation page for the rules: 298 https://docs.microsoft.com/en-us/windows/win32/fileio/naming-a-file#naming-conventions 299 300 If the filename matches one of Window's reserved file namespaces, then the 301 `WINDOWS_RESERVED_FILENAME_PREFIX` (i.e. "mobly_") prefix will be appended 302 to the filename to convert it into a valid Windows filename. 303 304 Args: 305 filename: string, the filename to sanitize for the Windows file system. 306 307 Returns: 308 A filename that should be safe to use on Windows. 309 """ 310 if re.match(WINDOWS_RESERVED_FILENAME_REGEX, filename): 311 return WINDOWS_RESERVED_FILENAME_PREFIX + filename 312 313 filename = _truncate_filename(filename, WINDOWS_MAX_FILENAME_LENGTH) 314 315 # In order to meet max length, none of these replacements should increase 316 # the length of the filename. 317 new_filename_chars = [] 318 for char in filename: 319 if char in WINDOWS_RESERVED_CHARACTERS_REPLACEMENTS: 320 new_filename_chars.append(WINDOWS_RESERVED_CHARACTERS_REPLACEMENTS[char]) 321 else: 322 new_filename_chars.append(char) 323 filename = ''.join(new_filename_chars) 324 if filename.endswith('.') or filename.endswith(' '): 325 # Filenames cannot end with a period or space on Windows. 326 filename = filename[:-1] + '_' 327 328 return filename 329 330 331def sanitize_filename(filename): 332 """Sanitizes a filename for various operating systems. 333 334 Args: 335 filename: string, the filename to sanitize. 336 337 Returns: 338 A string that is safe to use as a filename on various operating systems. 339 """ 340 # Split `filename` into the directory and filename in case the user 341 # accidentally passed in the full path instead of the name. 342 dirname = os.path.dirname(filename) 343 basename = os.path.basename(filename) 344 basename = _sanitize_windows_filename(basename) 345 basename = _truncate_filename(basename, LINUX_MAX_FILENAME_LENGTH) 346 # Replace spaces with underscores for convenience reasons. 347 basename = basename.replace(' ', '_') 348 return os.path.join(dirname, basename) 349 350 351def normalize_log_line_timestamp(log_line_timestamp): 352 """Replace special characters in log line timestamp with normal characters. 353 354 .. deprecated:: 1.10 355 356 This method is obsolete with the more general `sanitize_filename` method 357 and is only kept for backwards compatibility. In a future update, this 358 method may be removed. 359 360 Args: 361 log_line_timestamp: A string in the log line timestamp format. Obtained 362 with get_log_line_timestamp. 363 364 Returns: 365 A string representing the same time as input timestamp, but without 366 special characters. 367 """ 368 return sanitize_filename(log_line_timestamp) 369 370 371class PrefixLoggerAdapter(logging.LoggerAdapter): 372 """A wrapper that adds a prefix to each log line. 373 374 This logger adapter class is like a decorator to Logger. It takes one 375 Logger-like object and returns a new Logger-like object. The new Logger-like 376 object will print logs with a custom prefix added. Creating new Logger-like 377 objects doesn't modify the behavior of the old Logger-like object. 378 379 Chaining multiple logger adapters is also supported. The multiple adapters 380 will take effect in the order in which they are chained, i.e. the log will be 381 '<prefix1> <prefix2> <prefix3> <message>' if we chain 3 PrefixLoggerAdapters. 382 383 Example Usage: 384 385 .. code-block:: python 386 387 logger = PrefixLoggerAdapter(logging.getLogger(), { 388 'log_prefix': <custom prefix> 389 }) 390 391 Then each log line added by the logger will have a prefix: 392 '<custom prefix> <message>'. 393 """ 394 395 _KWARGS_TYPE = MutableMapping[str, Any] 396 _PROCESS_RETURN_TYPE = Tuple[str, _KWARGS_TYPE] 397 398 # The key of log_preifx item in the dict self.extra 399 EXTRA_KEY_LOG_PREFIX: str = 'log_prefix' 400 401 extra: _KWARGS_TYPE 402 403 def process(self, msg: str, kwargs: _KWARGS_TYPE) -> _PROCESS_RETURN_TYPE: 404 """Processes the logging call to insert contextual information. 405 406 Args: 407 msg: The logging message. 408 kwargs: Keyword arguments passed in to a logging call. 409 410 Returns: 411 The message and kwargs modified. 412 """ 413 new_msg = f'{self.extra[PrefixLoggerAdapter.EXTRA_KEY_LOG_PREFIX]} {msg}' 414 return (new_msg, kwargs) 415 416 def set_log_prefix(self, prefix: str) -> None: 417 """Sets the log prefix to the given string. 418 419 Args: 420 prefix: The new log prefix. 421 """ 422 self.debug('Setting the log prefix to "%s".', prefix) 423 self.extra[PrefixLoggerAdapter.EXTRA_KEY_LOG_PREFIX] = prefix 424