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