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