• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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