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